feat: Lyra App (#27370)
* feat: lyra conference app * fix: lyra -> lyra meeting * api url in env example * feat: lyra oauth * remove setup page * fix: revision on feb 25 review * production api url * fix: final suggestions implementation * yarn.lock revert
This commit is contained in:
+1
-1
@@ -520,4 +520,4 @@ LINKEDIN_ADS_ENABLED=1 # To enable LinkedIn Ads tracking (li_fat_id)
|
||||
B2_APPLICATION_KEY_ID=
|
||||
B2_APPLICATION_KEY=
|
||||
B2_BUCKET_ID=
|
||||
B2_BUCKET_NAME=
|
||||
B2_BUCKET_NAME=
|
||||
@@ -1,6 +1,5 @@
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { DynamicComponent } from "@calcom/app-store/_components/DynamicComponent";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const AppSetupMap = {
|
||||
alby: dynamic(() => import("@calcom/web/components/apps/alby/Setup")),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { appKeysSchema as intercom_zod_ts } from "./intercom/zod";
|
||||
import { appKeysSchema as jelly_zod_ts } from "./jelly/zod";
|
||||
import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
|
||||
import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appKeysSchema as lyra_zod_ts } from "./lyra/zod";
|
||||
import { appKeysSchema as make_zod_ts } from "./make/zod";
|
||||
import { appKeysSchema as matomo_zod_ts } from "./matomo/zod";
|
||||
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
|
||||
@@ -74,6 +75,7 @@ export const appKeysSchemas = {
|
||||
jelly: jelly_zod_ts,
|
||||
jitsivideo: jitsivideo_zod_ts,
|
||||
larkcalendar: larkcalendar_zod_ts,
|
||||
lyra: lyra_zod_ts,
|
||||
make: make_zod_ts,
|
||||
matomo: matomo_zod_ts,
|
||||
metapixel: metapixel_zod_ts,
|
||||
|
||||
@@ -55,6 +55,7 @@ import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
|
||||
import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata";
|
||||
import lindy_config_json from "./lindy/config.json";
|
||||
import linear_config_json from "./linear/config.json";
|
||||
import lyra_config_json from "./lyra/config.json";
|
||||
import make_config_json from "./make/config.json";
|
||||
import matomo_config_json from "./matomo/config.json";
|
||||
import metapixel_config_json from "./metapixel/config.json";
|
||||
@@ -166,6 +167,7 @@ export const appStoreMetadata = {
|
||||
larkcalendar: larkcalendar__metadata_ts,
|
||||
lindy: lindy_config_json,
|
||||
linear: linear_config_json,
|
||||
lyra: lyra_config_json,
|
||||
make: make_config_json,
|
||||
matomo: matomo_config_json,
|
||||
metapixel: metapixel_config_json,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { appDataSchema as intercom_zod_ts } from "./intercom/zod";
|
||||
import { appDataSchema as jelly_zod_ts } from "./jelly/zod";
|
||||
import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
|
||||
import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appDataSchema as lyra_zod_ts } from "./lyra/zod";
|
||||
import { appDataSchema as make_zod_ts } from "./make/zod";
|
||||
import { appDataSchema as matomo_zod_ts } from "./matomo/zod";
|
||||
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
|
||||
@@ -74,6 +75,7 @@ export const appDataSchemas = {
|
||||
jelly: jelly_zod_ts,
|
||||
jitsivideo: jitsivideo_zod_ts,
|
||||
larkcalendar: larkcalendar_zod_ts,
|
||||
lyra: lyra_zod_ts,
|
||||
make: make_zod_ts,
|
||||
matomo: matomo_zod_ts,
|
||||
metapixel: metapixel_zod_ts,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const apiHandlers = {
|
||||
jitsivideo: import("./jitsivideo/api"),
|
||||
larkcalendar: import("./larkcalendar/api"),
|
||||
linear: import("./linear/api"),
|
||||
lyra: import("./lyra/api"),
|
||||
make: import("./make/api"),
|
||||
matomo: import("./matomo/api"),
|
||||
metapixel: import("./metapixel/api"),
|
||||
|
||||
@@ -20,6 +20,7 @@ import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadat
|
||||
import insihts_config_json from "./insihts/config.json";
|
||||
import jelly_config_json from "./jelly/config.json";
|
||||
import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
|
||||
import lyra_config_json from "./lyra/config.json";
|
||||
import matomo_config_json from "./matomo/config.json";
|
||||
import metapixel_config_json from "./metapixel/config.json";
|
||||
import mirotalk_config_json from "./mirotalk/config.json";
|
||||
@@ -65,6 +66,7 @@ export const appStoreMetadata = {
|
||||
insihts: insihts_config_json,
|
||||
jelly: jelly_config_json,
|
||||
jitsivideo: jitsivideo__metadata_ts,
|
||||
lyra: lyra_config_json,
|
||||
matomo: matomo_config_json,
|
||||
metapixel: metapixel_config_json,
|
||||
mirotalk: mirotalk_config_json,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
The meeting platform built for pros. One-click join, no downloads, and no visible bots watching your guests. While your competition switches between five tools, everything happens in Lyra.
|
||||
|
||||
## Your Live AI Teammate
|
||||
|
||||
Ask questions during the call and generate proposals before you hang up. No more copying transcripts into other AI platforms or waiting for recordings to process.
|
||||
|
||||
## Zero-Touch Admin
|
||||
|
||||
Follow-ups write themselves and next steps are captured instantly. Every meeting becomes searchable so you stop wasting your week on manual data entry.
|
||||
|
||||
## Why Teams Switch to Lyra
|
||||
|
||||
- **Stop wasting time on admin** — Proposals, next steps, and follow-ups are ready before you hang up
|
||||
- **Faster rep ramp-up** — New reps lean on Lyra for live product questions and context, selling faster without months of training
|
||||
- **Deals don't disappear** — Every conversation lives in one place. Ask Lyra to catch you up on deal history instantly
|
||||
- **Accurate forecasting** — Deal reality is captured on every call. Know exactly where deals stand without guessing
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Install Lyra from the Cal.com App Store
|
||||
2. Sign in with your Lyra account to authorize Cal.com
|
||||
3. Select Lyra as your conferencing option for event types
|
||||
4. When bookings are made, Lyra meeting links are automatically generated
|
||||
@@ -0,0 +1,37 @@
|
||||
import { stringify } from "node:querystring";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { NextApiRequest } from "next";
|
||||
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
||||
import { getLyraAppKeys, LYRA_API_URL } from "../lib";
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { client_id } = await getLyraAppKeys();
|
||||
const state = encodeOAuthState(req);
|
||||
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/lyra/callback`,
|
||||
scope: "meeting.create",
|
||||
state,
|
||||
};
|
||||
const query = stringify(params);
|
||||
const url = `${LYRA_API_URL}/oauth/authorize?${query}`;
|
||||
return { url };
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
|
||||
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||
import { getLyraAppKeys, LYRA_API_URL } from "../lib";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const state = decodeOAuthState(req);
|
||||
|
||||
// Handle user denial or other OAuth errors gracefully
|
||||
if (req.query.error) {
|
||||
const errorReturnTo = getSafeRedirectUrl(state?.onErrorReturnTo) ?? "/apps/installed/conferencing";
|
||||
res.redirect(errorReturnTo);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.session?.user.id;
|
||||
if (!userId) {
|
||||
return res.status(404).json({ message: "No user found" });
|
||||
}
|
||||
|
||||
const { code } = req.query;
|
||||
const { client_id, client_secret } = await getLyraAppKeys();
|
||||
|
||||
const redirectUri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/lyra/callback`;
|
||||
|
||||
// Exchange authorization code for access token
|
||||
const result = await fetch(`${LYRA_API_URL}/api/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.status !== 200) {
|
||||
let errorMessage = "Something went wrong with the Lyra OAuth flow";
|
||||
try {
|
||||
const responseBody = await result.json();
|
||||
errorMessage = responseBody.error || errorMessage;
|
||||
} catch (_e) {
|
||||
errorMessage = await result.clone().text();
|
||||
}
|
||||
|
||||
res.status(400).json({ message: errorMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
const responseBody = await result.json();
|
||||
|
||||
if (responseBody.error) {
|
||||
res.status(400).json({ message: responseBody.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert expires_in (seconds) to expiry_date (absolute ms timestamp)
|
||||
// This follows Cal.com's universal OAuth token schema
|
||||
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
|
||||
delete responseBody.expires_in;
|
||||
|
||||
// Remove any existing lyra_video credentials for this user to avoid duplicates
|
||||
await prisma.credential.deleteMany({
|
||||
where: { type: "lyra_video", userId, appId: "lyra" },
|
||||
});
|
||||
|
||||
await createOAuthAppCredential({ appId: "lyra", type: "lyra_video" }, responseBody, req);
|
||||
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "lyra" })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Lyra",
|
||||
"title": "Lyra",
|
||||
"slug": "lyra",
|
||||
"type": "lyra_video",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://lyra.so/",
|
||||
"variant": "conferencing",
|
||||
"categories": ["conferencing"],
|
||||
"publisher": "Lyra",
|
||||
"email": "[email protected]",
|
||||
"description": "The meeting platform built for pros.",
|
||||
"isOAuth": true,
|
||||
"appData": {
|
||||
"location": {
|
||||
"linkType": "dynamic",
|
||||
"type": "integrations:lyra",
|
||||
"label": "Lyra Meeting"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as lib from "./lib";
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
} from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
|
||||
|
||||
import { invalidateCredential } from "../../_utils/invalidateCredential";
|
||||
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
|
||||
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
|
||||
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
|
||||
import { LYRA_API_URL } from "./constants";
|
||||
import { getLyraAppKeys } from "./getLyraAppKeys";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[lyra]"] });
|
||||
|
||||
const LyraVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
|
||||
const tokenResponse = getTokenObjectFromCredential(credential);
|
||||
|
||||
const fetchLyraApi = async (endpoint: string, options?: RequestInit) => {
|
||||
const auth = new OAuthManager({
|
||||
credentialSyncVariables: {
|
||||
APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
},
|
||||
resourceOwner: {
|
||||
type: "user",
|
||||
id: credential.userId,
|
||||
},
|
||||
appSlug: "lyra",
|
||||
currentTokenObject: tokenResponse,
|
||||
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const { client_id, client_secret } = await getLyraAppKeys();
|
||||
return fetch(`${LYRA_API_URL}/api/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id,
|
||||
client_secret,
|
||||
}),
|
||||
});
|
||||
},
|
||||
isTokenObjectUnusable: async (response) => {
|
||||
if (!response.ok) {
|
||||
const responseToUseInCaseOfError = response.clone();
|
||||
let responseBody: { error?: string };
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch (_e) {
|
||||
log.error(
|
||||
"Lyra isTokenObjectUnusable: failed to parse response as JSON",
|
||||
safeStringify({
|
||||
status: response.status,
|
||||
body: await responseToUseInCaseOfError.text().catch(() => "<unreadable>"),
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
return { reason: responseBody.error };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
isAccessTokenUnusable: async (response) => {
|
||||
if (response.status === 401) {
|
||||
return { reason: "access_token_expired" };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
invalidateTokenObject: () => invalidateCredential(credential.id),
|
||||
expireAccessToken: () => markTokenAsExpired(credential),
|
||||
updateTokenObject: async (newTokenObject) => {
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
key: newTokenObject as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { json } = await auth.request({
|
||||
url: `${LYRA_API_URL}${endpoint}`,
|
||||
options: {
|
||||
method: "GET",
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return json;
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailability: async () => {
|
||||
return [];
|
||||
},
|
||||
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
|
||||
log.debug("Creating Lyra meeting", { title: event.title });
|
||||
|
||||
try {
|
||||
const data = (await fetchLyraApi("/api/v1/meeting", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: event.title,
|
||||
start: event.startTime,
|
||||
attendees: event.attendees.map((attendee) => attendee.email),
|
||||
}),
|
||||
})) as { id: string; url: string; password?: string };
|
||||
|
||||
log.debug("Lyra meeting created", { id: data.id, url: data.url });
|
||||
|
||||
return {
|
||||
type: "lyra_video",
|
||||
id: data.id,
|
||||
password: data.password || "",
|
||||
url: data.url,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error("Lyra meeting creation failed", safeStringify(err));
|
||||
throw new Error("Failed to create Lyra meeting");
|
||||
}
|
||||
},
|
||||
deleteMeeting: async (uid: string): Promise<void> => {
|
||||
log.debug("Delete meeting called (no-op)", { uid });
|
||||
},
|
||||
updateMeeting: async (bookingRef: PartialReference, _event: CalendarEvent): Promise<VideoCallData> => {
|
||||
log.debug("Update meeting called (no-op)", { uid: bookingRef.uid });
|
||||
return {
|
||||
type: "lyra_video",
|
||||
id: bookingRef.meetingId as string,
|
||||
password: bookingRef.meetingPassword as string,
|
||||
url: bookingRef.meetingUrl as string,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default LyraVideoApiAdapter;
|
||||
@@ -0,0 +1 @@
|
||||
export const LYRA_API_URL = "https://app.lyra.so";
|
||||
@@ -0,0 +1,7 @@
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import { appKeysSchema } from "../zod";
|
||||
|
||||
export const getLyraAppKeys = async () => {
|
||||
const appKeys = await getAppKeysFromSlug("lyra");
|
||||
return appKeysSchema.parse(appKeys);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { LYRA_API_URL } from "./constants";
|
||||
export { getLyraAppKeys } from "./getLyraAppKeys";
|
||||
export { default as VideoApiAdapter } from "./VideoApiAdapter";
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@calcom/lyra",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./index.ts",
|
||||
"description": "The meeting platform built for pros.",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.42703 2.09497H10.5668V6.54706C12.2547 4.2179 13.8669 3.77574 16.4277 3.77574H17.335V7.13838H16.341C14.0222 7.13838 12.7232 7.58856 11.3544 9.1505C12.7413 10.7017 14.0065 11.1914 16.341 11.1914H17.335V14.554H16.4277C13.8669 14.554 12.2547 14.1119 10.5668 11.7827V15.905H7.42703V11.7896C5.7414 14.1131 4.13062 14.554 1.57249 14.554H0.665001V11.1914H1.65898C3.99339 11.1914 5.25883 10.7017 6.64542 9.15081C5.27632 7.58903 3.97779 7.13854 1.65898 7.13854H0.665001V3.77574H1.57233C4.13062 3.77574 5.74125 4.21711 7.42687 6.5403L7.42703 2.09497Z" fill="#FF5207"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 673 B |
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const appKeysSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
client_secret: z.string().min(1),
|
||||
});
|
||||
|
||||
export const appDataSchema = z.object({});
|
||||
@@ -10,6 +10,7 @@ export const VideoApiAdapterMap =
|
||||
huddle01video: import("./huddle01video/lib/VideoApiAdapter"),
|
||||
jelly: import("./jelly/lib/VideoApiAdapter"),
|
||||
jitsivideo: import("./jitsivideo/lib/VideoApiAdapter"),
|
||||
lyra: import("./lyra/lib/VideoApiAdapter"),
|
||||
nextcloudtalk: import("./nextcloudtalk/lib/VideoApiAdapter"),
|
||||
office365video: import("./office365video/lib/VideoApiAdapter"),
|
||||
shimmervideo: import("./shimmervideo/lib/VideoApiAdapter"),
|
||||
|
||||
@@ -2776,6 +2776,16 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/lyra@workspace:packages/app-store/lyra":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/lyra@workspace:packages/app-store/lyra"
|
||||
dependencies:
|
||||
"@calcom/lib": "workspace:*"
|
||||
"@calcom/prisma": "workspace:*"
|
||||
"@calcom/types": "workspace:*"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/make@workspace:packages/app-store/make":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/make@workspace:packages/app-store/make"
|
||||
|
||||
Reference in New Issue
Block a user