blob: 0b3507d455ca0cd478bded3ff81b4d9f00c969e8 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { Category, defaultCategories } from "./categories";
2import { CreateValidators, Validator } from "./config";
giod0026612025-05-08 13:00:36 +00003import { GitHubService, GitHubServiceImpl } from "./github";
4import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange } from "@xyflow/react";
5import { addEdge, applyEdgeChanges, applyNodeChanges, Connection, EdgeChange, useNodes } from "@xyflow/react";
6import type { DeepPartial } from "react-hook-form";
7import { v4 as uuidv4 } from "uuid";
gio5f2f1002025-03-20 18:38:48 +04008import { z } from "zod";
giod0026612025-05-08 13:00:36 +00009import { create } from "zustand";
gio5f2f1002025-03-20 18:38:48 +040010
11export type InitData = {
giod0026612025-05-08 13:00:36 +000012 label: string;
13 envVars: BoundEnvVar[];
14 ports: Port[];
gio5f2f1002025-03-20 18:38:48 +040015};
16
17export type NodeData = InitData & {
giod0026612025-05-08 13:00:36 +000018 activeField?: string | undefined;
19 state: string | null;
gio5f2f1002025-03-20 18:38:48 +040020};
21
22export type PortConnectedTo = {
giod0026612025-05-08 13:00:36 +000023 serviceId: string;
24 portId: string;
25};
gio5f2f1002025-03-20 18:38:48 +040026
gioaba9a962025-04-25 14:19:40 +000027export type NetworkData = NodeData & {
giod0026612025-05-08 13:00:36 +000028 domain: string;
gioaba9a962025-04-25 14:19:40 +000029};
30
31export type NetworkNode = Node<NetworkData> & {
giod0026612025-05-08 13:00:36 +000032 type: "network";
gioaba9a962025-04-25 14:19:40 +000033};
34
gio5f2f1002025-03-20 18:38:48 +040035export type GatewayHttpsData = NodeData & {
giod0026612025-05-08 13:00:36 +000036 network?: string;
37 subdomain?: string;
38 https?: PortConnectedTo;
39 auth?: {
40 enabled: boolean;
41 groups: string[];
42 noAuthPathPatterns: string[];
43 };
gio5f2f1002025-03-20 18:38:48 +040044};
45
46export type GatewayHttpsNode = Node<GatewayHttpsData> & {
giod0026612025-05-08 13:00:36 +000047 type: "gateway-https";
gio5f2f1002025-03-20 18:38:48 +040048};
49
50export type GatewayTCPData = NodeData & {
giod0026612025-05-08 13:00:36 +000051 network?: string;
52 subdomain?: string;
53 exposed: PortConnectedTo[];
54 selected?: {
55 serviceId?: string;
56 portId?: string;
57 };
gio5f2f1002025-03-20 18:38:48 +040058};
59
60export type GatewayTCPNode = Node<GatewayTCPData> & {
giod0026612025-05-08 13:00:36 +000061 type: "gateway-tcp";
gio5f2f1002025-03-20 18:38:48 +040062};
63
64export type Port = {
giod0026612025-05-08 13:00:36 +000065 id: string;
66 name: string;
67 value: number;
gio5f2f1002025-03-20 18:38:48 +040068};
69
gio91165612025-05-03 17:07:38 +000070export const ServiceTypes = [
giod0026612025-05-08 13:00:36 +000071 "deno:2.2.0",
72 "golang:1.20.0",
73 "golang:1.22.0",
74 "golang:1.24.0",
75 "hugo:latest",
76 "php:8.2-apache",
77 "nextjs:deno-2.0.0",
78 "node-23.1.0",
gio91165612025-05-03 17:07:38 +000079] as const;
giod0026612025-05-08 13:00:36 +000080export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +040081
82export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +000083 type: ServiceType;
84 repository:
85 | {
86 id: string;
87 }
88 | {
89 id: string;
90 branch: string;
91 }
92 | {
93 id: string;
94 branch: string;
95 rootDir: string;
96 };
97 env: string[];
98 volume: string[];
99 preBuildCommands: string;
100 isChoosingPortToConnect: boolean;
gio5f2f1002025-03-20 18:38:48 +0400101};
102
103export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000104 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400105};
106
107export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
108
109export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000110 type: VolumeType;
111 size: string;
112 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400113};
114
115export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000116 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400117};
118
119export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000120 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400121};
122
123export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000124 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400125};
126
127export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000128 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400129};
130
131export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000132 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400133};
134
135export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000136 repository?: {
137 id: number;
138 sshURL: string;
139 };
gio5f2f1002025-03-20 18:38:48 +0400140};
141
142export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000143 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400144};
145
146export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000147 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400148};
149
giod0026612025-05-08 13:00:36 +0000150export type AppNode =
151 | NetworkNode
152 | GatewayHttpsNode
153 | GatewayTCPNode
154 | ServiceNode
155 | VolumeNode
156 | PostgreSQLNode
157 | MongoDBNode
158 | GithubNode
159 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400160
161export function nodeLabel(n: AppNode): string {
giod0026612025-05-08 13:00:36 +0000162 switch (n.type) {
163 case "network":
164 return n.data.domain;
165 case "app":
166 return n.data.label || "Service";
167 case "github":
168 return n.data.repository?.sshURL || "Github";
169 case "gateway-https": {
170 if (n.data && n.data.network && n.data.subdomain) {
171 return `https://${n.data.subdomain}.${n.data.network}`;
172 } else {
173 return "HTTPS Gateway";
174 }
175 }
176 case "gateway-tcp": {
177 if (n.data && n.data.network && n.data.subdomain) {
178 return `${n.data.subdomain}.${n.data.network}`;
179 } else {
180 return "TCP Gateway";
181 }
182 }
183 case "mongodb":
184 return n.data.label || "MongoDB";
185 case "postgresql":
186 return n.data.label || "PostgreSQL";
187 case "volume":
188 return n.data.label || "Volume";
189 case undefined:
190 throw new Error("MUST NOT REACH!");
191 }
gio5f2f1002025-03-20 18:38:48 +0400192}
193
194export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000195 switch (n.type) {
196 case "network":
197 return true;
198 case "app":
199 if (handle === "ports") {
200 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
201 } else if (handle === "repository") {
202 if (!n.data || !n.data.repository || !n.data.repository.id) {
203 return true;
204 }
205 return false;
206 }
207 return false;
208 case "github":
209 if (n.data.repository?.id !== undefined) {
210 return true;
211 }
212 return false;
213 case "gateway-https":
214 if (handle === "subdomain") {
215 return n.data.network === undefined;
216 }
217 return n.data === undefined || n.data.https === undefined;
218 case "gateway-tcp":
219 if (handle === "subdomain") {
220 return n.data.network === undefined;
221 }
222 return true;
223 case "mongodb":
224 return true;
225 case "postgresql":
226 return true;
227 case "volume":
228 if (n.data === undefined || n.data.type === undefined) {
229 return false;
230 }
231 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
232 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
233 }
234 return true;
235 case undefined:
236 throw new Error("MUST NOT REACH!");
237 }
gio5f2f1002025-03-20 18:38:48 +0400238}
239
giod0026612025-05-08 13:00:36 +0000240export type BoundEnvVar =
241 | {
242 id: string;
243 source: string | null;
244 }
245 | {
246 id: string;
247 source: string | null;
248 name: string;
249 isEditting: boolean;
250 }
251 | {
252 id: string;
253 source: string | null;
254 name: string;
255 alias: string;
256 isEditting: boolean;
257 }
258 | {
259 id: string;
260 source: string | null;
261 portId: string;
262 name: string;
263 alias: string;
264 isEditting: boolean;
265 };
gio5f2f1002025-03-20 18:38:48 +0400266
267export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000268 name: string;
269 value: string;
gio5f2f1002025-03-20 18:38:48 +0400270};
271
giob41ecae2025-04-24 08:46:50 +0000272export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000273 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000274}
275
gio5f2f1002025-03-20 18:38:48 +0400276export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000277 switch (n.type) {
278 case "app":
279 return [
280 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
281 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
282 ];
283 case "github":
284 return [];
285 case "gateway-https":
286 return [];
287 case "gateway-tcp":
288 return [];
289 case "mongodb":
290 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
291 case "postgresql":
292 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
293 case "volume":
294 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
295 case undefined:
296 throw new Error("MUST NOT REACH");
297 default:
298 throw new Error("MUST NOT REACH");
299 }
gio5f2f1002025-03-20 18:38:48 +0400300}
301
302export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
303
304export type MessageType = "INFO" | "WARNING" | "FATAL";
305
306export type Message = {
giod0026612025-05-08 13:00:36 +0000307 id: string;
308 type: MessageType;
309 nodeId?: string;
310 message: string;
311 onHighlight?: (state: AppState) => void;
312 onLooseHighlight?: (state: AppState) => void;
313 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400314};
315
316export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000317 managerAddr: z.optional(z.string().min(1)),
giod0026612025-05-08 13:00:36 +0000318 deployKey: z.optional(z.string().min(1)),
319 networks: z
320 .array(
321 z.object({
322 name: z.string().min(1),
323 domain: z.string().min(1),
324 }),
325 )
326 .default([]),
327 integrations: z.object({
328 github: z.boolean(),
329 }),
gio5f2f1002025-03-20 18:38:48 +0400330});
331
332export type Env = z.infer<typeof envSchema>;
333
gio7f98e772025-05-07 11:00:14 +0000334const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000335 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000336 deployKey: undefined,
337 networks: [],
338 integrations: {
339 github: false,
340 },
gio7f98e772025-05-07 11:00:14 +0000341};
342
gio5f2f1002025-03-20 18:38:48 +0400343export type Project = {
giod0026612025-05-08 13:00:36 +0000344 id: string;
345 name: string;
346};
gio5f2f1002025-03-20 18:38:48 +0400347
gio7f98e772025-05-07 11:00:14 +0000348export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000349 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000350};
351
352type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
353type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
354
gio5f2f1002025-03-20 18:38:48 +0400355export type AppState = {
giod0026612025-05-08 13:00:36 +0000356 projectId: string | undefined;
357 projects: Project[];
358 nodes: AppNode[];
359 edges: Edge[];
360 categories: Category[];
361 messages: Message[];
362 env: Env;
363 githubService: GitHubService | null;
364 setHighlightCategory: (name: string, active: boolean) => void;
365 onNodesChange: OnNodesChange<AppNode>;
366 onEdgesChange: OnEdgesChange;
367 onConnect: OnConnect;
368 setNodes: (nodes: AppNode[]) => void;
369 setEdges: (edges: Edge[]) => void;
370 setProject: (projectId: string | undefined) => void;
371 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
372 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
373 replaceEdge: (c: Connection, id?: string) => void;
374 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400375};
376
377const projectIdSelector = (state: AppState) => state.projectId;
378const categoriesSelector = (state: AppState) => state.categories;
379const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000380const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400381const envSelector = (state: AppState) => state.env;
382
383export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000384 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400385}
386
387export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000388 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400389}
390
391export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000392 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400393}
394
395export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000396 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400397}
398
399export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000400 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400401}
402
403export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000404 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400405}
406
gio5f2f1002025-03-20 18:38:48 +0400407export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000408 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000409}
410
411export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000412 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400413}
414
415const v: Validator = CreateValidators();
416
417export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000418 const setN = (nodes: AppNode[]) => {
419 set((state) => ({
420 ...state,
421 nodes,
gio5cf364c2025-05-08 16:01:21 +0000422 messages: v(nodes),
giod0026612025-05-08 13:00:36 +0000423 }));
424 };
gio7f98e772025-05-07 11:00:14 +0000425
giod0026612025-05-08 13:00:36 +0000426 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
427 setN(
428 get().nodes.map((n) => {
429 if (n.id === id) {
430 return {
431 ...n,
432 data: {
433 ...n.data,
434 ...data,
435 },
436 } as Extract<AppNode, { type: T }>;
437 }
438 return n;
439 }),
440 );
441 }
gio7f98e772025-05-07 11:00:14 +0000442
giod0026612025-05-08 13:00:36 +0000443 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
444 setN(
445 get().nodes.map((n) => {
446 if (n.id === id) {
447 return {
448 ...n,
449 ...node,
450 } as Extract<AppNode, { type: T }>;
451 }
452 return n;
453 }),
454 );
455 }
gio7f98e772025-05-07 11:00:14 +0000456
giod0026612025-05-08 13:00:36 +0000457 function onConnect(c: Connection) {
458 const { nodes, edges } = get();
459 set({
460 edges: addEdge(c, edges),
461 });
462 const sn = nodes.filter((n) => n.id === c.source)[0]!;
463 const tn = nodes.filter((n) => n.id === c.target)[0]!;
464 if (tn.type === "network") {
465 if (sn.type === "gateway-https") {
466 updateNodeData<"gateway-https">(sn.id, {
467 network: tn.data.domain,
468 });
469 } else if (sn.type === "gateway-tcp") {
470 updateNodeData<"gateway-tcp">(sn.id, {
471 network: tn.data.domain,
472 });
473 }
474 }
475 if (tn.type === "app") {
476 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
477 const sourceEnvVars = nodeEnvVarNames(sn);
478 if (sourceEnvVars.length === 0) {
479 throw new Error("MUST NOT REACH!");
480 }
481 const id = uuidv4();
482 if (sourceEnvVars.length === 1) {
483 updateNode<"app">(c.target, {
484 ...tn,
485 data: {
486 ...tn.data,
487 envVars: [
488 ...(tn.data.envVars || []),
489 {
490 id: id,
491 source: c.source,
492 name: sourceEnvVars[0],
493 isEditting: false,
494 },
495 ],
496 },
497 });
498 } else {
499 updateNode<"app">(c.target, {
500 ...tn,
501 data: {
502 ...tn.data,
503 envVars: [
504 ...(tn.data.envVars || []),
505 {
506 id: id,
507 source: c.source,
508 },
509 ],
510 },
511 });
512 }
513 }
514 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
515 const sourcePorts = sn.data.ports || [];
516 const id = uuidv4();
517 if (sourcePorts.length === 1) {
518 updateNode<"app">(c.target, {
519 ...tn,
520 data: {
521 ...tn.data,
522 envVars: [
523 ...(tn.data.envVars || []),
524 {
525 id: id,
526 source: c.source,
527 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
528 portId: sourcePorts[0].id,
529 isEditting: false,
530 },
531 ],
532 },
533 });
534 }
535 }
536 }
537 if (c.sourceHandle === "volume") {
538 updateNodeData<"volume">(c.source, {
539 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
540 });
541 }
542 if (c.targetHandle === "volume") {
543 if (tn.type === "postgresql" || tn.type === "mongodb") {
544 updateNodeData(c.target, {
545 volumeId: c.source,
546 });
547 }
548 }
549 if (c.targetHandle === "https") {
550 if ((sn.data.ports || []).length === 1) {
551 updateNodeData<"gateway-https">(c.target, {
552 https: {
553 serviceId: c.source,
554 portId: sn.data.ports![0].id,
555 },
556 });
557 } else {
558 updateNodeData<"gateway-https">(c.target, {
559 https: {
560 serviceId: c.source,
561 portId: "", // TODO(gio)
562 },
563 });
564 }
565 }
566 if (c.targetHandle === "tcp") {
567 const td = tn.data as GatewayTCPData;
568 if ((sn.data.ports || []).length === 1) {
569 updateNodeData<"gateway-tcp">(c.target, {
570 exposed: (td.exposed || []).concat({
571 serviceId: c.source,
572 portId: sn.data.ports![0].id,
573 }),
574 });
575 } else {
576 updateNodeData<"gateway-tcp">(c.target, {
577 selected: {
578 serviceId: c.source,
579 portId: undefined,
580 },
581 });
582 }
583 }
584 if (sn.type === "app") {
585 if (c.sourceHandle === "ports") {
586 updateNodeData<"app">(sn.id, {
587 isChoosingPortToConnect: true,
588 });
589 }
590 }
591 if (tn.type === "app") {
592 if (c.targetHandle === "repository") {
593 updateNodeData<"app">(tn.id, {
594 repository: {
595 id: c.source,
596 branch: "master",
597 rootDir: "/",
598 },
599 });
600 }
601 }
602 }
603 return {
604 projectId: undefined,
605 projects: [],
606 nodes: [],
607 edges: [],
608 categories: defaultCategories,
609 messages: v([]),
610 env: defaultEnv,
611 githubService: null,
612 setHighlightCategory: (name, active) => {
613 set({
614 categories: get().categories.map((c) => {
615 if (c.title.toLowerCase() !== name.toLowerCase()) {
616 return c;
617 } else {
618 return {
619 ...c,
620 active,
621 };
622 }
623 }),
624 });
625 },
626 onNodesChange: (changes) => {
627 const nodes = applyNodeChanges(changes, get().nodes);
628 setN(nodes);
629 },
630 onEdgesChange: (changes) => {
631 set({
632 edges: applyEdgeChanges(changes, get().edges),
633 });
634 },
635 setNodes: (nodes) => {
636 setN(nodes);
637 },
638 setEdges: (edges) => {
639 set({ edges });
640 },
641 replaceEdge: (c, id) => {
642 let change: EdgeChange;
643 if (id === undefined) {
644 change = {
645 type: "add",
646 item: {
647 id: uuidv4(),
648 ...c,
649 },
650 };
651 onConnect(c);
652 } else {
653 change = {
654 type: "replace",
655 id,
656 item: {
657 id,
658 ...c,
659 },
660 };
661 }
662 set({
663 edges: applyEdgeChanges([change], get().edges),
664 });
665 },
666 updateNode,
667 updateNodeData,
668 onConnect,
669 refreshEnv: async () => {
670 const projectId = get().projectId;
671 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000672
giod0026612025-05-08 13:00:36 +0000673 try {
674 if (projectId) {
675 const response = await fetch(`/api/project/${projectId}/env`);
676 if (response.ok) {
677 const data = await response.json();
678 const result = envSchema.safeParse(data);
679 if (result.success) {
680 env = result.data;
681 } else {
682 console.error("Invalid env data:", result.error);
683 }
684 }
685 }
686 } catch (error) {
687 console.error("Failed to fetch integrations:", error);
688 } finally {
689 set({ env: env });
690 if (env.integrations.github) {
691 set({ githubService: new GitHubServiceImpl(projectId!) });
692 } else {
693 set({ githubService: null });
694 }
695 }
696 },
697 setProject: (projectId) => {
698 set({
699 projectId,
700 });
701 if (projectId) {
702 get().refreshEnv();
703 }
704 },
705 };
gio5f2f1002025-03-20 18:38:48 +0400706});