veryfront 0.0.82 → 0.0.83

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.
Files changed (120) hide show
  1. package/README.md +15 -1
  2. package/esm/deno.js +1 -1
  3. package/esm/proxy/cache/index.d.ts +41 -0
  4. package/esm/proxy/cache/index.d.ts.map +1 -0
  5. package/esm/proxy/cache/index.js +75 -0
  6. package/esm/proxy/cache/memory-cache.d.ts +18 -0
  7. package/esm/proxy/cache/memory-cache.d.ts.map +1 -0
  8. package/esm/proxy/cache/memory-cache.js +100 -0
  9. package/esm/proxy/cache/redis-cache.d.ts +27 -0
  10. package/esm/proxy/cache/redis-cache.d.ts.map +1 -0
  11. package/esm/proxy/cache/redis-cache.js +183 -0
  12. package/esm/proxy/cache/resilient-cache.d.ts +44 -0
  13. package/esm/proxy/cache/resilient-cache.d.ts.map +1 -0
  14. package/esm/proxy/cache/resilient-cache.js +178 -0
  15. package/esm/proxy/cache/types.d.ts +65 -0
  16. package/esm/proxy/cache/types.d.ts.map +1 -0
  17. package/esm/proxy/cache/types.js +7 -0
  18. package/esm/proxy/handler.d.ts +81 -0
  19. package/esm/proxy/handler.d.ts.map +1 -0
  20. package/esm/proxy/handler.js +417 -0
  21. package/esm/proxy/logger.d.ts +29 -0
  22. package/esm/proxy/logger.d.ts.map +1 -0
  23. package/esm/proxy/logger.js +258 -0
  24. package/esm/proxy/oauth-client.d.ts +15 -0
  25. package/esm/proxy/oauth-client.d.ts.map +1 -0
  26. package/esm/proxy/oauth-client.js +52 -0
  27. package/esm/proxy/token-manager.d.ts +59 -0
  28. package/esm/proxy/token-manager.d.ts.map +1 -0
  29. package/esm/proxy/token-manager.js +125 -0
  30. package/esm/proxy/tracing.d.ts +39 -0
  31. package/esm/proxy/tracing.d.ts.map +1 -0
  32. package/esm/proxy/tracing.js +194 -0
  33. package/esm/src/cache/backend.d.ts +2 -0
  34. package/esm/src/cache/backend.d.ts.map +1 -1
  35. package/esm/src/cache/backend.js +2 -0
  36. package/esm/src/cache/cache-key-builder.d.ts +0 -4
  37. package/esm/src/cache/cache-key-builder.d.ts.map +1 -1
  38. package/esm/src/cache/cache-key-builder.js +0 -6
  39. package/esm/src/cache/multi-tier.d.ts +0 -29
  40. package/esm/src/cache/multi-tier.d.ts.map +1 -1
  41. package/esm/src/cache/multi-tier.js +0 -26
  42. package/esm/src/cli/app/actions.d.ts +26 -0
  43. package/esm/src/cli/app/actions.d.ts.map +1 -0
  44. package/esm/src/cli/app/actions.js +152 -0
  45. package/esm/src/cli/app/components/inline-input.d.ts +35 -0
  46. package/esm/src/cli/app/components/inline-input.d.ts.map +1 -0
  47. package/esm/src/cli/app/components/inline-input.js +220 -0
  48. package/esm/src/cli/app/components/list-select.d.ts +69 -0
  49. package/esm/src/cli/app/components/list-select.d.ts.map +1 -0
  50. package/esm/src/cli/app/components/list-select.js +137 -0
  51. package/esm/src/cli/app/index.d.ts +45 -0
  52. package/esm/src/cli/app/index.d.ts.map +1 -0
  53. package/esm/src/cli/app/index.js +1252 -0
  54. package/esm/src/cli/app/state.d.ts +122 -0
  55. package/esm/src/cli/app/state.d.ts.map +1 -0
  56. package/esm/src/cli/app/state.js +232 -0
  57. package/esm/src/cli/app/views/dashboard.d.ts +19 -0
  58. package/esm/src/cli/app/views/dashboard.d.ts.map +1 -0
  59. package/esm/src/cli/app/views/dashboard.js +178 -0
  60. package/esm/src/cli/index/command-router.d.ts.map +1 -1
  61. package/esm/src/cli/index/command-router.js +9 -39
  62. package/esm/src/cli/index/start-handler.d.ts +3 -0
  63. package/esm/src/cli/index/start-handler.d.ts.map +1 -0
  64. package/esm/src/cli/index/start-handler.js +145 -0
  65. package/esm/src/cli/mcp/index.d.ts +11 -0
  66. package/esm/src/cli/mcp/index.d.ts.map +1 -0
  67. package/esm/src/cli/mcp/index.js +10 -0
  68. package/esm/src/middleware/builtin/security/redis-rate-limit.d.ts +2 -0
  69. package/esm/src/middleware/builtin/security/redis-rate-limit.d.ts.map +1 -1
  70. package/esm/src/middleware/builtin/security/redis-rate-limit.js +23 -9
  71. package/esm/src/modules/react-loader/ssr-module-loader/cache/redis.d.ts +10 -0
  72. package/esm/src/modules/react-loader/ssr-module-loader/cache/redis.d.ts.map +1 -1
  73. package/esm/src/modules/react-loader/ssr-module-loader/cache/redis.js +30 -42
  74. package/esm/src/modules/react-loader/ssr-module-loader/loader.d.ts.map +1 -1
  75. package/esm/src/modules/react-loader/ssr-module-loader/loader.js +34 -13
  76. package/esm/src/platform/adapters/fs/cache/file-cache.d.ts.map +1 -1
  77. package/esm/src/platform/adapters/fs/cache/file-cache.js +9 -3
  78. package/esm/src/server/context/cache-invalidation.d.ts.map +1 -1
  79. package/esm/src/server/context/cache-invalidation.js +4 -0
  80. package/esm/src/server/handlers/dev/dashboard/api.js +4 -0
  81. package/esm/src/server/handlers/dev/projects/ui-handler.d.ts.map +1 -1
  82. package/esm/src/server/handlers/dev/projects/ui-handler.js +6 -0
  83. package/esm/src/transforms/esm/http-cache.d.ts.map +1 -1
  84. package/esm/src/transforms/esm/http-cache.js +139 -64
  85. package/esm/src/utils/index.d.ts +1 -1
  86. package/esm/src/utils/index.d.ts.map +1 -1
  87. package/esm/src/utils/index.js +1 -1
  88. package/package.json +2 -1
  89. package/src/deno.js +1 -1
  90. package/src/proxy/cache/index.ts +93 -0
  91. package/src/proxy/cache/memory-cache.ts +120 -0
  92. package/src/proxy/cache/redis-cache.ts +203 -0
  93. package/src/proxy/cache/resilient-cache.ts +205 -0
  94. package/src/proxy/cache/types.ts +72 -0
  95. package/src/proxy/handler.ts +593 -0
  96. package/src/proxy/logger.ts +329 -0
  97. package/src/proxy/oauth-client.ts +91 -0
  98. package/src/proxy/token-manager.ts +174 -0
  99. package/src/proxy/tracing.ts +237 -0
  100. package/src/src/cache/backend.ts +3 -0
  101. package/src/src/cache/cache-key-builder.ts +0 -9
  102. package/src/src/cache/multi-tier.ts +0 -41
  103. package/src/src/cli/app/actions.ts +190 -0
  104. package/src/src/cli/app/components/inline-input.ts +255 -0
  105. package/src/src/cli/app/components/list-select.ts +215 -0
  106. package/src/src/cli/app/index.ts +1471 -0
  107. package/src/src/cli/app/state.ts +385 -0
  108. package/src/src/cli/app/views/dashboard.ts +212 -0
  109. package/src/src/cli/index/command-router.ts +9 -40
  110. package/src/src/cli/index/start-handler.ts +195 -0
  111. package/src/src/cli/mcp/index.ts +11 -0
  112. package/src/src/middleware/builtin/security/redis-rate-limit.ts +24 -11
  113. package/src/src/modules/react-loader/ssr-module-loader/cache/redis.ts +36 -50
  114. package/src/src/modules/react-loader/ssr-module-loader/loader.ts +38 -14
  115. package/src/src/platform/adapters/fs/cache/file-cache.ts +9 -3
  116. package/src/src/server/context/cache-invalidation.ts +4 -0
  117. package/src/src/server/handlers/dev/dashboard/api.ts +2 -0
  118. package/src/src/server/handlers/dev/projects/ui-handler.ts +6 -0
  119. package/src/src/transforms/esm/http-cache.ts +148 -73
  120. package/src/src/utils/index.ts +0 -1
@@ -0,0 +1,1471 @@
1
+ /**
2
+ * CLI App Shell
3
+ *
4
+ * Interactive app-like CLI experience with dashboard, project navigation,
5
+ * and MCP integration for coding agents.
6
+ * Uses cross-runtime platform abstractions for terminal I/O.
7
+ */
8
+ import * as dntShim from "../../../_dnt.shims.js";
9
+
10
+
11
+ import {
12
+ cwd,
13
+ exit,
14
+ isInteractive,
15
+ isStdoutTTY,
16
+ writeStdout,
17
+ } from "../../platform/compat/process.js";
18
+ import { join } from "../../platform/compat/path/index.js";
19
+ import { getRuntimeEnv } from "../../config/runtime-env.js";
20
+ import { getStdinReader, setRawMode } from "../../platform/compat/stdin.js";
21
+ import { cursor, screen, SPINNER_FRAMES } from "../ui/ansi.js";
22
+ import { brand, dim, success } from "../ui/colors.js";
23
+ import { moveDown, moveUp, selectByNumber } from "./components/list-select.js";
24
+ import { renderDashboard, renderEmptyState } from "./views/dashboard.js";
25
+ import { openInBrowser, openInIDE, openInStudio, openMCPSettings } from "./actions.js";
26
+ import { initCommand } from "../commands/init/init-command.js";
27
+ import type { InitTemplate } from "../commands/init/types.js";
28
+ import {
29
+ addLog,
30
+ type AppState,
31
+ createInitialState,
32
+ endInput,
33
+ getActiveSelection,
34
+ goBack,
35
+ type LogMeta,
36
+ navigateTo,
37
+ type ProjectInfo,
38
+ scrollLogs,
39
+ setActiveList,
40
+ setExamples,
41
+ setProjects,
42
+ setTemplates,
43
+ startInput,
44
+ type StateUpdater,
45
+ toggleLogsExpanded,
46
+ updateActiveList,
47
+ updateInputValue,
48
+ updateMCP,
49
+ updateRemote,
50
+ updateServer,
51
+ } from "./state.js";
52
+ import { handleInputKey, renderInput, renderLogs } from "./components/inline-input.js";
53
+ import { login, logout, validateToken } from "../auth/login.js";
54
+ import { readToken } from "../auth/token-store.js";
55
+ import { openBrowser } from "../auth/browser.js";
56
+ import { fetchRemoteProjects } from "../sync/index.js";
57
+ import { pullCommand } from "../commands/pull.js";
58
+ import { pushCommand } from "../commands/push.js";
59
+
60
+ export interface AppConfig {
61
+ port: number;
62
+ projects: Map<string, string>;
63
+ examples?: Map<string, string>;
64
+ defaultProject?: string;
65
+ mcpPort?: number;
66
+ /** Force headless mode (no TUI) for coding agents */
67
+ headless?: boolean;
68
+ }
69
+
70
+ export interface App {
71
+ /** Start the app */
72
+ start(): void;
73
+ /** Stop the app and restore terminal */
74
+ stop(): void;
75
+ /** Update state */
76
+ update(updater: StateUpdater): void;
77
+ /** Get current state */
78
+ getState(): AppState;
79
+ /** Render the current view */
80
+ render(): void;
81
+ /** Set server ready */
82
+ setServerReady(): void;
83
+ /** Add an error */
84
+ addError(): void;
85
+ /** Clear errors */
86
+ clearErrors(): void;
87
+ /** Add a log entry to the logs area */
88
+ log(level: "info" | "warn" | "error" | "debug", message: string): void;
89
+ /** Intercept console output and route to TUI logs */
90
+ interceptConsole(): () => void;
91
+ }
92
+
93
+ async function copyDirectory(src: string, dest: string): Promise<void> {
94
+ const fs = await import("../../platform/compat/fs.js");
95
+ const pathMod = await import("../../platform/compat/path/index.js");
96
+ const filesystem = fs.createFileSystem();
97
+
98
+ await filesystem.mkdir(dest, { recursive: true });
99
+ for await (const entry of filesystem.readDir(src)) {
100
+ const srcPath = pathMod.join(src, entry.name);
101
+ const destPath = pathMod.join(dest, entry.name);
102
+ if (entry.isDirectory) {
103
+ await copyDirectory(srcPath, destPath);
104
+ } else {
105
+ const content = await filesystem.readFile(srcPath);
106
+ await filesystem.writeFile(destPath, content);
107
+ }
108
+ }
109
+ }
110
+
111
+ function generateRandomSlug(): string {
112
+ const adjectives = [
113
+ // colors & gems
114
+ "amber",
115
+ "azure",
116
+ "coral",
117
+ "crimson",
118
+ "cyan",
119
+ "golden",
120
+ "indigo",
121
+ "ivory",
122
+ "jade",
123
+ "magenta",
124
+ "maroon",
125
+ "olive",
126
+ "onyx",
127
+ "opal",
128
+ "pearl",
129
+ "ruby",
130
+ "scarlet",
131
+ "silver",
132
+ "teal",
133
+ "topaz",
134
+ "turquoise",
135
+ "violet",
136
+ // nature
137
+ "alpine",
138
+ "arctic",
139
+ "autumn",
140
+ "coastal",
141
+ "crystal",
142
+ "desert",
143
+ "floral",
144
+ "forest",
145
+ "frozen",
146
+ "lunar",
147
+ "misty",
148
+ "mossy",
149
+ "ocean",
150
+ "polar",
151
+ "rainy",
152
+ "snowy",
153
+ "solar",
154
+ "spring",
155
+ "stormy",
156
+ "sunny",
157
+ "tidal",
158
+ "tropic",
159
+ "windy",
160
+ // qualities
161
+ "agile",
162
+ "bold",
163
+ "brave",
164
+ "bright",
165
+ "calm",
166
+ "clever",
167
+ "cosmic",
168
+ "daring",
169
+ "eager",
170
+ "epic",
171
+ "fierce",
172
+ "gentle",
173
+ "grand",
174
+ "keen",
175
+ "kind",
176
+ "lively",
177
+ "mystic",
178
+ "nimble",
179
+ "noble",
180
+ "proud",
181
+ "quiet",
182
+ "rapid",
183
+ "serene",
184
+ "silent",
185
+ "steady",
186
+ "swift",
187
+ "vivid",
188
+ "wild",
189
+ "wise",
190
+ "witty",
191
+ "zen",
192
+ ];
193
+ const nouns = [
194
+ // water
195
+ "bay",
196
+ "brook",
197
+ "canal",
198
+ "cascade",
199
+ "coast",
200
+ "creek",
201
+ "delta",
202
+ "falls",
203
+ "fjord",
204
+ "gulf",
205
+ "harbor",
206
+ "lagoon",
207
+ "lake",
208
+ "marsh",
209
+ "ocean",
210
+ "pond",
211
+ "rapids",
212
+ "reef",
213
+ "river",
214
+ "shore",
215
+ "spring",
216
+ "strait",
217
+ "stream",
218
+ "tide",
219
+ "wave",
220
+ // land
221
+ "bluff",
222
+ "canyon",
223
+ "cave",
224
+ "cliff",
225
+ "crater",
226
+ "desert",
227
+ "dune",
228
+ "field",
229
+ "glade",
230
+ "gorge",
231
+ "grove",
232
+ "hill",
233
+ "isle",
234
+ "mesa",
235
+ "oasis",
236
+ "pass",
237
+ "peak",
238
+ "plain",
239
+ "plateau",
240
+ "ridge",
241
+ "rock",
242
+ "slope",
243
+ "stone",
244
+ "summit",
245
+ "trail",
246
+ "valley",
247
+ "volcano",
248
+ // sky & space
249
+ "aurora",
250
+ "cloud",
251
+ "comet",
252
+ "cosmos",
253
+ "dawn",
254
+ "dusk",
255
+ "eclipse",
256
+ "ember",
257
+ "flare",
258
+ "frost",
259
+ "galaxy",
260
+ "glow",
261
+ "haze",
262
+ "horizon",
263
+ "meteor",
264
+ "mist",
265
+ "moon",
266
+ "nebula",
267
+ "nova",
268
+ "orbit",
269
+ "prism",
270
+ "pulse",
271
+ "quasar",
272
+ "ray",
273
+ "shadow",
274
+ "sky",
275
+ "spark",
276
+ "star",
277
+ "storm",
278
+ "sun",
279
+ "thunder",
280
+ "twilight",
281
+ "vapor",
282
+ "wind",
283
+ "zenith",
284
+ ];
285
+ const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
286
+ const noun = nouns[Math.floor(Math.random() * nouns.length)];
287
+ return `${adj}-${noun}`;
288
+ }
289
+
290
+ /**
291
+ * Create the CLI app
292
+ */
293
+ export function createApp(config: AppConfig): App {
294
+ let state = createInitialState();
295
+ let running = false;
296
+ let spinnerFrame = 0;
297
+ let spinnerInterval: number | null = null;
298
+
299
+ // Force non-interactive if headless flag is set (for coding agents)
300
+ const isInteractiveMode = !config.headless && isInteractive() && isStdoutTTY();
301
+
302
+ state = setProjects(
303
+ Array.from(config.projects.entries()).map(([slug, path]) => ({ slug, path })),
304
+ )(state);
305
+
306
+ if (config.examples) {
307
+ state = setExamples(
308
+ Array.from(config.examples.entries()).map(([slug, path]) => ({ slug, path })),
309
+ )(state);
310
+ }
311
+
312
+ state = setTemplates([
313
+ { id: "minimal", name: "Minimal", description: "Bare-bones starter with just the essentials" },
314
+ { id: "app", name: "App", description: "Full-featured app with routing and layouts" },
315
+ { id: "ai", name: "AI", description: "AI-powered app with chat and agents" },
316
+ { id: "blog", name: "Blog", description: "MDX-powered blog with syntax highlighting" },
317
+ { id: "docs", name: "Docs", description: "Documentation site with search" },
318
+ ])(state);
319
+
320
+ state = updateServer({
321
+ port: config.port,
322
+ url: `http://veryfront.me:${config.port}`,
323
+ })(state);
324
+
325
+ state = updateMCP({
326
+ enabled: config.mcpPort !== undefined,
327
+ transport: config.mcpPort ? "http" : null,
328
+ httpPort: config.mcpPort,
329
+ })(state);
330
+
331
+ // Check for existing auth (async, updates state when ready)
332
+ void (async () => {
333
+ try {
334
+ const token = await readToken();
335
+ if (token) {
336
+ const user = await validateToken(token);
337
+ if (user) {
338
+ const result = await fetchRemoteProjects();
339
+ state = updateRemote({
340
+ user,
341
+ projects: result.projects.map((p) => ({ id: p.id, name: p.name, slug: p.slug })),
342
+ })(state);
343
+ }
344
+ }
345
+ } catch {
346
+ // Auth check failed - non-fatal
347
+ }
348
+ })();
349
+
350
+ const write = (text: string): void => writeStdout(text);
351
+
352
+ function renderTemplatesView(): string {
353
+ const lines = [
354
+ "",
355
+ ` ${brand("Templates")}`,
356
+ "",
357
+ ` ${dim("Create a new project from a template:")}`,
358
+ "",
359
+ ];
360
+
361
+ state.templates.items.forEach((item, i) => {
362
+ const selected = i === state.templates.selectedIndex;
363
+ const prefix = selected ? brand("›") : " ";
364
+ const label = selected ? brand(item.label) : item.label;
365
+ lines.push(` ${prefix} ${label} ${dim(item.description || "")}`);
366
+ });
367
+
368
+ lines.push("");
369
+ lines.push(
370
+ ` ${dim("Press")} ${brand("Enter")} ${dim("to create •")} ${brand("Esc")} ${
371
+ dim("to go back")
372
+ }`,
373
+ );
374
+ lines.push("");
375
+
376
+ return lines.join("\n");
377
+ }
378
+
379
+ function renderExamplesView(): string {
380
+ const lines = [
381
+ "",
382
+ ` ${brand("Examples")}`,
383
+ "",
384
+ ` ${dim("Create a new project from an example:")}`,
385
+ "",
386
+ ];
387
+
388
+ state.examples.items.forEach((item, i) => {
389
+ const selected = i === state.examples.selectedIndex;
390
+ const prefix = selected ? brand("›") : " ";
391
+ const label = selected ? brand(item.label) : item.label;
392
+ lines.push(` ${prefix} ${label} ${dim(item.description || "")}`);
393
+ });
394
+
395
+ lines.push("");
396
+ lines.push(
397
+ ` ${dim("Press")} ${brand("Enter")} ${dim("to create •")} ${brand("Esc")} ${
398
+ dim("to go back")
399
+ }`,
400
+ );
401
+ lines.push("");
402
+
403
+ return lines.join("\n");
404
+ }
405
+
406
+ function renderHelpView(): string {
407
+ const lines = [
408
+ "",
409
+ ` ${brand("Keyboard Shortcuts")}`,
410
+ "",
411
+ ` ${dim("Navigation")}`,
412
+ ` ${brand("↑↓")} ${dim("or")} ${brand("jk")} Navigate list`,
413
+ ` ${brand("Tab")} Switch sections`,
414
+ ` ${brand("1-9")} Quick select item`,
415
+ ` ${brand("Enter")} Select / Open in browser`,
416
+ ` ${brand("Esc")} Go back`,
417
+ "",
418
+ ` ${dim("Actions")}`,
419
+ ` ${brand("o")} Open in browser`,
420
+ ` ${brand("s")} Open in Studio`,
421
+ ` ${brand("i")} Open in IDE`,
422
+ ` ${brand("p")} Pull from remote`,
423
+ ` ${brand("u")} Push to remote`,
424
+ "",
425
+ ` ${dim("Auth")}`,
426
+ ` ${brand("a")} Login`,
427
+ ` ${brand("x")} Logout`,
428
+ "",
429
+ ` ${dim("Views")}`,
430
+ ` ${brand("n")} New project`,
431
+ ` ${brand("l")} Toggle logs`,
432
+ ` ${brand("?")} Help (this screen)`,
433
+ "",
434
+ ` ${dim("Other")}`,
435
+ ` ${brand("q")} Quit`,
436
+ "",
437
+ ];
438
+
439
+ if (state.mcp.enabled) {
440
+ lines.push(` ${brand("MCP Server")}`);
441
+ lines.push("");
442
+ lines.push(` ${dim("Add to your")} ${brand("~/.claude/settings.json")}${dim(":")}`);
443
+ lines.push("");
444
+ lines.push(` ${dim('"mcpServers": {')}`);
445
+ lines.push(` ${dim(' "veryfront": {')}`);
446
+ lines.push(` ${dim(' "type": "url",')}`);
447
+ lines.push(` ${dim(` "url": "http://veryfront.me:${state.mcp.httpPort}/mcp"`)}`);
448
+ lines.push(` ${dim(" }")}`);
449
+ lines.push(` ${dim("}")}`);
450
+ lines.push("");
451
+ lines.push(` ${brand("m")} ${dim("Open settings.json in IDE")}`);
452
+ lines.push("");
453
+ lines.push(` ${dim("Tools:")} vf_list_routes, vf_scaffold, vf_get_errors, vf_get_logs`);
454
+ lines.push("");
455
+ }
456
+
457
+ lines.push(` ${dim("Press")} ${brand("Esc")} ${dim("to go back")}`);
458
+ lines.push("");
459
+
460
+ return lines.join("\n");
461
+ }
462
+
463
+ function renderNewProjectView(): string {
464
+ const options = [
465
+ { label: "From template", desc: "Start with a pre-built template" },
466
+ { label: "From example", desc: "Copy an example project" },
467
+ { label: "From scratch", desc: "Empty project" },
468
+ ];
469
+
470
+ const lines = [
471
+ "",
472
+ ` ${brand("New Project")}`,
473
+ "",
474
+ ` ${dim("Choose how to start:")}`,
475
+ "",
476
+ ];
477
+
478
+ options.forEach((opt, i) => {
479
+ const isFocused = i === state.newProjectIndex;
480
+ const cursor = isFocused ? brand("›") : " ";
481
+ const num = isFocused ? brand(`[${i + 1}]`) : dim(`[${i + 1}]`);
482
+ const label = isFocused ? opt.label : dim(opt.label);
483
+ const desc = dim(opt.desc);
484
+ lines.push(` ${cursor} ${num} ${label} ${desc}`);
485
+ });
486
+
487
+ lines.push(
488
+ "",
489
+ ` ${dim("↑↓ nav enter select esc back")}`,
490
+ "",
491
+ );
492
+
493
+ return lines.join("\n");
494
+ }
495
+
496
+ function renderAuthView(): string {
497
+ const providers = ["Google", "GitHub", "Microsoft"];
498
+ const lines = [
499
+ "",
500
+ ` ${brand("Login to Veryfront")}`,
501
+ "",
502
+ ` ${dim("Choose authentication provider:")}`,
503
+ "",
504
+ ];
505
+
506
+ providers.forEach((p, i) => {
507
+ const isFocused = i === state.authProviderIndex;
508
+ const cursor = isFocused ? brand("›") : " ";
509
+ const num = isFocused ? brand(`[${i + 1}]`) : dim(`[${i + 1}]`);
510
+ const label = isFocused ? p : dim(p);
511
+ lines.push(`${cursor} ${num} ${label}`);
512
+ });
513
+
514
+ lines.push("", ` ${dim("↑↓ nav enter select esc back")}`, "");
515
+ return lines.join("\n");
516
+ }
517
+
518
+ function render(): void {
519
+ let content: string;
520
+
521
+ switch (state.view) {
522
+ case "dashboard":
523
+ content = state.projects.items.length > 0 || state.examples.items.length > 0
524
+ ? renderDashboard(state)
525
+ : renderEmptyState();
526
+ break;
527
+ case "new-project":
528
+ content = renderNewProjectView();
529
+ break;
530
+ case "templates":
531
+ content = renderTemplatesView();
532
+ break;
533
+ case "examples":
534
+ content = renderExamplesView();
535
+ break;
536
+ case "auth":
537
+ content = renderAuthView();
538
+ break;
539
+ case "help":
540
+ content = renderHelpView();
541
+ break;
542
+ default:
543
+ content = renderDashboard(state);
544
+ }
545
+
546
+ const parts: string[] = [content];
547
+
548
+ if (state.logs.length > 0) {
549
+ const logsHeader = state.logsExpanded ? "▼ Logs" : "▶ Logs";
550
+ parts.push("");
551
+ parts.push(` ${dim("─".repeat(60))}`);
552
+ parts.push(
553
+ ` ${dim(logsHeader)} ${dim(`(${state.logs.length})`)} ${dim("l")} ${dim("toggle")} ${
554
+ state.logsExpanded ? `${dim("↑↓")} ${dim("scroll")}` : ""
555
+ }`,
556
+ );
557
+ parts.push(renderLogs(state.logs, {
558
+ maxLines: state.logsExpanded ? 15 : 3,
559
+ scroll: state.logScroll,
560
+ expanded: state.logsExpanded,
561
+ }));
562
+ }
563
+
564
+ if (state.input.active) {
565
+ parts.push("");
566
+ parts.push(` ${dim("─".repeat(60))}`);
567
+ parts.push(renderInput(state.input));
568
+ }
569
+
570
+ if (!isInteractiveMode) return;
571
+
572
+ write(cursor.moveTo(1, 1) + screen.clearDown);
573
+ write(parts.join("\n"));
574
+ }
575
+
576
+ function update(updater: StateUpdater): void {
577
+ state = updater(state);
578
+ if (isInteractiveMode) render();
579
+ }
580
+
581
+ function startSpinner(): void {
582
+ if (spinnerInterval) return;
583
+
584
+ spinnerInterval = dntShim.setInterval(() => {
585
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
586
+ render();
587
+ }, 80) as unknown as number;
588
+ }
589
+
590
+ function stopSpinner(): void {
591
+ if (!spinnerInterval) return;
592
+ clearInterval(spinnerInterval);
593
+ spinnerInterval = null;
594
+ }
595
+
596
+ async function handleInput(): Promise<void> {
597
+ // Skip interactive input if not a TTY (e.g., running in background or CI)
598
+ if (!isInteractive()) return;
599
+
600
+ setRawMode(true);
601
+ const reader = getStdinReader();
602
+ const decoder = new TextDecoder();
603
+
604
+ try {
605
+ while (running) {
606
+ const { value, done } = await reader.read();
607
+ if (done) break;
608
+
609
+ await handleKey(decoder.decode(value));
610
+ }
611
+ } finally {
612
+ reader.releaseLock();
613
+ try {
614
+ setRawMode(false);
615
+ } catch {
616
+ // Ignore if stdin is already closed
617
+ }
618
+ }
619
+ }
620
+
621
+ async function handleKey(key: string): Promise<void> {
622
+ if (state.input.active) {
623
+ const result = handleInputKey(key, state.input.value, state.input.cursorPos);
624
+
625
+ if ("action" in result) {
626
+ if (result.action === "submit" && state.input.onSubmit) {
627
+ const value = state.input.value;
628
+ const onSubmit = state.input.onSubmit;
629
+ state = endInput()(state);
630
+ render();
631
+ await onSubmit(value);
632
+ return;
633
+ }
634
+
635
+ if (result.action === "cancel") {
636
+ const onCancel = state.input.onCancel;
637
+ state = endInput()(state);
638
+ render();
639
+ onCancel?.();
640
+ return;
641
+ }
642
+
643
+ return;
644
+ }
645
+
646
+ state = updateInputValue(result.value, result.cursorPos)(state);
647
+ render();
648
+ return;
649
+ }
650
+
651
+ if (key === "\x03" || (key === "q" && state.view === "dashboard")) {
652
+ stop();
653
+ exit(0);
654
+ }
655
+
656
+ // Handle Escape key (but not escape sequences like arrow keys)
657
+ // Escape alone is \x1b, arrow keys are \x1b[A, \x1b[B, etc.
658
+ if (key === "\x1b") {
659
+ if (state.view !== "dashboard") update(goBack());
660
+ return;
661
+ }
662
+
663
+ if (state.view === "templates") {
664
+ handleTemplatesKey(key);
665
+ return;
666
+ }
667
+
668
+ if (state.view === "examples") {
669
+ handleExamplesKey(key);
670
+ return;
671
+ }
672
+
673
+ if (state.view === "new-project") {
674
+ handleNewProjectKey(key);
675
+ return;
676
+ }
677
+
678
+ if (state.view === "auth") {
679
+ handleAuthKey(key);
680
+ return;
681
+ }
682
+
683
+ if (state.view === "help") {
684
+ update(goBack());
685
+ return;
686
+ }
687
+
688
+ // Toggle logs expanded with 'l'
689
+ if (key === "l" || key === "L") {
690
+ update(toggleLogsExpanded());
691
+ return;
692
+ }
693
+
694
+ // When logs are expanded, arrow keys scroll logs instead of list
695
+ if (state.logsExpanded && state.logs.length > 0) {
696
+ if (key === "\x1b[A" || key === "k") {
697
+ update(scrollLogs("up"));
698
+ return;
699
+ }
700
+ if (key === "\x1b[B" || key === "j") {
701
+ update(scrollLogs("down"));
702
+ return;
703
+ }
704
+ }
705
+
706
+ if (key === "\x1b[A" || key === "k") {
707
+ if (state.activeList === "remoteProjects") {
708
+ const total = state.remote.projects.length;
709
+ const visibleCount = 5;
710
+ const newIndex = state.remote.focusedIndex > 0 ? state.remote.focusedIndex - 1 : total - 1;
711
+ // Adjust scroll offset
712
+ let scrollOffset = state.remote.scrollOffset;
713
+ if (newIndex < scrollOffset) {
714
+ scrollOffset = newIndex;
715
+ } else if (newIndex === total - 1) {
716
+ // Wrapped to bottom
717
+ scrollOffset = Math.max(0, total - visibleCount);
718
+ }
719
+ update(updateRemote({ focusedIndex: newIndex, scrollOffset }));
720
+ } else {
721
+ update(updateActiveList((list) => moveUp(list)));
722
+ }
723
+ return;
724
+ }
725
+
726
+ if (key === "\x1b[B" || key === "j") {
727
+ if (state.activeList === "remoteProjects") {
728
+ const total = state.remote.projects.length;
729
+ const visibleCount = 5;
730
+ const newIndex = state.remote.focusedIndex < total - 1 ? state.remote.focusedIndex + 1 : 0;
731
+ // Adjust scroll offset
732
+ let scrollOffset = state.remote.scrollOffset;
733
+ if (newIndex === 0) {
734
+ // Wrapped to top
735
+ scrollOffset = 0;
736
+ } else if (newIndex >= scrollOffset + visibleCount) {
737
+ scrollOffset = newIndex - visibleCount + 1;
738
+ }
739
+ update(updateRemote({ focusedIndex: newIndex, scrollOffset }));
740
+ } else {
741
+ update(updateActiveList((list) => moveDown(list, 5)));
742
+ }
743
+ return;
744
+ }
745
+
746
+ if (key === "\t") {
747
+ const hasProjects = state.projects.items.length > 0;
748
+ const hasExamples = state.examples.items.length > 0;
749
+ const hasRemoteProjects = state.remote.user && state.remote.projects.length > 0;
750
+
751
+ // Build list of available sections in display order
752
+ const sections: Array<"projects" | "remoteProjects" | "examples"> = [];
753
+ if (hasProjects) sections.push("projects");
754
+ if (hasRemoteProjects) sections.push("remoteProjects");
755
+ if (hasExamples) sections.push("examples");
756
+
757
+ if (sections.length > 1) {
758
+ const currentIndex = sections.indexOf(state.activeList as typeof sections[number]);
759
+ const nextIndex = (currentIndex + 1) % sections.length;
760
+ const nextSection = sections[nextIndex];
761
+ if (nextSection) update(setActiveList(nextSection));
762
+ }
763
+ return;
764
+ }
765
+
766
+ // Number keys for remote project - update focusedIndex (Enter triggers pull)
767
+ if (key >= "1" && key <= "9" && state.activeList === "remoteProjects") {
768
+ const num = parseInt(key, 10);
769
+ const total = state.remote.projects.length;
770
+ if (num <= total) {
771
+ const newIndex = num - 1;
772
+ const visibleCount = 5;
773
+ let scrollOffset = state.remote.scrollOffset;
774
+ if (newIndex < scrollOffset) {
775
+ scrollOffset = newIndex;
776
+ } else if (newIndex >= scrollOffset + visibleCount) {
777
+ scrollOffset = newIndex - visibleCount + 1;
778
+ }
779
+ update(updateRemote({ focusedIndex: newIndex, scrollOffset }));
780
+ }
781
+ return;
782
+ }
783
+
784
+ // Letter keys for remote project items 10+ (a=10, b=11, etc.)
785
+ // Exclude p (pull), u (push), j/k (vim nav), o/s/i (open actions) shortcuts
786
+ if (
787
+ key >= "a" && key <= "z" && key !== "j" && key !== "k" && key !== "p" && key !== "u" &&
788
+ key !== "o" && key !== "s" && key !== "i" &&
789
+ state.activeList === "remoteProjects"
790
+ ) {
791
+ const num = key.charCodeAt(0) - 96 + 9; // a=10, b=11, etc.
792
+ const total = state.remote.projects.length;
793
+ if (num <= total) {
794
+ const newIndex = num - 1;
795
+ const visibleCount = 5;
796
+ let scrollOffset = state.remote.scrollOffset;
797
+ if (newIndex < scrollOffset) {
798
+ scrollOffset = newIndex;
799
+ } else if (newIndex >= scrollOffset + visibleCount) {
800
+ scrollOffset = newIndex - visibleCount + 1;
801
+ }
802
+ update(updateRemote({ focusedIndex: newIndex, scrollOffset }));
803
+ }
804
+ return;
805
+ }
806
+
807
+ // Auth: login
808
+ if (key === "a" && !state.remote.user) {
809
+ update(navigateTo("auth"));
810
+ return;
811
+ }
812
+
813
+ // Auth: logout
814
+ if (key === "x" && state.remote.user) {
815
+ await logout();
816
+ update(updateRemote({ user: null, projects: [], focusedIndex: 0, scrollOffset: 0 }));
817
+ update(addLog("info", "Logged out"));
818
+ return;
819
+ }
820
+
821
+ // Number keys select from active list (1-9) - skip for remoteProjects (handled above)
822
+ if (key >= "1" && key <= "9" && state.activeList !== "remoteProjects") {
823
+ const num = parseInt(key, 10);
824
+ const activeList = state[state.activeList];
825
+ if (num <= activeList.items.length) {
826
+ state = { ...state, [state.activeList]: selectByNumber(activeList, num) };
827
+ render();
828
+ const selected = activeList.items[num - 1];
829
+ if (selected?.data) await openInBrowser(selected.data, state.server.port);
830
+ return;
831
+ }
832
+ }
833
+
834
+ // Letter keys only work when examples focused (a=1, b=2, etc.)
835
+ // Exclude j/k (vim nav), p/u (pull/push) shortcuts
836
+ if (
837
+ key >= "a" && key <= "z" && key !== "j" && key !== "k" && key !== "p" && key !== "u" &&
838
+ state.activeList === "examples"
839
+ ) {
840
+ const num = key.charCodeAt(0) - 96; // a=1, b=2, ...
841
+ if (num <= state.examples.items.length) {
842
+ state = { ...state, examples: selectByNumber(state.examples, num) };
843
+ render();
844
+ const selected = state.examples.items[num - 1];
845
+ if (selected?.data) await openInBrowser(selected.data, state.server.port);
846
+ return;
847
+ }
848
+ }
849
+
850
+ if (key === "\r" || key === "\n") {
851
+ // Enter on remote projects: pull
852
+ if (state.activeList === "remoteProjects") {
853
+ const focused = state.remote.projects[state.remote.focusedIndex];
854
+ if (focused) {
855
+ const projectDir = join(cwd(), "projects", focused.slug);
856
+ update(addLog("info", `Pulling to projects/${focused.slug}/...`));
857
+ render();
858
+ try {
859
+ await pullCommand({
860
+ projectSlug: focused.slug,
861
+ projectDir,
862
+ force: true,
863
+ quiet: true,
864
+ });
865
+ update(addLog("info", `Pulled to projects/${focused.slug}/`));
866
+ } catch (err) {
867
+ update(
868
+ addLog("error", `Pull failed: ${err instanceof Error ? err.message : String(err)}`),
869
+ );
870
+ }
871
+ render();
872
+ }
873
+ return;
874
+ }
875
+ // Enter on local projects/examples: open in browser
876
+ const selected = getActiveSelection(state);
877
+ if (selected?.data) await openInBrowser(selected.data, state.server.port);
878
+ return;
879
+ }
880
+
881
+ if (key === "o") {
882
+ // Open focused remote project in local dev server
883
+ if (state.activeList === "remoteProjects") {
884
+ const focused = state.remote.projects[state.remote.focusedIndex];
885
+ if (focused) {
886
+ const url = `http://${focused.slug}.veryfront.me:${state.server.port}`;
887
+ await openBrowser(url);
888
+ }
889
+ return;
890
+ }
891
+ // Otherwise open local project in browser
892
+ const selected = getActiveSelection(state);
893
+ if (selected?.data) await openInBrowser(selected.data, state.server.port);
894
+ return;
895
+ }
896
+
897
+ if (key === "s") {
898
+ // Open focused remote project in Studio
899
+ if (state.activeList === "remoteProjects") {
900
+ const focused = state.remote.projects[state.remote.focusedIndex];
901
+ if (focused) {
902
+ const url = `https://veryfront.com/projects/${focused.slug}`;
903
+ await openBrowser(url);
904
+ }
905
+ return;
906
+ }
907
+ // Otherwise open local project in Studio
908
+ const selected = getActiveSelection(state);
909
+ if (selected?.data) await openInStudio(selected.data);
910
+ return;
911
+ }
912
+
913
+ if (key === "i") {
914
+ // Open focused remote project's local directory in IDE
915
+ if (state.activeList === "remoteProjects") {
916
+ const focused = state.remote.projects[state.remote.focusedIndex];
917
+ if (focused) {
918
+ const projectDir = join(cwd(), "projects", focused.slug);
919
+ await openInIDE({ slug: focused.slug, path: projectDir, type: "local" });
920
+ }
921
+ return;
922
+ }
923
+ // Otherwise open local project in IDE
924
+ const selected = getActiveSelection(state);
925
+ if (selected?.data) await openInIDE(selected.data);
926
+ return;
927
+ }
928
+
929
+ if (key === "n") {
930
+ state = { ...state, newProjectIndex: 0 };
931
+ update(navigateTo("new-project"));
932
+ return;
933
+ }
934
+
935
+ if (key === "?") {
936
+ update(navigateTo("help"));
937
+ return;
938
+ }
939
+
940
+ if (key === "m" && state.mcp.enabled) {
941
+ const result = await openMCPSettings();
942
+ update(
943
+ addLog(
944
+ result.success ? "info" : "error",
945
+ result.message ||
946
+ (result.success ? "Opened MCP settings" : "Failed to open MCP settings"),
947
+ ),
948
+ );
949
+ return;
950
+ }
951
+
952
+ // Pull focused remote project
953
+ if (key === "p" && state.activeList === "remoteProjects") {
954
+ const focused = state.remote.projects[state.remote.focusedIndex];
955
+ if (focused) {
956
+ const projectDir = join(cwd(), "projects", focused.slug);
957
+ update(addLog("info", `Pulling to projects/${focused.slug}/...`));
958
+ render();
959
+ try {
960
+ await pullCommand({
961
+ projectSlug: focused.slug,
962
+ projectDir,
963
+ force: true,
964
+ quiet: true,
965
+ });
966
+ update(addLog("info", `Pulled to projects/${focused.slug}/`));
967
+ } catch (err) {
968
+ update(
969
+ addLog("error", `Pull failed: ${err instanceof Error ? err.message : String(err)}`),
970
+ );
971
+ }
972
+ render();
973
+ }
974
+ return;
975
+ }
976
+
977
+ // Pull local project from remote (sync)
978
+ if (key === "p" && state.activeList === "projects") {
979
+ const selected = state.projects.items[state.projects.selectedIndex];
980
+ if (selected?.data) {
981
+ const { slug, path: projectDir } = selected.data;
982
+ update(addLog("info", `Pulling ${slug}...`));
983
+ render();
984
+ try {
985
+ await pullCommand({ projectSlug: slug, projectDir, force: true, quiet: true });
986
+ update(addLog("info", `Pulled ${slug}`));
987
+ } catch (err) {
988
+ update(
989
+ addLog("error", `Pull failed: ${err instanceof Error ? err.message : String(err)}`),
990
+ );
991
+ }
992
+ render();
993
+ }
994
+ return;
995
+ }
996
+
997
+ // Push local project
998
+ if (key === "u" && state.activeList === "projects") {
999
+ const selected = state.projects.items[state.projects.selectedIndex];
1000
+ if (selected?.data) {
1001
+ const { slug, path: projectDir } = selected.data;
1002
+ update(addLog("info", `Pushing ${slug}...`));
1003
+ render();
1004
+ try {
1005
+ await pushCommand({ projectSlug: slug, projectDir, force: true, quiet: true });
1006
+ update(addLog("info", `Pushed ${slug} — merge in Studio`));
1007
+ } catch (err) {
1008
+ update(
1009
+ addLog("error", `Push failed: ${err instanceof Error ? err.message : String(err)}`),
1010
+ );
1011
+ }
1012
+ render();
1013
+ }
1014
+ return;
1015
+ }
1016
+ }
1017
+
1018
+ async function createProject(projectName: string, template: InitTemplate): Promise<void> {
1019
+ try {
1020
+ state = addLog("info", `Creating project...`)(state);
1021
+ render();
1022
+
1023
+ const token = await readToken();
1024
+ if (!token) {
1025
+ state = addLog("error", "Not authenticated. Press 'a' to login.")(state);
1026
+ return;
1027
+ }
1028
+
1029
+ // Normalize slug: lowercase, alphanumeric and hyphens only
1030
+ const normalizedSlug = projectName.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1031
+ // Use slug as name (capitalize first letter of each word)
1032
+ const name = normalizedSlug
1033
+ .split("-")
1034
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1035
+ .join(" ");
1036
+
1037
+ const apiUrl = getRuntimeEnv().apiUrl || "https://api.veryfront.com";
1038
+ const response = await dntShim.fetch(`${apiUrl}/projects`, {
1039
+ method: "POST",
1040
+ headers: {
1041
+ Authorization: `Bearer ${token}`,
1042
+ "Content-Type": "application/json",
1043
+ Accept: "application/json",
1044
+ },
1045
+ body: JSON.stringify({ slug: normalizedSlug, name }),
1046
+ });
1047
+
1048
+ if (!response.ok) {
1049
+ const error = await response.json().catch(() => ({}));
1050
+ const msg = (error as { message?: string }).message || `HTTP ${response.status}`;
1051
+ throw new Error(msg);
1052
+ }
1053
+
1054
+ const { slug } = await response.json() as { slug: string };
1055
+ const projectPath = `${cwd()}/projects/${slug}`;
1056
+
1057
+ await initCommand({
1058
+ name: `projects/${slug}`,
1059
+ template,
1060
+ skipInstall: true,
1061
+ skipEnvPrompt: true,
1062
+ quiet: true,
1063
+ });
1064
+
1065
+ const currentProjects = state.projects.items.map((item) => ({
1066
+ slug: item.data!.slug,
1067
+ path: item.data!.path,
1068
+ }));
1069
+ currentProjects.push({ slug, path: projectPath });
1070
+
1071
+ state = setProjects(currentProjects)(state);
1072
+
1073
+ // Refresh remote projects list to include the new project
1074
+ const result = await fetchRemoteProjects();
1075
+ state = updateRemote({
1076
+ projects: result.projects.map((p) => ({ id: p.id, name: p.name, slug: p.slug })),
1077
+ })(state);
1078
+
1079
+ state = addLog("info", `Created ${slug}`)(state);
1080
+ } catch (error) {
1081
+ state = addLog("error", `Failed: ${error}`)(state);
1082
+ }
1083
+ }
1084
+
1085
+ function promptForProjectName(template: InitTemplate, onCancel: () => void): void {
1086
+ const suggested = generateRandomSlug();
1087
+ state = startInput(
1088
+ "Project name",
1089
+ async (name: string) => {
1090
+ if (name.trim()) await createProject(name.trim(), template);
1091
+ state = navigateTo("dashboard")(state);
1092
+ render();
1093
+ },
1094
+ onCancel,
1095
+ suggested,
1096
+ )(state);
1097
+ render();
1098
+ }
1099
+
1100
+ function handleTemplatesKey(key: string): void {
1101
+ if (key === "\x1b[A" || key === "k") {
1102
+ state = { ...state, templates: moveUp(state.templates) };
1103
+ render();
1104
+ return;
1105
+ }
1106
+
1107
+ if (key === "\x1b[B" || key === "j") {
1108
+ state = { ...state, templates: moveDown(state.templates, state.templates.items.length) };
1109
+ render();
1110
+ return;
1111
+ }
1112
+
1113
+ if (key === "\r" || key === "\n") {
1114
+ const selected = state.templates.items[state.templates.selectedIndex];
1115
+ if (selected) promptForProjectName(selected.id as InitTemplate, () => render());
1116
+ }
1117
+ }
1118
+
1119
+ function handleExamplesKey(key: string): void {
1120
+ if (key === "\x1b[A" || key === "k") {
1121
+ state = { ...state, examples: moveUp(state.examples) };
1122
+ render();
1123
+ return;
1124
+ }
1125
+
1126
+ if (key === "\x1b[B" || key === "j") {
1127
+ state = { ...state, examples: moveDown(state.examples, state.examples.items.length) };
1128
+ render();
1129
+ return;
1130
+ }
1131
+
1132
+ if (key === "\r" || key === "\n") {
1133
+ const selected = state.examples.items[state.examples.selectedIndex];
1134
+ if (selected?.data) {
1135
+ promptForExampleProject(selected.data, () => render());
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ function promptForExampleProject(example: ProjectInfo, onCancel: () => void): void {
1141
+ const suggested = generateRandomSlug();
1142
+ state = startInput(
1143
+ "Project name",
1144
+ async (name: string) => {
1145
+ if (name.trim()) await createProjectFromExample(name.trim(), example);
1146
+ state = navigateTo("dashboard")(state);
1147
+ render();
1148
+ },
1149
+ onCancel,
1150
+ suggested,
1151
+ )(state);
1152
+ render();
1153
+ }
1154
+
1155
+ async function createProjectFromExample(
1156
+ projectName: string,
1157
+ example: ProjectInfo,
1158
+ ): Promise<void> {
1159
+ try {
1160
+ state = addLog("info", `Creating project from ${example.slug}...`)(state);
1161
+ render();
1162
+
1163
+ const token = await readToken();
1164
+ if (!token) {
1165
+ state = addLog("error", "Not authenticated. Press 'a' to login.")(state);
1166
+ return;
1167
+ }
1168
+
1169
+ // Normalize slug
1170
+ const normalizedSlug = projectName.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1171
+ const name = normalizedSlug
1172
+ .split("-")
1173
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1174
+ .join(" ");
1175
+
1176
+ // Create project in API
1177
+ const apiUrl = getRuntimeEnv().apiUrl || "https://api.veryfront.com";
1178
+ const response = await dntShim.fetch(`${apiUrl}/projects`, {
1179
+ method: "POST",
1180
+ headers: {
1181
+ Authorization: `Bearer ${token}`,
1182
+ "Content-Type": "application/json",
1183
+ Accept: "application/json",
1184
+ },
1185
+ body: JSON.stringify({ slug: normalizedSlug, name }),
1186
+ });
1187
+
1188
+ if (!response.ok) {
1189
+ const error = await response.json().catch(() => ({}));
1190
+ const msg = (error as { message?: string }).message || `HTTP ${response.status}`;
1191
+ throw new Error(msg);
1192
+ }
1193
+
1194
+ const { slug } = await response.json() as { slug: string };
1195
+ const projectPath = `${cwd()}/projects/${slug}`;
1196
+
1197
+ // Copy example files to new project
1198
+ await copyDirectory(example.path, projectPath);
1199
+
1200
+ // Update local projects list
1201
+ const currentProjects = state.projects.items.map((item) => ({
1202
+ slug: item.data!.slug,
1203
+ path: item.data!.path,
1204
+ }));
1205
+ currentProjects.push({ slug, path: projectPath });
1206
+ state = setProjects(currentProjects)(state);
1207
+
1208
+ // Refresh remote projects
1209
+ const result = await fetchRemoteProjects();
1210
+ state = updateRemote({
1211
+ projects: result.projects.map((p) => ({ id: p.id, name: p.name, slug: p.slug })),
1212
+ })(state);
1213
+
1214
+ state = addLog("info", `Created ${slug} from ${example.slug}`)(state);
1215
+ } catch (error) {
1216
+ state = addLog("error", `Failed: ${error}`)(state);
1217
+ }
1218
+ }
1219
+
1220
+ function handleNewProjectKey(key: string): void {
1221
+ // Arrow navigation
1222
+ if (key === "\x1b[A" || key === "k") {
1223
+ state = {
1224
+ ...state,
1225
+ newProjectIndex: state.newProjectIndex > 0 ? state.newProjectIndex - 1 : 2,
1226
+ };
1227
+ render();
1228
+ return;
1229
+ }
1230
+ if (key === "\x1b[B" || key === "j") {
1231
+ state = {
1232
+ ...state,
1233
+ newProjectIndex: state.newProjectIndex < 2 ? state.newProjectIndex + 1 : 0,
1234
+ };
1235
+ render();
1236
+ return;
1237
+ }
1238
+
1239
+ // Number keys to select directly
1240
+ if (key >= "1" && key <= "3") {
1241
+ state = { ...state, newProjectIndex: parseInt(key, 10) - 1 };
1242
+ render();
1243
+ // Fall through to execute the selection
1244
+ }
1245
+
1246
+ // Enter to confirm selection (or after number key press)
1247
+ if (key === "\r" || key === "\n" || (key >= "1" && key <= "3")) {
1248
+ switch (state.newProjectIndex) {
1249
+ case 0:
1250
+ update(navigateTo("templates"));
1251
+ break;
1252
+ case 1:
1253
+ update(navigateTo("examples"));
1254
+ break;
1255
+ case 2:
1256
+ promptForProjectName("minimal", () => render());
1257
+ break;
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ function handleAuthKey(key: string): void {
1263
+ const providerList: Array<"google" | "github" | "microsoft"> = [
1264
+ "google",
1265
+ "github",
1266
+ "microsoft",
1267
+ ];
1268
+
1269
+ // Arrow navigation
1270
+ if (key === "\x1b[A" || key === "k") {
1271
+ state = {
1272
+ ...state,
1273
+ authProviderIndex: state.authProviderIndex > 0 ? state.authProviderIndex - 1 : 2,
1274
+ };
1275
+ render();
1276
+ return;
1277
+ }
1278
+ if (key === "\x1b[B" || key === "j") {
1279
+ state = {
1280
+ ...state,
1281
+ authProviderIndex: state.authProviderIndex < 2 ? state.authProviderIndex + 1 : 0,
1282
+ };
1283
+ render();
1284
+ return;
1285
+ }
1286
+
1287
+ // Number keys to select directly
1288
+ if (key >= "1" && key <= "3") {
1289
+ state = { ...state, authProviderIndex: parseInt(key, 10) - 1 };
1290
+ render();
1291
+ return;
1292
+ }
1293
+
1294
+ // Enter to confirm selection
1295
+ if (key === "\r" || key === "\n") {
1296
+ const provider = providerList[state.authProviderIndex];
1297
+ update(addLog("info", `Opening browser for ${provider} login...`));
1298
+ update(navigateTo("dashboard"));
1299
+
1300
+ // Run login in background to keep TUI responsive
1301
+ void (async () => {
1302
+ const user = await login(provider);
1303
+ if (user) {
1304
+ const result = await fetchRemoteProjects();
1305
+ update(updateRemote({
1306
+ user,
1307
+ projects: result.projects.map((p) => ({ id: p.id, name: p.name, slug: p.slug })),
1308
+ }));
1309
+ update(addLog("info", `Logged in as ${user.email}`));
1310
+ }
1311
+ render();
1312
+ })();
1313
+ }
1314
+ }
1315
+
1316
+ function start(): void {
1317
+ running = true;
1318
+
1319
+ if (!isInteractiveMode) {
1320
+ console.log(`Server running on http://veryfront.me:${config.port}`);
1321
+ if (config.mcpPort) console.log(`MCP available at http://veryfront.me:${config.mcpPort}/mcp`);
1322
+ return;
1323
+ }
1324
+
1325
+ write(screen.altOn + cursor.hide);
1326
+ render();
1327
+ handleInput();
1328
+
1329
+ if (!state.server.running) startSpinner();
1330
+ }
1331
+
1332
+ function stop(): void {
1333
+ running = false;
1334
+ stopSpinner();
1335
+
1336
+ if (isInteractiveMode) write(cursor.show + screen.altOff);
1337
+ }
1338
+
1339
+ return {
1340
+ start,
1341
+ stop,
1342
+ update,
1343
+ getState: (): AppState => state,
1344
+ render,
1345
+ setServerReady: (): void => {
1346
+ stopSpinner();
1347
+ update(updateServer({ running: true }));
1348
+ },
1349
+ addError: (): void => {
1350
+ update(updateServer({ errors: state.server.errors + 1 }));
1351
+ },
1352
+ clearErrors: (): void => {
1353
+ update(updateServer({ errors: 0, warnings: 0 }));
1354
+ },
1355
+ log: (level: "info" | "warn" | "error" | "debug", message: string): void => {
1356
+ update(addLog(level, message));
1357
+ },
1358
+ interceptConsole: (): () => void => {
1359
+ if (!isInteractiveMode) return () => {};
1360
+
1361
+ const orig = {
1362
+ log: console.log,
1363
+ error: console.error,
1364
+ warn: console.warn,
1365
+ info: console.info,
1366
+ debug: console.debug,
1367
+ };
1368
+
1369
+ // Parse request log format: " GET /path 200 45ms project:env:release"
1370
+ const parseRequestLog = (msg: string): LogMeta | undefined => {
1371
+ // Match: whitespace + METHOD + path + status + duration + optional project:env:release
1372
+ const match = msg.match(
1373
+ /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S+)\s+(\d{3})\s+(\d+)ms(?:\s+(\S+))?/,
1374
+ );
1375
+ if (!match) return undefined;
1376
+
1377
+ const [, method, path, status, duration, context] = match;
1378
+ const meta: LogMeta = {
1379
+ method,
1380
+ path,
1381
+ status: parseInt(status!, 10),
1382
+ durationMs: parseInt(duration!, 10),
1383
+ };
1384
+
1385
+ if (context) {
1386
+ // Parse project:env:release or project:env
1387
+ const parts = context.split(":");
1388
+ if (parts[0]) meta.project = parts[0];
1389
+ if (parts[1]) meta.env = parts[1];
1390
+ if (parts[2]) meta.releaseId = parts[2];
1391
+ }
1392
+
1393
+ return meta;
1394
+ };
1395
+
1396
+ // Regex to strip ANSI escape codes (ESC [ ... m)
1397
+ // deno-lint-ignore no-control-regex
1398
+ const ansiPattern = /\x1b\[[0-9;]*m/g;
1399
+
1400
+ const capture =
1401
+ (level: "info" | "warn" | "error" | "debug") => (...args: unknown[]): void => {
1402
+ const msg = args
1403
+ .map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
1404
+ .join(" ")
1405
+ .replace(ansiPattern, "");
1406
+ if (msg.trim()) {
1407
+ const meta = parseRequestLog(msg);
1408
+ state = addLog(level, msg, meta)(state);
1409
+ render();
1410
+ }
1411
+ };
1412
+
1413
+ console.log = capture("info");
1414
+ console.error = capture("error");
1415
+ console.warn = capture("warn");
1416
+ console.info = capture("info");
1417
+ console.debug = capture("debug");
1418
+
1419
+ return () => Object.assign(console, orig);
1420
+ },
1421
+ };
1422
+ }
1423
+
1424
+ /**
1425
+ * Show startup animation
1426
+ */
1427
+ export async function showStartup(steps: string[]): Promise<void> {
1428
+ const write = (text: string): void => writeStdout(text);
1429
+
1430
+ write(screen.altOn + cursor.hide);
1431
+
1432
+ for (let i = 0; i < steps.length; i++) {
1433
+ const step = steps[i]!;
1434
+ const completed = steps.slice(0, i).map((s) => ` ${success("✓")} ${dim(s)}`);
1435
+ const current = ` ${brand("●")} ${step}`;
1436
+ const pending = steps.slice(i + 1).map((s) => ` ${dim("○")} ${dim(s)}`);
1437
+
1438
+ const content = [
1439
+ "",
1440
+ ` ${brand("Veryfront")} ${dim("starting...")}`,
1441
+ "",
1442
+ ...completed,
1443
+ current,
1444
+ ...pending,
1445
+ "",
1446
+ ].join("\n");
1447
+
1448
+ write(cursor.moveTo(1, 1) + screen.clearDown + content);
1449
+ await new Promise((r) => dntShim.setTimeout(r, 200));
1450
+ }
1451
+
1452
+ const allComplete = steps.map((s) => ` ${success("✓")} ${dim(s)}`);
1453
+ const finalContent = [
1454
+ "",
1455
+ ` ${brand("Veryfront")} ${success("ready")}`,
1456
+ "",
1457
+ ...allComplete,
1458
+ "",
1459
+ ].join("\n");
1460
+
1461
+ write(cursor.moveTo(1, 1) + screen.clearDown + finalContent);
1462
+ await new Promise((r) => dntShim.setTimeout(r, 300));
1463
+
1464
+ // Don't exit alternate screen - let app.start() continue in it
1465
+ // This prevents a flash when transitioning to the dashboard
1466
+ }
1467
+
1468
+ export type { AppState } from "./state.js";
1469
+ export * from "./state.js";
1470
+ export * from "./actions.js";
1471
+ export * from "./components/list-select.js";