onflyt-cli 0.1.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1039 @@
1
+ import React, { useEffect, useState, useCallback } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { readFileSync, existsSync } from "fs";
4
+ import { isLoggedIn } from "../lib/config.js";
5
+ import { GitDetector } from "../lib/git.js";
6
+ import {
7
+ Team,
8
+ Balance,
9
+ ProjectConfig,
10
+ INSTANCE_OPTIONS,
11
+ isPodProject,
12
+ getProjectConfig,
13
+ loadTeamsWithBalances,
14
+ getTeamDetails,
15
+ getTeamLimits,
16
+ getTeamPlanLabel,
17
+ findOrCreateProject,
18
+ updateProjectSettings,
19
+ getProjectDetails,
20
+ startDeployment,
21
+ getDeploymentStatus,
22
+ streamLogs,
23
+ DeploymentStatus,
24
+ deployManual,
25
+ } from "../lib/deploy-api.js";
26
+ import { TIER_HOURLY_PRICE } from "../shared";
27
+ import { api } from "../lib/api.js";
28
+
29
+ type Step =
30
+ | "loading"
31
+ | "team"
32
+ | "config"
33
+ | "instance"
34
+ | "env"
35
+ | "deploying"
36
+ | "done"
37
+ | "error";
38
+
39
+ type DeployStage = "starting" | "zipping" | "uploading" | "deployed";
40
+
41
+ interface Props {
42
+ teamFlag?: string;
43
+ }
44
+
45
+ const Deploy: React.FC<Props> = ({ teamFlag }) => {
46
+ const [step, setStep] = useState<Step>("loading");
47
+ const [errorMsg, setErrorMsg] = useState("");
48
+
49
+ useEffect(() => {
50
+ const handleSigInt = () => process.exit(0);
51
+ process.on("SIGINT", handleSigInt);
52
+ return () => {
53
+ process.off("SIGINT", handleSigInt);
54
+ };
55
+ }, []);
56
+
57
+ useEffect(() => {
58
+ if (step === "done" || step === "error") {
59
+ const timer = setTimeout(() => process.exit(0), 500);
60
+ return () => clearTimeout(timer);
61
+ }
62
+ }, [step]);
63
+ const [teams, setTeams] = useState<Team[]>([]);
64
+ const [selectedTeamIndex, setSelectedTeamIndex] = useState(0);
65
+ const [balances, setBalances] = useState<Record<string, Balance>>({});
66
+ const [projectConfig, setProjectConfig] = useState<ProjectConfig | null>(
67
+ null,
68
+ );
69
+ const [selectedInstanceIndex, setSelectedInstanceIndex] = useState(2);
70
+ const [replicas, setReplicas] = useState(1);
71
+ const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
72
+ [],
73
+ );
74
+ const [envMessage, setEnvMessage] = useState<string>("");
75
+ const [deploymentId, setDeploymentId] = useState<string>("");
76
+ const [deploymentStatus, setDeploymentStatus] =
77
+ useState<DeploymentStatus>("queued");
78
+ const [liveLogs, setLiveLogs] = useState<string[]>([]);
79
+ const [previewUrl, setPreviewUrl] = useState<string>("");
80
+ const [liveUrl, setLiveUrl] = useState<string>("");
81
+ const [projectId, setProjectId] = useState<string>("");
82
+ const [dotCount, setDotCount] = useState(0);
83
+ const [existingProject, setExistingProject] = useState<any>(null);
84
+ const [isNewProject, setIsNewProject] = useState(false);
85
+ const [projectInstanceSize, setProjectInstanceSize] = useState<string>("");
86
+ const [projectReplicas, setProjectReplicas] = useState<number>(1);
87
+ const [totalLifetimeSpend, setTotalLifetimeSpend] = useState<number>(0);
88
+ const [currentBalance, setCurrentBalance] = useState<{
89
+ balanceUSD: number;
90
+ balanceFormatted: string;
91
+ }>({ balanceUSD: 0, balanceFormatted: "$0.00" });
92
+ const [hasGitRemote, setHasGitRemote] = useState(false);
93
+ const [deployStage, setDeployStage] = useState<DeployStage>("starting");
94
+ const [uploadProgress, setUploadProgress] = useState({
95
+ uploaded: 0,
96
+ total: 0,
97
+ });
98
+ const [zipSize, setZipSize] = useState<number | null>(null);
99
+
100
+ const selectedTeam = teams[selectedTeamIndex];
101
+ const limits = getTeamLimits(totalLifetimeSpend);
102
+ const planLabel = getTeamPlanLabel(totalLifetimeSpend);
103
+ const needsPod = isPodProject(projectConfig?.framework);
104
+
105
+ const selectedInstance = INSTANCE_OPTIONS[selectedInstanceIndex];
106
+ const estimatedHourlyCost =
107
+ needsPod && selectedInstance
108
+ ? TIER_HOURLY_PRICE[selectedInstance.id] * replicas
109
+ : null;
110
+
111
+ const formatEstimatedCost = (cost: number): string => {
112
+ if (cost === 0) return "Free";
113
+ return `$${cost.toFixed(3)}/hr`;
114
+ };
115
+
116
+ useEffect(() => {
117
+ if (step !== "loading") return;
118
+
119
+ if (!isLoggedIn()) {
120
+ setErrorMsg("Not logged in. Run 'onflyt login' first.");
121
+ setStep("error");
122
+ return;
123
+ }
124
+
125
+ const config = getProjectConfig();
126
+ if (!config) {
127
+ setErrorMsg("No onflyt.json found. Run 'onflyt init' first.");
128
+ setStep("error");
129
+ return;
130
+ }
131
+ setProjectConfig(config);
132
+
133
+ const loadData = async () => {
134
+ try {
135
+ const gitDetector = new GitDetector(process.cwd());
136
+ const gitInfo = await gitDetector.detect();
137
+ setHasGitRemote(gitInfo.isGitRepo && gitInfo.remotes.length > 0);
138
+
139
+ const { teams: loadedTeams, balances: loadedBalances } =
140
+ await loadTeamsWithBalances();
141
+ setTeams(loadedTeams);
142
+ setBalances(loadedBalances);
143
+
144
+ const selectedIdx = teamFlag
145
+ ? loadedTeams.findIndex(
146
+ (t) => t.team.id === teamFlag || t.team.slug === teamFlag,
147
+ )
148
+ : 0;
149
+ if (selectedIdx >= 0) setSelectedTeamIndex(selectedIdx);
150
+
151
+ const selectedTeamForProject =
152
+ loadedTeams[selectedIdx >= 0 ? selectedIdx : 0];
153
+
154
+ if (selectedTeamForProject) {
155
+ try {
156
+ const teamDetails = await getTeamDetails(
157
+ selectedTeamForProject.team.id,
158
+ );
159
+ setTotalLifetimeSpend(teamDetails.totalLifetimeSpend);
160
+ setCurrentBalance({
161
+ balanceUSD: teamDetails.balanceUSD,
162
+ balanceFormatted:
163
+ loadedBalances[selectedTeamForProject.team.id]
164
+ ?.balanceFormatted || "$0.00",
165
+ });
166
+ } catch {
167
+ setTotalLifetimeSpend(0);
168
+ setCurrentBalance(
169
+ loadedBalances[selectedTeamForProject.team.id] || {
170
+ balanceUSD: 0,
171
+ balanceFormatted: "$0.00",
172
+ },
173
+ );
174
+ }
175
+
176
+ try {
177
+ const projectsRes = await api.get<any>(
178
+ `/projects/team/${selectedTeamForProject.team.id}`,
179
+ );
180
+ const existingProject = (projectsRes.projects || []).find(
181
+ (p: any) => p.name === config.name,
182
+ );
183
+ if (existingProject) {
184
+ const fullProject = await getProjectDetails(existingProject.id);
185
+ setExistingProject(fullProject);
186
+ setProjectId(fullProject.id);
187
+ setIsNewProject(false);
188
+ setProjectInstanceSize(fullProject.instanceSize || "micro");
189
+ setProjectReplicas(fullProject.maxInstances || 1);
190
+ const instanceIdx = INSTANCE_OPTIONS.findIndex(
191
+ (i) => i.id === (fullProject.instanceSize || "micro"),
192
+ );
193
+ setSelectedInstanceIndex(instanceIdx >= 0 ? instanceIdx : 2);
194
+ } else {
195
+ setIsNewProject(true);
196
+ }
197
+ } catch {
198
+ setIsNewProject(true);
199
+ }
200
+ }
201
+
202
+ setStep("team");
203
+ } catch (err: any) {
204
+ setErrorMsg(err.message || "Failed to load data");
205
+ setStep("error");
206
+ }
207
+ };
208
+
209
+ loadData();
210
+ }, [step, teamFlag]);
211
+
212
+ useEffect(() => {
213
+ if (step !== "deploying") return;
214
+ if (deploymentStatus === "deployed" || deploymentStatus === "failed")
215
+ return;
216
+
217
+ const interval = setInterval(() => {
218
+ setDotCount((c) => (c + 1) % 4);
219
+ }, 500);
220
+
221
+ return () => clearInterval(interval);
222
+ }, [step, deploymentStatus]);
223
+
224
+ const handleDeploy = useCallback(async () => {
225
+ if (!selectedTeam || !projectConfig) return;
226
+
227
+ try {
228
+ const project = await findOrCreateProject(
229
+ selectedTeam.team.id,
230
+ projectConfig.name,
231
+ projectConfig.framework,
232
+ projectConfig.gitRepoUrl,
233
+ );
234
+
235
+ if (!project.isNew) {
236
+ const fullProject = await getProjectDetails(project.id);
237
+ setExistingProject(fullProject);
238
+ setProjectId(project.id);
239
+ setIsNewProject(false);
240
+ const existingSize = fullProject.instanceSize || "micro";
241
+ const existingReplicas = fullProject.maxInstances || 1;
242
+ setProjectInstanceSize(existingSize);
243
+ setProjectReplicas(existingReplicas);
244
+ if (needsPod) {
245
+ const instanceIdx = INSTANCE_OPTIONS.findIndex(
246
+ (i) => i.id === existingSize,
247
+ );
248
+ setSelectedInstanceIndex(instanceIdx >= 0 ? instanceIdx : 2);
249
+ setReplicas(existingReplicas);
250
+ setStep("instance");
251
+ } else {
252
+ setStep("env");
253
+ }
254
+ } else {
255
+ setProjectId(project.id);
256
+ setIsNewProject(true);
257
+ if (needsPod) {
258
+ setStep("instance");
259
+ } else {
260
+ setStep("env");
261
+ }
262
+ }
263
+ } catch (err: any) {
264
+ setErrorMsg(err.message);
265
+ setStep("error");
266
+ }
267
+ }, [selectedTeam, projectConfig, needsPod]);
268
+
269
+ const triggerDeployment = useCallback(
270
+ async (projId: string) => {
271
+ setStep("deploying");
272
+ setDeployStage("starting");
273
+ setUploadProgress({ uploaded: 0, total: 0 });
274
+ setZipSize(null);
275
+
276
+ const hasConfiguredGit = !!projectConfig?.gitRepoUrl;
277
+
278
+ if (hasConfiguredGit) {
279
+ try {
280
+ const depId = await startDeployment(
281
+ projId,
282
+ projectConfig?.gitBranch || "main",
283
+ needsPod ? selectedInstance?.id : undefined,
284
+ needsPod ? replicas : undefined,
285
+ envVars.length > 0 ? envVars : undefined,
286
+ );
287
+
288
+ setDeploymentId(depId);
289
+ pollStatus(depId);
290
+ } catch (err: any) {
291
+ setStep("error");
292
+ setErrorMsg(err.message);
293
+ }
294
+ return;
295
+ }
296
+
297
+ const { join } = await import("path");
298
+ const { tmpdir } = await import("os");
299
+ const { statSync } = await import("fs");
300
+ const zipPath = join(tmpdir(), `onflyt-deploy-${Date.now()}.zip`);
301
+
302
+ setDeployStage("zipping");
303
+
304
+ setTimeout(async () => {
305
+ try {
306
+ const { createProjectZip } = await import("../lib/deploy-api.js");
307
+ await createProjectZip(process.cwd(), zipPath);
308
+
309
+ const size = statSync(zipPath).size;
310
+ setZipSize(size);
311
+ console.log(
312
+ `\x1b[90m Archive size: ${(size / 1024 / 1024).toFixed(1)} MB\x1b[0m`,
313
+ );
314
+
315
+ if (size > 100 * 1024 * 1024) {
316
+ console.log(`\x1b[33m Warning: File exceeds 100MB limit\x1b[0m`);
317
+ }
318
+
319
+ setDeployStage("uploading");
320
+ const { deployManual } = await import("../lib/deploy-api.js");
321
+ const depId = await deployManual(
322
+ projId,
323
+ zipPath,
324
+ projectConfig?.framework,
325
+ needsPod ? selectedInstance?.id : undefined,
326
+ needsPod ? replicas : undefined,
327
+ envVars.length > 0 ? envVars : undefined,
328
+ (uploaded, total) => {
329
+ setUploadProgress({ uploaded, total });
330
+ },
331
+ {
332
+ buildCommand: projectConfig?.buildCommand,
333
+ outputDirectory: projectConfig?.outputDirectory,
334
+ installCommand: projectConfig?.installCommand,
335
+ },
336
+ );
337
+
338
+ setDeploymentId(depId);
339
+ pollStatus(depId);
340
+ } catch (err: any) {
341
+ setStep("error");
342
+ setErrorMsg(err.message);
343
+ }
344
+ }, 100);
345
+ },
346
+ [projectConfig, selectedInstance, replicas, envVars, needsPod],
347
+ );
348
+
349
+ const handleUpdateAndDeploy = useCallback(async () => {
350
+ try {
351
+ if (needsPod) {
352
+ const instanceToUse = selectedInstance?.id || projectInstanceSize;
353
+ const replicasToUse = replicas || projectReplicas;
354
+ if (instanceToUse || replicasToUse) {
355
+ await updateProjectSettings(
356
+ projectId,
357
+ instanceToUse as any,
358
+ replicasToUse,
359
+ );
360
+ }
361
+ }
362
+ setStep("deploying");
363
+ triggerDeployment(projectId);
364
+ } catch (err: any) {
365
+ setErrorMsg(err.message);
366
+ setStep("error");
367
+ }
368
+ }, [
369
+ projectId,
370
+ projectInstanceSize,
371
+ projectReplicas,
372
+ selectedInstance,
373
+ replicas,
374
+ needsPod,
375
+ triggerDeployment,
376
+ ]);
377
+
378
+ const pollStatus = useCallback(
379
+ (depId: string) => {
380
+ const poll = async () => {
381
+ try {
382
+ const details = await getDeploymentStatus(depId);
383
+ setDeploymentStatus(details.status);
384
+
385
+ if (details.status === "building" && liveLogs.length === 0) {
386
+ if (limits.enableLogStreaming) {
387
+ streamLogs(
388
+ depId,
389
+ (log) => {
390
+ setLiveLogs((prev) => [...prev.slice(-200), log]);
391
+ },
392
+ () => {
393
+ console.log("\x1b[33m⚠ Live logs unavailable\x1b[0m");
394
+ },
395
+ );
396
+ }
397
+ }
398
+
399
+ if (details.status === "live") {
400
+ setLiveUrl(
401
+ details.url || `https://${projectConfig?.name}.onflyt.dev`,
402
+ );
403
+ setPreviewUrl(details.previewUrl || "");
404
+ setStep("done");
405
+ return;
406
+ }
407
+
408
+ if (details.status === "failed") {
409
+ setErrorMsg(
410
+ "Deployment failed. Run 'onflyt logs " + depId + "' for details.",
411
+ );
412
+ setStep("error");
413
+ return;
414
+ }
415
+
416
+ setTimeout(() => poll(), 3000);
417
+ } catch (err: any) {
418
+ setTimeout(() => poll(), 5000);
419
+ }
420
+ };
421
+
422
+ poll();
423
+ },
424
+ [liveLogs.length, limits.enableLogStreaming, projectConfig?.name],
425
+ );
426
+
427
+ useInput((input, key) => {
428
+ if (input === "q" || input === "Q" || (key.ctrl && input === "c")) {
429
+ process.exit(0);
430
+ }
431
+
432
+ if (key.escape) {
433
+ if (step === "instance") setStep("config");
434
+ if (step === "env") setStep(needsPod ? "instance" : "config");
435
+ return;
436
+ }
437
+
438
+ if (key.return) {
439
+ if (step === "team") {
440
+ setStep("config");
441
+ } else if (step === "config") {
442
+ handleDeploy();
443
+ } else if (step === "instance") {
444
+ setStep("env");
445
+ } else if (step === "env") {
446
+ triggerDeployment(projectId);
447
+ }
448
+ return;
449
+ }
450
+
451
+ if (step === "config" && input === "1" && needsPod) {
452
+ setStep("instance");
453
+ return;
454
+ }
455
+
456
+ if (step === "team") {
457
+ if (key.upArrow) {
458
+ setSelectedTeamIndex((i) => Math.max(0, i - 1));
459
+ } else if (key.downArrow) {
460
+ setSelectedTeamIndex((i) => Math.min(teams.length - 1, i + 1));
461
+ }
462
+ return;
463
+ }
464
+
465
+ if (step === "instance") {
466
+ if (key.upArrow) {
467
+ setSelectedInstanceIndex((i) => Math.max(0, i - 1));
468
+ } else if (key.downArrow) {
469
+ setSelectedInstanceIndex((i) =>
470
+ Math.min(INSTANCE_OPTIONS.length - 1, i + 1),
471
+ );
472
+ } else if (key.leftArrow) {
473
+ setReplicas((r) => Math.max(1, r - 1));
474
+ } else if (key.rightArrow) {
475
+ setReplicas((r) =>
476
+ Math.min(INSTANCE_OPTIONS[selectedInstanceIndex].maxReplicas, r + 1),
477
+ );
478
+ }
479
+ return;
480
+ }
481
+
482
+ if (step === "env") {
483
+ if (input === "1") {
484
+ handleImportEnv();
485
+ } else if (input === "2") {
486
+ handleAddEnv();
487
+ } else if (input === "3") {
488
+ handleAddEnv();
489
+ }
490
+ return;
491
+ }
492
+
493
+ if (input === "q" || input === "Q") {
494
+ process.exit(0);
495
+ }
496
+ });
497
+
498
+ const handleImportEnv = () => {
499
+ try {
500
+ const projectPath = process.cwd();
501
+ const envPath = `${projectPath}/.env`;
502
+ if (existsSync(envPath)) {
503
+ const content = readFileSync(envPath, "utf-8");
504
+ const vars = content
505
+ .split("\n")
506
+ .filter((line: string) => line.includes("=") && !line.startsWith("#"))
507
+ .map((line: string) => {
508
+ const [key, ...rest] = line.split("=");
509
+ return { key: key.trim(), value: rest.join("=").trim() };
510
+ });
511
+ setEnvVars(vars);
512
+ }
513
+ } catch {}
514
+ handleUpdateAndDeploy();
515
+ };
516
+
517
+ const handleAddEnv = () => {
518
+ handleUpdateAndDeploy();
519
+ };
520
+
521
+ if (step === "loading") {
522
+ return (
523
+ <Box flexDirection="column">
524
+ <Text bold color="rgb(255,191,0)">
525
+ <Text>DEPLOY</Text>
526
+ </Text>
527
+ <Box marginTop={1}>
528
+ <Text>Loading...</Text>
529
+ </Box>
530
+ </Box>
531
+ );
532
+ }
533
+
534
+ if (step === "team") {
535
+ return (
536
+ <Box flexDirection="column" padding={1}>
537
+ <Text bold color="rgb(255,191,0)">
538
+ <Text>DEPLOY</Text>
539
+ </Text>
540
+
541
+ <Box marginTop={1} flexDirection="column">
542
+ <Text bold>Select Team</Text>
543
+ <Text dimColor>Use ↑↓ to navigate, Enter to confirm</Text>
544
+
545
+ {teams.map((team, idx) => (
546
+ <Box key={team.team.id} marginTop={1}>
547
+ <Text color={idx === selectedTeamIndex ? "cyan" : "gray"}>
548
+ {idx === selectedTeamIndex ? "▶ " : " "}
549
+ </Text>
550
+ <Text bold={idx === selectedTeamIndex}>{team.team.name}</Text>
551
+ <Text dimColor> ({team.role})</Text>
552
+ </Box>
553
+ ))}
554
+ </Box>
555
+
556
+ <Box marginTop={2}>
557
+ <Text dimColor>[Enter] Deploy to this team [Esc] Cancel</Text>
558
+ </Box>
559
+ </Box>
560
+ );
561
+ }
562
+
563
+ if (step === "error") {
564
+ return (
565
+ <Box flexDirection="column">
566
+ <Text bold color="rgb(255,191,0)">
567
+ <Text>DEPLOY</Text>
568
+ </Text>
569
+ <Box marginTop={1}>
570
+ <Text bold color="red">
571
+ ✖ Error
572
+ </Text>
573
+ </Box>
574
+ <Box marginTop={1}>
575
+ <Text color="red">{errorMsg}</Text>
576
+ </Box>
577
+ </Box>
578
+ );
579
+ }
580
+
581
+ if (step === "config") {
582
+ return (
583
+ <Box flexDirection="column" padding={1}>
584
+ <Text bold color="rgb(255,191,0)">
585
+ <Text>DEPLOY</Text>
586
+ </Text>
587
+
588
+ <Box marginTop={1} flexDirection="column">
589
+ <Text bold>┌─ Project Configuration</Text>
590
+ <Box marginTop={1}>
591
+ <Text color="cyan"> │ Name: </Text>
592
+ <Text bold>{projectConfig?.name || "Unknown"}</Text>
593
+ </Box>
594
+ <Box>
595
+ <Text color="cyan"> │ Type: </Text>
596
+ <Text bold color="yellow">
597
+ {projectConfig?.framework?.toUpperCase() || "STATIC"}
598
+ </Text>
599
+ {needsPod && <Text color="magenta"> (Onflyt Pod)</Text>}
600
+ </Box>
601
+ <Box>
602
+ <Text color="cyan"> │ Deploy: </Text>
603
+ <Text color={hasGitRemote ? "green" : "yellow"}>
604
+ {hasGitRemote ? "Git-based" : "Manual (ZIP)"}
605
+ </Text>
606
+ </Box>
607
+ <Box>
608
+ <Text color="cyan"> └─ Team: </Text>
609
+ <Text>{selectedTeam?.team.name}</Text>
610
+ </Box>
611
+ </Box>
612
+
613
+ <Box marginTop={2} flexDirection="column">
614
+ <Text bold>┌─ Usage Plan</Text>
615
+ <Box marginTop={1}>
616
+ <Text color="cyan"> │ Plan: </Text>
617
+ <Text bold color="yellow">
618
+ {planLabel}
619
+ </Text>
620
+ </Box>
621
+ <Box>
622
+ <Text color="cyan"> │ Credits: </Text>
623
+ <Text>{currentBalance.balanceFormatted}</Text>
624
+ </Box>
625
+ <Box>
626
+ <Text color="cyan"> │ Max Replicas: </Text>
627
+ <Text>{limits.maxInstancesPerProject}</Text>
628
+ </Box>
629
+ <Box>
630
+ <Text color="cyan"> └─ Build Time: </Text>
631
+ <Text>{limits.maxBuildMinutes} min</Text>
632
+ </Box>
633
+ </Box>
634
+
635
+ {needsPod && (
636
+ <Box marginTop={2} flexDirection="column">
637
+ <Text bold>┌─ Onflyt Pod</Text>
638
+ <Box marginTop={1}>
639
+ <Text color="cyan"> │ Instance: </Text>
640
+ <Text color="yellow">{selectedInstance?.label || "not set"}</Text>
641
+ </Box>
642
+ <Box>
643
+ <Text color="cyan"> │ Replicas: </Text>
644
+ <Text color="yellow">{replicas}</Text>
645
+ </Box>
646
+ <Box>
647
+ <Text color="cyan"> └─ Est. Cost: </Text>
648
+ <Text color="yellow">
649
+ {estimatedHourlyCost !== null
650
+ ? formatEstimatedCost(estimatedHourlyCost)
651
+ : "Free"}
652
+ </Text>
653
+ </Box>
654
+ </Box>
655
+ )}
656
+
657
+ <Box marginTop={2}>
658
+ <Text bold color="gray">
659
+ {" "}
660
+ ─────────────────────────────────────────────
661
+ </Text>
662
+ </Box>
663
+
664
+ <Box marginTop={2} flexDirection="column">
665
+ {needsPod && (
666
+ <Box marginBottom={1}>
667
+ <Text color="gray"> </Text>
668
+ <Text bold color="cyan">
669
+ [1]
670
+ </Text>
671
+ <Text color="gray"> Configure Onflyt Pod</Text>
672
+ </Box>
673
+ )}
674
+ <Box>
675
+ <Text color="gray"> </Text>
676
+ <Text bold color="green">
677
+
678
+ </Text>
679
+ <Text color="gray"> </Text>
680
+ <Text bold color="green">
681
+ [Enter] Deploy now
682
+ </Text>
683
+ </Box>
684
+ <Box marginTop={1}>
685
+ <Text color="gray"> </Text>
686
+ <Text color="gray">[q] Quit</Text>
687
+ </Box>
688
+ </Box>
689
+ </Box>
690
+ );
691
+ }
692
+
693
+ if (step === "instance") {
694
+ return (
695
+ <Box flexDirection="column" padding={1}>
696
+ <Text bold color="rgb(255,191,0)">
697
+ <Text>DEPLOY</Text>
698
+ </Text>
699
+
700
+ <Box marginTop={1} flexDirection="column">
701
+ <Text bold>┌─ Onflyt Pod Configuration</Text>
702
+ <Text color="gray"> Choose instance size and replica count</Text>
703
+
704
+ <Box marginTop={1} flexDirection="column">
705
+ {INSTANCE_OPTIONS.map((option, idx) => (
706
+ <Box key={option.id}>
707
+ <Text color="gray"> </Text>
708
+ <Text
709
+ bold={selectedInstanceIndex === idx}
710
+ color={selectedInstanceIndex === idx ? "cyan" : "gray"}
711
+ >
712
+ {selectedInstanceIndex === idx ? "▸" : " "}[{idx + 1}]{" "}
713
+ {option.label.padEnd(10)}
714
+ </Text>
715
+ <Text color="gray">
716
+ {option.cpu} CPU, {option.ram} RAM, {option.disk} Disk
717
+ </Text>
718
+ <Text color="yellow"> - {option.hourly}</Text>
719
+ </Box>
720
+ ))}
721
+ </Box>
722
+
723
+ <Box marginTop={1}>
724
+ <Text color="cyan"> │ Selected: </Text>
725
+ <Text bold color="cyan">
726
+ {INSTANCE_OPTIONS[selectedInstanceIndex].label}
727
+ </Text>
728
+ <Text color="gray">
729
+ {" "}
730
+ ({INSTANCE_OPTIONS[selectedInstanceIndex].cpu} CPU,{" "}
731
+ {INSTANCE_OPTIONS[selectedInstanceIndex].ram} RAM)
732
+ </Text>
733
+ </Box>
734
+
735
+ <Box marginTop={1}>
736
+ <Text color="cyan"> └─ Replicas: </Text>
737
+ <Text bold color="cyan">
738
+ {replicas}
739
+ </Text>
740
+ <Text color="gray">
741
+ {" "}
742
+ (max{" "}
743
+ {Math.min(
744
+ limits.maxInstancesPerProject,
745
+ INSTANCE_OPTIONS[selectedInstanceIndex].maxReplicas,
746
+ )}
747
+ )
748
+ </Text>
749
+ </Box>
750
+ </Box>
751
+
752
+ <Box marginTop={2}>
753
+ <Text color="gray">
754
+ {" "}
755
+ ↑↓ Change instance ←→ Change replicas [Enter] Continue [Esc] Back
756
+ </Text>
757
+ </Box>
758
+ </Box>
759
+ );
760
+ }
761
+
762
+ if (step === "env") {
763
+ return (
764
+ <Box flexDirection="column" padding={1}>
765
+ <Text bold color="rgb(255,191,0)">
766
+ <Text>DEPLOY</Text>
767
+ </Text>
768
+
769
+ <Box marginTop={1} flexDirection="column">
770
+ <Text bold>┌─ Environment Variables</Text>
771
+ <Text color="gray"> Import from .env or add manually</Text>
772
+
773
+ <Box marginTop={1}>
774
+ <Text color="gray"> </Text>
775
+ <Text bold color="cyan">
776
+ [1]
777
+ </Text>
778
+ <Text color="gray"> Import from .env</Text>
779
+ </Box>
780
+ <Box>
781
+ <Text color="gray"> </Text>
782
+ <Text bold color="cyan">
783
+ [2]
784
+ </Text>
785
+ <Text color="gray"> Add manually</Text>
786
+ </Box>
787
+ <Box>
788
+ <Text color="gray"> </Text>
789
+ <Text bold color="cyan">
790
+ [3]
791
+ </Text>
792
+ <Text color="gray"> Skip (use existing)</Text>
793
+ </Box>
794
+ </Box>
795
+
796
+ <Box marginTop={2}>
797
+ <Text color="gray"> [1/2/3] Select option [Esc] Back</Text>
798
+ </Box>
799
+
800
+ {envMessage && (
801
+ <Box marginTop={1}>
802
+ <Text color={envMessage.includes("✓") ? "green" : "yellow"}>
803
+ {envMessage}
804
+ </Text>
805
+ </Box>
806
+ )}
807
+ </Box>
808
+ );
809
+ }
810
+
811
+ if (step === "deploying") {
812
+ const dots = ".".repeat(dotCount);
813
+ const statusColors: Record<DeploymentStatus, string> = {
814
+ queued: "yellow",
815
+ building: "cyan",
816
+ provisioning: "magenta",
817
+ deployed: "green",
818
+ failed: "red",
819
+ };
820
+ const statusLabels: Record<DeploymentStatus, string> = {
821
+ queued: "QUEUED",
822
+ building: "BUILDING",
823
+ provisioning: "PROVISIONING",
824
+ deployed: "DEPLOYED",
825
+ failed: "FAILED",
826
+ };
827
+ const statusIcons: Record<DeploymentStatus, string> = {
828
+ queued: "⏳",
829
+ building: "🔨",
830
+ provisioning: "⚙️",
831
+ deployed: "✅",
832
+ failed: "❌",
833
+ };
834
+
835
+ const progressPercent =
836
+ uploadProgress.total > 0
837
+ ? Math.round((uploadProgress.uploaded / uploadProgress.total) * 100)
838
+ : 0;
839
+
840
+ const getStageInfo = (): { icon: string; label: string; color: string } => {
841
+ switch (deployStage) {
842
+ case "starting":
843
+ return { icon: "⏳", label: "Starting deployment", color: "cyan" };
844
+ case "zipping":
845
+ return { icon: "📦", label: "Creating ZIP archive", color: "cyan" };
846
+ case "uploading":
847
+ if (progressPercent >= 100) {
848
+ return { icon: "✅", label: "Upload completed", color: "green" };
849
+ }
850
+ return {
851
+ icon: "⬆️",
852
+ label: `Uploading ${progressPercent}%`,
853
+ color: "cyan",
854
+ };
855
+ case "deployed":
856
+ return { icon: "✅", label: "Deployed", color: "green" };
857
+ default:
858
+ return { icon: "⏳", label: "Processing", color: "cyan" };
859
+ }
860
+ };
861
+
862
+ const stageInfo = getStageInfo();
863
+
864
+ const formatBytes = (bytes: number): string => {
865
+ if (bytes === 0) return "0 B";
866
+ const k = 1024;
867
+ const sizes = ["B", "KB", "MB", "GB"];
868
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
869
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
870
+ };
871
+
872
+ const progressBar = (percent: number, width: number = 30): string => {
873
+ const filled = Math.round((percent / 100) * width);
874
+ const empty = width - filled;
875
+ return "█".repeat(filled) + "░".repeat(empty);
876
+ };
877
+
878
+ const showProgressBar =
879
+ deployStage === "uploading" && zipSize && progressPercent < 100;
880
+
881
+ return (
882
+ <Box flexDirection="column" padding={1}>
883
+ <Text bold color="rgb(255,191,0)">
884
+ <Text>DEPLOY</Text>
885
+ </Text>
886
+
887
+ <Box marginTop={2}>
888
+ <Text bold color={stageInfo.color}>
889
+ {stageInfo.icon} {stageInfo.label}
890
+ </Text>
891
+ <Text dimColor>{dots}</Text>
892
+ </Box>
893
+
894
+ {deployStage === "zipping" && (
895
+ <Box marginTop={1}>
896
+ <Text dimColor>Compressing files...</Text>
897
+ </Box>
898
+ )}
899
+
900
+ {showProgressBar && (
901
+ <Box marginTop={1} flexDirection="column">
902
+ <Box>
903
+ <Text dimColor>[{progressBar(progressPercent)}]</Text>
904
+ </Box>
905
+ <Box>
906
+ <Text dimColor>
907
+ {formatBytes(uploadProgress.uploaded)} /{" "}
908
+ {formatBytes(uploadProgress.total)}
909
+ </Text>
910
+ </Box>
911
+ </Box>
912
+ )}
913
+
914
+ <Box marginTop={2}>
915
+ <Text bold color="cyan">
916
+ Status:{" "}
917
+ </Text>
918
+ <Text bold color={statusColors[deploymentStatus]}>
919
+ {statusIcons[deploymentStatus]} {statusLabels[deploymentStatus]}
920
+ </Text>
921
+ <Text dimColor>{dots}</Text>
922
+ </Box>
923
+ {deploymentId && (
924
+ <Box>
925
+ <Text bold color="cyan">
926
+ ID:
927
+ </Text>
928
+ <Text> {deploymentId}</Text>
929
+ </Box>
930
+ )}
931
+ <Box>
932
+ <Text bold color="cyan">
933
+ Project:
934
+ </Text>
935
+ <Text> {projectConfig?.name}</Text>
936
+ </Box>
937
+
938
+ {liveLogs.length > 0 && (
939
+ <Box marginTop={2} flexDirection="column">
940
+ <Text bold color="cyan">
941
+ Build Logs:
942
+ </Text>
943
+ <Box
944
+ flexDirection="column"
945
+ marginTop={1}
946
+ padding={1}
947
+ borderStyle="round"
948
+ borderDimColor
949
+ >
950
+ {liveLogs.slice(-15).map((log, idx) => (
951
+ <Text key={idx} color="white">
952
+ {log}
953
+ </Text>
954
+ ))}
955
+ </Box>
956
+ </Box>
957
+ )}
958
+
959
+ {deploymentId && (
960
+ <Box marginTop={2}>
961
+ <Text color="gray">
962
+ Run 'onflyt tail {deploymentId}' to watch logs
963
+ </Text>
964
+ </Box>
965
+ )}
966
+ </Box>
967
+ );
968
+ }
969
+
970
+ if (step === "done") {
971
+ return (
972
+ <Box flexDirection="column" padding={1}>
973
+ <Text bold color="rgb(255,191,0)">
974
+ DEPLOYED!
975
+ </Text>
976
+
977
+ <Box
978
+ marginTop={2}
979
+ flexDirection="column"
980
+ borderStyle="round"
981
+ borderColor="green"
982
+ padding={1}
983
+ >
984
+ <Text bold color="green">
985
+ ✓ Deployment Successful
986
+ </Text>
987
+
988
+ <Box marginTop={1}>
989
+ <Text bold color="cyan">
990
+ ID:
991
+ </Text>
992
+ <Text> {deploymentId}</Text>
993
+ </Box>
994
+
995
+ <Box marginTop={1}>
996
+ <Text bold color="cyan">
997
+ Live URL:
998
+ </Text>
999
+ <Text bold color="white">
1000
+ {" "}
1001
+ {liveUrl}
1002
+ </Text>
1003
+ </Box>
1004
+
1005
+ {previewUrl && (
1006
+ <Box marginTop={1}>
1007
+ <Text bold color="cyan">
1008
+ Preview:
1009
+ </Text>
1010
+ <Text color="white"> {previewUrl}</Text>
1011
+ </Box>
1012
+ )}
1013
+
1014
+ {needsPod && (
1015
+ <Box marginTop={1}>
1016
+ <Text bold color="magenta">
1017
+ Onflyt Pod:
1018
+ </Text>
1019
+ <Text>
1020
+ {" "}
1021
+ {replicas}x {INSTANCE_OPTIONS[selectedInstanceIndex].label}
1022
+ </Text>
1023
+ </Box>
1024
+ )}
1025
+ </Box>
1026
+
1027
+ <Box marginTop={2}>
1028
+ <Text color="gray">
1029
+ Run 'onflyt logs {deploymentId}' to view logs
1030
+ </Text>
1031
+ </Box>
1032
+ </Box>
1033
+ );
1034
+ }
1035
+
1036
+ return null;
1037
+ };
1038
+
1039
+ export default Deploy;