create-x4 1.0.0 → 1.0.5

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,1246 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as __createRequire } from "node:module";
3
+ const require = __createRequire(import.meta.url);
4
+ import {
5
+ BD,
6
+ L,
7
+ de,
8
+ fe,
9
+ require_picocolors,
10
+ ue,
11
+ v,
12
+ ve,
13
+ we
14
+ } from "./chunk-2A4FL2KN.mjs";
15
+ import {
16
+ __toESM
17
+ } from "./chunk-VL4BT7E7.mjs";
18
+
19
+ // src/commands/add.ts
20
+ import { existsSync, readFileSync, readdirSync, mkdirSync as mkdirSync2, cpSync } from "fs";
21
+ import { join as join2, resolve } from "path";
22
+ import { execSync } from "child_process";
23
+ var import_picocolors = __toESM(require_picocolors(), 1);
24
+
25
+ // src/templates/apply.ts
26
+ import { writeFileSync, mkdirSync } from "fs";
27
+ import { join, dirname } from "path";
28
+ function applyTemplate(opts) {
29
+ for (const file of opts.template) {
30
+ let content = file.content;
31
+ for (const [placeholder, value] of Object.entries(opts.replacements)) {
32
+ content = content.replaceAll(placeholder, value);
33
+ }
34
+ const fullPath = join(opts.targetDir, file.path);
35
+ mkdirSync(dirname(fullPath), { recursive: true });
36
+ writeFileSync(fullPath, content);
37
+ }
38
+ }
39
+
40
+ // src/templates/mobile-app.ts
41
+ var MOBILE_APP_TEMPLATE = [
42
+ {
43
+ path: "package.json",
44
+ content: `{
45
+ "name": "__SCOPE__/mobile-__MOBILE_NAME__",
46
+ "version": "0.0.0",
47
+ "private": true,
48
+ "main": "expo-router/entry",
49
+ "scripts": {
50
+ "dev": "expo start",
51
+ "build": "echo 'use eas build'",
52
+ "lint": "eslint app/ src/",
53
+ "type-check": "tsc --noEmit",
54
+ "test": "echo 'no tests yet'",
55
+ "ios": "expo run:ios",
56
+ "android": "expo run:android"
57
+ },
58
+ "dependencies": {
59
+ "__SCOPE__/shared": "workspace:*",
60
+ "__SCOPE__/auth": "workspace:*",
61
+ "@tanstack/react-query": "^5.60.0",
62
+ "@trpc/client": "^11.0.0",
63
+ "@trpc/react-query": "^11.0.0",
64
+ "expo": "~52.0.0",
65
+ "expo-router": "~4.0.0",
66
+ "expo-secure-store": "~14.2.4",
67
+ "expo-status-bar": "~2.2.3",
68
+ "react": "^18.3.1",
69
+ "react-native": "~0.76.0",
70
+ "react-native-safe-area-context": "~5.6.2",
71
+ "react-native-screens": "~4.23.0",
72
+ "zod": "^3.23.0"
73
+ },
74
+ "devDependencies": {
75
+ "@types/react": "^18.3.0",
76
+ "typescript": "~5.6.0"
77
+ }
78
+ }
79
+ `
80
+ },
81
+ {
82
+ path: "app.json",
83
+ content: `{
84
+ "expo": {
85
+ "name": "__PROJECT_NAME__",
86
+ "slug": "__PROJECT_NAME__-mobile-__MOBILE_NAME__",
87
+ "version": "1.0.0",
88
+ "orientation": "portrait",
89
+ "icon": "./assets/icon.png",
90
+ "scheme": "__PROJECT_NAME__-__MOBILE_NAME__",
91
+ "userInterfaceStyle": "automatic",
92
+ "newArchEnabled": true,
93
+ "splash": {
94
+ "image": "./assets/splash-icon.png",
95
+ "resizeMode": "contain",
96
+ "backgroundColor": "#ffffff"
97
+ },
98
+ "ios": {
99
+ "supportsTablet": true,
100
+ "bundleIdentifier": "__BUNDLE_ID__"
101
+ },
102
+ "android": {
103
+ "adaptiveIcon": {
104
+ "foregroundImage": "./assets/adaptive-icon.png",
105
+ "backgroundColor": "#ffffff"
106
+ },
107
+ "package": "__BUNDLE_ID__"
108
+ },
109
+ "plugins": [
110
+ "expo-router",
111
+ "expo-secure-store"
112
+ ]
113
+ }
114
+ }
115
+ `
116
+ },
117
+ {
118
+ path: "tsconfig.json",
119
+ content: `{
120
+ "extends": "expo/tsconfig.base",
121
+ "compilerOptions": {
122
+ "strict": true,
123
+ "module": "esnext",
124
+ "moduleResolution": "bundler",
125
+ "paths": {
126
+ "react": ["./node_modules/@types/react"],
127
+ "@/*": ["src/*"]
128
+ }
129
+ },
130
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
131
+ }
132
+ `
133
+ },
134
+ {
135
+ path: "eas.json",
136
+ content: `{
137
+ "cli": {
138
+ "version": ">= 12.0.0"
139
+ },
140
+ "build": {
141
+ "development": {
142
+ "developmentClient": true,
143
+ "distribution": "internal"
144
+ },
145
+ "preview": {
146
+ "distribution": "internal"
147
+ },
148
+ "production": {}
149
+ },
150
+ "submit": {
151
+ "production": {}
152
+ }
153
+ }
154
+ `
155
+ },
156
+ {
157
+ path: ".env.example",
158
+ content: `EXPO_PUBLIC_API_URL=http://localhost:3002
159
+ `
160
+ },
161
+ {
162
+ path: "src/lib/api.ts",
163
+ content: `import { createTRPCClient } from "__SCOPE__/shared/api-client";
164
+ import { getStoredToken } from "__SCOPE__/auth/client/native";
165
+
166
+ const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3002";
167
+
168
+ export const api = createTRPCClient({
169
+ url: \`\${API_URL}/trpc\`,
170
+ getToken: getStoredToken,
171
+ });
172
+ `
173
+ },
174
+ {
175
+ path: "src/lib/trpc-provider.tsx",
176
+ content: `import React, { useState } from "react";
177
+ import { QueryClient } from "@tanstack/react-query";
178
+ import { SharedTRPCProvider } from "__SCOPE__/shared/trpc-provider";
179
+ import { api } from "./api";
180
+
181
+ export function TRPCProvider({ children }: { children: React.ReactNode }) {
182
+ const [queryClient] = useState(
183
+ () =>
184
+ new QueryClient({
185
+ defaultOptions: {
186
+ queries: { staleTime: 5_000, retry: 1 },
187
+ },
188
+ }),
189
+ );
190
+
191
+ return (
192
+ <SharedTRPCProvider client={api} queryClient={queryClient}>
193
+ {children}
194
+ </SharedTRPCProvider>
195
+ );
196
+ }
197
+ `
198
+ },
199
+ {
200
+ path: "app/_layout.tsx",
201
+ content: `import { Stack } from "expo-router";
202
+ import { TRPCProvider } from "../src/lib/trpc-provider";
203
+
204
+ export default function RootLayout() {
205
+ return (
206
+ <TRPCProvider>
207
+ <Stack
208
+ screenOptions={{
209
+ headerStyle: { backgroundColor: "#ffffff" },
210
+ headerTintColor: "#000000",
211
+ headerTitleStyle: { fontWeight: "bold" },
212
+ }}
213
+ >
214
+ <Stack.Screen name="index" options={{ title: "Welcome" }} />
215
+ <Stack.Screen name="login" options={{ title: "Log In" }} />
216
+ <Stack.Screen name="signup" options={{ title: "Sign Up" }} />
217
+ <Stack.Screen
218
+ name="(authenticated)"
219
+ options={{ headerShown: false }}
220
+ />
221
+ </Stack>
222
+ </TRPCProvider>
223
+ );
224
+ }
225
+ `
226
+ },
227
+ {
228
+ path: "app/index.tsx",
229
+ content: `import { View, Text, Pressable, StyleSheet } from "react-native";
230
+ import { useRouter } from "expo-router";
231
+
232
+ export default function HomeScreen() {
233
+ const router = useRouter();
234
+
235
+ return (
236
+ <View style={styles.container}>
237
+ <Text style={styles.title}>__MOBILE_NAME_CLEAN__</Text>
238
+ <Text style={styles.subtitle}>Welcome to your app</Text>
239
+
240
+ <Pressable style={styles.button} onPress={() => router.push("/login")}>
241
+ <Text style={styles.buttonText}>Log In</Text>
242
+ </Pressable>
243
+
244
+ <Pressable
245
+ style={[styles.button, styles.secondaryButton]}
246
+ onPress={() => router.push("/signup")}
247
+ >
248
+ <Text style={[styles.buttonText, styles.secondaryButtonText]}>
249
+ Create Account
250
+ </Text>
251
+ </Pressable>
252
+ </View>
253
+ );
254
+ }
255
+
256
+ const styles = StyleSheet.create({
257
+ container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
258
+ title: { fontSize: 32, fontWeight: "bold", marginBottom: 8 },
259
+ subtitle: { fontSize: 16, color: "#666", marginBottom: 32 },
260
+ button: {
261
+ backgroundColor: "#000",
262
+ paddingVertical: 14,
263
+ paddingHorizontal: 32,
264
+ borderRadius: 8,
265
+ width: "100%",
266
+ alignItems: "center",
267
+ marginBottom: 12,
268
+ },
269
+ buttonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
270
+ secondaryButton: { backgroundColor: "#f0f0f0" },
271
+ secondaryButtonText: { color: "#000" },
272
+ });
273
+ `
274
+ },
275
+ {
276
+ path: "app/login.tsx",
277
+ content: `import { useState } from "react";
278
+ import {
279
+ View,
280
+ Text,
281
+ TextInput,
282
+ Pressable,
283
+ Alert,
284
+ StyleSheet,
285
+ KeyboardAvoidingView,
286
+ Platform,
287
+ } from "react-native";
288
+ import { useRouter, Link } from "expo-router";
289
+ import { signInAndStore } from "__SCOPE__/auth/client/native";
290
+
291
+ export default function LoginScreen() {
292
+ const router = useRouter();
293
+ const [email, setEmail] = useState("");
294
+ const [password, setPassword] = useState("");
295
+ const [loading, setLoading] = useState(false);
296
+
297
+ const handleLogin = async () => {
298
+ if (!email || !password) {
299
+ Alert.alert("Error", "Please fill in all fields");
300
+ return;
301
+ }
302
+ setLoading(true);
303
+ try {
304
+ await signInAndStore({ email, password });
305
+ router.replace("/(authenticated)");
306
+ } catch (err: unknown) {
307
+ Alert.alert("Error", err instanceof Error ? err.message : "Sign in failed");
308
+ } finally {
309
+ setLoading(false);
310
+ }
311
+ };
312
+
313
+ return (
314
+ <KeyboardAvoidingView
315
+ style={styles.container}
316
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
317
+ >
318
+ <Text style={styles.title}>Log In</Text>
319
+ <TextInput
320
+ style={styles.input}
321
+ placeholder="Email"
322
+ value={email}
323
+ onChangeText={setEmail}
324
+ autoCapitalize="none"
325
+ keyboardType="email-address"
326
+ />
327
+ <TextInput
328
+ style={styles.input}
329
+ placeholder="Password"
330
+ value={password}
331
+ onChangeText={setPassword}
332
+ secureTextEntry
333
+ />
334
+ <Pressable
335
+ style={[styles.button, loading && styles.disabled]}
336
+ onPress={handleLogin}
337
+ disabled={loading}
338
+ >
339
+ <Text style={styles.buttonText}>{loading ? "Signing in..." : "Log In"}</Text>
340
+ </Pressable>
341
+ <Link href="/signup" style={styles.link}>
342
+ Don't have an account? Sign up
343
+ </Link>
344
+ </KeyboardAvoidingView>
345
+ );
346
+ }
347
+
348
+ const styles = StyleSheet.create({
349
+ container: { flex: 1, justifyContent: "center", padding: 24 },
350
+ title: { fontSize: 28, fontWeight: "bold", marginBottom: 24, textAlign: "center" },
351
+ input: {
352
+ borderWidth: 1,
353
+ borderColor: "#ddd",
354
+ borderRadius: 8,
355
+ padding: 14,
356
+ marginBottom: 12,
357
+ fontSize: 16,
358
+ },
359
+ button: {
360
+ backgroundColor: "#000",
361
+ paddingVertical: 14,
362
+ borderRadius: 8,
363
+ alignItems: "center",
364
+ marginTop: 8,
365
+ },
366
+ buttonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
367
+ disabled: { opacity: 0.6 },
368
+ link: { marginTop: 16, textAlign: "center", color: "#007AFF" },
369
+ });
370
+ `
371
+ },
372
+ {
373
+ path: "app/signup.tsx",
374
+ content: `import { useState } from "react";
375
+ import {
376
+ View,
377
+ Text,
378
+ TextInput,
379
+ Pressable,
380
+ Alert,
381
+ StyleSheet,
382
+ KeyboardAvoidingView,
383
+ Platform,
384
+ } from "react-native";
385
+ import { useRouter, Link } from "expo-router";
386
+ import { authClient } from "__SCOPE__/auth/client/native";
387
+
388
+ export default function SignupScreen() {
389
+ const router = useRouter();
390
+ const [name, setName] = useState("");
391
+ const [email, setEmail] = useState("");
392
+ const [password, setPassword] = useState("");
393
+ const [loading, setLoading] = useState(false);
394
+
395
+ const handleSignup = async () => {
396
+ if (!name || !email || !password) {
397
+ Alert.alert("Error", "Please fill in all fields");
398
+ return;
399
+ }
400
+ if (password.length < 8) {
401
+ Alert.alert("Error", "Password must be at least 8 characters");
402
+ return;
403
+ }
404
+ setLoading(true);
405
+ try {
406
+ await authClient.signUp.email({ name, email, password });
407
+ Alert.alert("Success", "Account created! Please log in.", [
408
+ { text: "OK", onPress: () => router.replace("/login") },
409
+ ]);
410
+ } catch (err: unknown) {
411
+ Alert.alert("Error", err instanceof Error ? err.message : "Sign up failed");
412
+ } finally {
413
+ setLoading(false);
414
+ }
415
+ };
416
+
417
+ return (
418
+ <KeyboardAvoidingView
419
+ style={styles.container}
420
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
421
+ >
422
+ <Text style={styles.title}>Create Account</Text>
423
+ <TextInput
424
+ style={styles.input}
425
+ placeholder="Full Name"
426
+ value={name}
427
+ onChangeText={setName}
428
+ />
429
+ <TextInput
430
+ style={styles.input}
431
+ placeholder="Email"
432
+ value={email}
433
+ onChangeText={setEmail}
434
+ autoCapitalize="none"
435
+ keyboardType="email-address"
436
+ />
437
+ <TextInput
438
+ style={styles.input}
439
+ placeholder="Password"
440
+ value={password}
441
+ onChangeText={setPassword}
442
+ secureTextEntry
443
+ />
444
+ <Pressable
445
+ style={[styles.button, loading && styles.disabled]}
446
+ onPress={handleSignup}
447
+ disabled={loading}
448
+ >
449
+ <Text style={styles.buttonText}>
450
+ {loading ? "Creating account..." : "Sign Up"}
451
+ </Text>
452
+ </Pressable>
453
+ <Link href="/login" style={styles.link}>
454
+ Already have an account? Log in
455
+ </Link>
456
+ </KeyboardAvoidingView>
457
+ );
458
+ }
459
+
460
+ const styles = StyleSheet.create({
461
+ container: { flex: 1, justifyContent: "center", padding: 24 },
462
+ title: { fontSize: 28, fontWeight: "bold", marginBottom: 24, textAlign: "center" },
463
+ input: {
464
+ borderWidth: 1,
465
+ borderColor: "#ddd",
466
+ borderRadius: 8,
467
+ padding: 14,
468
+ marginBottom: 12,
469
+ fontSize: 16,
470
+ },
471
+ button: {
472
+ backgroundColor: "#000",
473
+ paddingVertical: 14,
474
+ borderRadius: 8,
475
+ alignItems: "center",
476
+ marginTop: 8,
477
+ },
478
+ buttonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
479
+ disabled: { opacity: 0.6 },
480
+ link: { marginTop: 16, textAlign: "center", color: "#007AFF" },
481
+ });
482
+ `
483
+ },
484
+ {
485
+ path: "app/(authenticated)/_layout.tsx",
486
+ content: `import { useEffect, useState } from "react";
487
+ import { ActivityIndicator, View, Text, Pressable } from "react-native";
488
+ import { Stack, useRouter } from "expo-router";
489
+ import { getStoredToken, signOutAndClear } from "__SCOPE__/auth/client/native";
490
+
491
+ export default function AuthenticatedLayout() {
492
+ const router = useRouter();
493
+ const [checking, setChecking] = useState(true);
494
+
495
+ useEffect(() => {
496
+ getStoredToken().then((token) => {
497
+ if (!token) router.replace("/login");
498
+ setChecking(false);
499
+ });
500
+ }, []);
501
+
502
+ if (checking) {
503
+ return (
504
+ <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
505
+ <ActivityIndicator size="large" />
506
+ </View>
507
+ );
508
+ }
509
+
510
+ return (
511
+ <Stack
512
+ screenOptions={{
513
+ headerRight: () => (
514
+ <Pressable
515
+ style={{ marginRight: 8 }}
516
+ onPress={async () => {
517
+ await signOutAndClear();
518
+ router.replace("/login");
519
+ }}
520
+ >
521
+ <Text style={{ color: "#ff3b30", fontSize: 16 }}>Logout</Text>
522
+ </Pressable>
523
+ ),
524
+ }}
525
+ >
526
+ <Stack.Screen name="index" options={{ title: "Home" }} />
527
+ </Stack>
528
+ );
529
+ }
530
+ `
531
+ },
532
+ {
533
+ path: "app/(authenticated)/index.tsx",
534
+ content: `import { View, Text, StyleSheet } from "react-native";
535
+
536
+ export default function HomeScreen() {
537
+ return (
538
+ <View style={styles.container}>
539
+ <Text style={styles.title}>Home</Text>
540
+ <Text style={styles.subtitle}>Start building here</Text>
541
+ </View>
542
+ );
543
+ }
544
+
545
+ const styles = StyleSheet.create({
546
+ container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
547
+ title: { fontSize: 28, fontWeight: "bold", marginBottom: 8 },
548
+ subtitle: { fontSize: 16, color: "#666" },
549
+ });
550
+ `
551
+ }
552
+ ];
553
+
554
+ // src/templates/web-app.ts
555
+ var WEB_APP_TEMPLATE = [
556
+ {
557
+ path: "package.json",
558
+ content: `{
559
+ "name": "__SCOPE__/__WEB_NAME__",
560
+ "version": "0.0.0",
561
+ "private": true,
562
+ "scripts": {
563
+ "dev": "next dev --port __PORT__",
564
+ "build": "next build",
565
+ "start": "next start --port __PORT__",
566
+ "lint": "eslint src/",
567
+ "type-check": "tsc --noEmit"
568
+ },
569
+ "dependencies": {
570
+ "__SCOPE__/shared": "workspace:*",
571
+ "__SCOPE__/auth": "workspace:*",
572
+ "@tanstack/react-query": "^5.60.0",
573
+ "@trpc/client": "^11.0.0",
574
+ "@trpc/react-query": "^11.0.0",
575
+ "next": "~15.0.0",
576
+ "react": "^19.0.0",
577
+ "react-dom": "^19.0.0",
578
+ "zod": "^3.23.0"
579
+ },
580
+ "devDependencies": {
581
+ "@tailwindcss/postcss": "^4.0.0",
582
+ "@types/node": "^22.0.0",
583
+ "@types/react": "^19.0.0",
584
+ "@types/react-dom": "^19.0.0",
585
+ "tailwindcss": "^4.0.0",
586
+ "typescript": "~5.6.0"
587
+ }
588
+ }
589
+ `
590
+ },
591
+ {
592
+ path: "next.config.ts",
593
+ content: `import type { NextConfig } from "next";
594
+ import path from "node:path";
595
+
596
+ const nextConfig: NextConfig = {
597
+ outputFileTracingRoot: path.join(__dirname, "../../"),
598
+ transpilePackages: ["__SCOPE__/shared", "__SCOPE__/auth"],
599
+ };
600
+
601
+ export default nextConfig;
602
+ `
603
+ },
604
+ {
605
+ path: "tsconfig.json",
606
+ content: `{
607
+ "extends": "../../tsconfig.base.json",
608
+ "compilerOptions": {
609
+ "target": "ES2017",
610
+ "lib": ["dom", "dom.iterable", "esnext"],
611
+ "allowJs": true,
612
+ "noEmit": true,
613
+ "module": "esnext",
614
+ "moduleResolution": "bundler",
615
+ "resolveJsonModule": true,
616
+ "isolatedModules": true,
617
+ "jsx": "preserve",
618
+ "incremental": true,
619
+ "plugins": [{ "name": "next" }],
620
+ "paths": {
621
+ "@/*": ["./src/*"]
622
+ }
623
+ },
624
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
625
+ "exclude": ["node_modules"]
626
+ }
627
+ `
628
+ },
629
+ {
630
+ path: "postcss.config.js",
631
+ content: `module.exports = {
632
+ plugins: {
633
+ "@tailwindcss/postcss": {},
634
+ },
635
+ };
636
+ `
637
+ },
638
+ {
639
+ path: "src/styles/globals.css",
640
+ content: `@import "tailwindcss";
641
+ `
642
+ },
643
+ {
644
+ path: ".env.example",
645
+ content: `NEXT_PUBLIC_API_URL=http://localhost:3002
646
+ `
647
+ },
648
+ {
649
+ path: "src/lib/trpc-provider.tsx",
650
+ content: `"use client";
651
+
652
+ import React, { useState } from "react";
653
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
654
+ import { createTRPCClient } from "__SCOPE__/shared/api-client";
655
+
656
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
657
+
658
+ function getToken(): string | null {
659
+ if (typeof document === "undefined") return null;
660
+ const match = document.cookie.match(/(?:^|; )session_token=([^;]*)/);
661
+ return match ? decodeURIComponent(match[1]) : null;
662
+ }
663
+
664
+ export function TRPCProvider({ children }: { children: React.ReactNode }) {
665
+ const [queryClient] = useState(
666
+ () =>
667
+ new QueryClient({
668
+ defaultOptions: {
669
+ queries: { staleTime: 5_000, retry: 1 },
670
+ },
671
+ }),
672
+ );
673
+
674
+ const [trpcClient] = useState(() =>
675
+ createTRPCClient({
676
+ url: \`\${API_URL}/trpc\`,
677
+ getToken: async () => getToken(),
678
+ }),
679
+ );
680
+
681
+ return (
682
+ <QueryClientProvider client={queryClient}>
683
+ {children}
684
+ </QueryClientProvider>
685
+ );
686
+ }
687
+ `
688
+ },
689
+ {
690
+ path: "src/lib/utils.ts",
691
+ content: `import { type ClassValue, clsx } from "clsx";
692
+
693
+ export function cn(...inputs: ClassValue[]) {
694
+ return clsx(inputs);
695
+ }
696
+ `
697
+ },
698
+ {
699
+ path: "src/app/layout.tsx",
700
+ content: `import type { Metadata } from "next";
701
+ import "@/styles/globals.css";
702
+ import { TRPCProvider } from "@/lib/trpc-provider";
703
+
704
+ export const metadata: Metadata = {
705
+ title: "__WEB_NAME__",
706
+ description: "Built with x4",
707
+ };
708
+
709
+ export default function RootLayout({
710
+ children,
711
+ }: {
712
+ children: React.ReactNode;
713
+ }) {
714
+ return (
715
+ <html lang="en">
716
+ <body>
717
+ <TRPCProvider>{children}</TRPCProvider>
718
+ </body>
719
+ </html>
720
+ );
721
+ }
722
+ `
723
+ },
724
+ {
725
+ path: "src/app/page.tsx",
726
+ content: `import Link from "next/link";
727
+
728
+ export default function HomePage() {
729
+ return (
730
+ <main className="flex min-h-screen flex-col items-center justify-center p-8">
731
+ <h1 className="text-4xl font-bold mb-4">__WEB_NAME__</h1>
732
+ <p className="text-gray-500 mb-8">Start building your app</p>
733
+ <Link
734
+ href="/login"
735
+ className="bg-black text-white px-6 py-3 rounded-lg hover:bg-gray-800"
736
+ >
737
+ Get Started
738
+ </Link>
739
+ </main>
740
+ );
741
+ }
742
+ `
743
+ },
744
+ {
745
+ path: "src/app/(auth)/layout.tsx",
746
+ content: `export default function AuthLayout({
747
+ children,
748
+ }: {
749
+ children: React.ReactNode;
750
+ }) {
751
+ return (
752
+ <div className="flex min-h-screen items-center justify-center p-4">
753
+ <div className="w-full max-w-md">{children}</div>
754
+ </div>
755
+ );
756
+ }
757
+ `
758
+ },
759
+ {
760
+ path: "src/app/(auth)/login/page.tsx",
761
+ content: `"use client";
762
+
763
+ import { useState } from "react";
764
+ import Link from "next/link";
765
+ import { useRouter } from "next/navigation";
766
+
767
+ export default function LoginPage() {
768
+ const router = useRouter();
769
+ const [email, setEmail] = useState("");
770
+ const [password, setPassword] = useState("");
771
+ const [error, setError] = useState("");
772
+ const [loading, setLoading] = useState(false);
773
+
774
+ const handleSubmit = async (e: React.FormEvent) => {
775
+ e.preventDefault();
776
+ setError("");
777
+ setLoading(true);
778
+
779
+ try {
780
+ const res = await fetch(
781
+ \`\${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002"}/api/auth/sign-in/email\`,
782
+ {
783
+ method: "POST",
784
+ headers: { "Content-Type": "application/json" },
785
+ body: JSON.stringify({ email, password }),
786
+ credentials: "include",
787
+ },
788
+ );
789
+ if (!res.ok) throw new Error("Invalid credentials");
790
+ router.push("/");
791
+ } catch (err: unknown) {
792
+ setError(err instanceof Error ? err.message : "Login failed");
793
+ } finally {
794
+ setLoading(false);
795
+ }
796
+ };
797
+
798
+ return (
799
+ <div>
800
+ <h1 className="text-2xl font-bold text-center mb-6">Log In</h1>
801
+ {error && (
802
+ <p className="text-red-600 text-sm text-center mb-4">{error}</p>
803
+ )}
804
+ <form onSubmit={handleSubmit} className="space-y-4">
805
+ <input
806
+ type="email"
807
+ placeholder="Email"
808
+ value={email}
809
+ onChange={(e) => setEmail(e.target.value)}
810
+ className="w-full border rounded-lg p-3"
811
+ required
812
+ />
813
+ <input
814
+ type="password"
815
+ placeholder="Password"
816
+ value={password}
817
+ onChange={(e) => setPassword(e.target.value)}
818
+ className="w-full border rounded-lg p-3"
819
+ required
820
+ />
821
+ <button
822
+ type="submit"
823
+ disabled={loading}
824
+ className="w-full bg-black text-white py-3 rounded-lg hover:bg-gray-800 disabled:opacity-50"
825
+ >
826
+ {loading ? "Signing in..." : "Log In"}
827
+ </button>
828
+ </form>
829
+ <p className="text-center mt-4 text-sm">
830
+ Don&apos;t have an account?{" "}
831
+ <Link href="/signup" className="text-blue-600 hover:underline">
832
+ Sign up
833
+ </Link>
834
+ </p>
835
+ </div>
836
+ );
837
+ }
838
+ `
839
+ },
840
+ {
841
+ path: "src/app/(auth)/signup/page.tsx",
842
+ content: `"use client";
843
+
844
+ import { useState } from "react";
845
+ import Link from "next/link";
846
+ import { useRouter } from "next/navigation";
847
+
848
+ export default function SignupPage() {
849
+ const router = useRouter();
850
+ const [name, setName] = useState("");
851
+ const [email, setEmail] = useState("");
852
+ const [password, setPassword] = useState("");
853
+ const [error, setError] = useState("");
854
+ const [loading, setLoading] = useState(false);
855
+
856
+ const handleSubmit = async (e: React.FormEvent) => {
857
+ e.preventDefault();
858
+ setError("");
859
+ if (password.length < 8) {
860
+ setError("Password must be at least 8 characters");
861
+ return;
862
+ }
863
+ setLoading(true);
864
+
865
+ try {
866
+ const res = await fetch(
867
+ \`\${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002"}/api/auth/sign-up/email\`,
868
+ {
869
+ method: "POST",
870
+ headers: { "Content-Type": "application/json" },
871
+ body: JSON.stringify({ name, email, password }),
872
+ },
873
+ );
874
+ if (!res.ok) throw new Error("Registration failed");
875
+ router.push("/login");
876
+ } catch (err: unknown) {
877
+ setError(err instanceof Error ? err.message : "Sign up failed");
878
+ } finally {
879
+ setLoading(false);
880
+ }
881
+ };
882
+
883
+ return (
884
+ <div>
885
+ <h1 className="text-2xl font-bold text-center mb-6">Create Account</h1>
886
+ {error && (
887
+ <p className="text-red-600 text-sm text-center mb-4">{error}</p>
888
+ )}
889
+ <form onSubmit={handleSubmit} className="space-y-4">
890
+ <input
891
+ type="text"
892
+ placeholder="Full Name"
893
+ value={name}
894
+ onChange={(e) => setName(e.target.value)}
895
+ className="w-full border rounded-lg p-3"
896
+ required
897
+ />
898
+ <input
899
+ type="email"
900
+ placeholder="Email"
901
+ value={email}
902
+ onChange={(e) => setEmail(e.target.value)}
903
+ className="w-full border rounded-lg p-3"
904
+ required
905
+ />
906
+ <input
907
+ type="password"
908
+ placeholder="Password"
909
+ value={password}
910
+ onChange={(e) => setPassword(e.target.value)}
911
+ className="w-full border rounded-lg p-3"
912
+ required
913
+ />
914
+ <button
915
+ type="submit"
916
+ disabled={loading}
917
+ className="w-full bg-black text-white py-3 rounded-lg hover:bg-gray-800 disabled:opacity-50"
918
+ >
919
+ {loading ? "Creating account..." : "Sign Up"}
920
+ </button>
921
+ </form>
922
+ <p className="text-center mt-4 text-sm">
923
+ Already have an account?{" "}
924
+ <Link href="/login" className="text-blue-600 hover:underline">
925
+ Log in
926
+ </Link>
927
+ </p>
928
+ </div>
929
+ );
930
+ }
931
+ `
932
+ },
933
+ {
934
+ path: "src/app/(dashboard)/layout.tsx",
935
+ content: `import Link from "next/link";
936
+
937
+ export default function DashboardLayout({
938
+ children,
939
+ }: {
940
+ children: React.ReactNode;
941
+ }) {
942
+ return (
943
+ <div className="min-h-screen">
944
+ <header className="border-b px-6 py-4 flex items-center justify-between">
945
+ <h1 className="font-bold text-lg">__WEB_NAME__</h1>
946
+ <Link href="/login" className="text-sm text-red-600 hover:underline">
947
+ Logout
948
+ </Link>
949
+ </header>
950
+ <main className="p-6">{children}</main>
951
+ </div>
952
+ );
953
+ }
954
+ `
955
+ },
956
+ {
957
+ path: "src/app/(dashboard)/page.tsx",
958
+ content: `export default function DashboardPage() {
959
+ return (
960
+ <div>
961
+ <h2 className="text-2xl font-bold mb-4">Welcome</h2>
962
+ <p className="text-gray-500">Start building your dashboard here.</p>
963
+ </div>
964
+ );
965
+ }
966
+ `
967
+ },
968
+ {
969
+ path: "src/middleware.ts",
970
+ content: `import { NextResponse } from "next/server";
971
+ import type { NextRequest } from "next/server";
972
+
973
+ const publicPaths = ["/", "/login", "/signup"];
974
+
975
+ export function middleware(request: NextRequest) {
976
+ const { pathname } = request.nextUrl;
977
+
978
+ if (publicPaths.some((p) => pathname === p || pathname.startsWith(p + "/"))) {
979
+ return NextResponse.next();
980
+ }
981
+
982
+ const token = request.cookies.get("session_token");
983
+ if (!token) {
984
+ return NextResponse.redirect(new URL("/login", request.url));
985
+ }
986
+
987
+ return NextResponse.next();
988
+ }
989
+
990
+ export const config = {
991
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
992
+ };
993
+ `
994
+ }
995
+ ];
996
+
997
+ // src/commands/add.ts
998
+ var TEMPLATE_TYPES = ["mobile-app", "web-app"];
999
+ function findMonorepoRoot(startDir) {
1000
+ let dir = resolve(startDir);
1001
+ const root = resolve("/");
1002
+ while (dir !== root) {
1003
+ if (existsSync(join2(dir, "turbo.json")) && existsSync(join2(dir, "packages"))) {
1004
+ return dir;
1005
+ }
1006
+ dir = resolve(dir, "..");
1007
+ }
1008
+ return null;
1009
+ }
1010
+ function detectScopeFromPackages(root) {
1011
+ const packagesDir = join2(root, "packages");
1012
+ if (!existsSync(packagesDir)) return null;
1013
+ const dirs = readdirSync(packagesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1014
+ for (const dir of dirs) {
1015
+ const pkgPath = join2(packagesDir, dir, "package.json");
1016
+ if (!existsSync(pkgPath)) continue;
1017
+ try {
1018
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1019
+ const pkgName = pkg.name;
1020
+ if (pkgName?.startsWith("@") && pkgName.includes("/")) {
1021
+ return pkgName.split("/")[0];
1022
+ }
1023
+ } catch {
1024
+ }
1025
+ }
1026
+ return null;
1027
+ }
1028
+ function readMonorepoConfig(root) {
1029
+ const rootPkg = JSON.parse(readFileSync(join2(root, "package.json"), "utf-8"));
1030
+ const name = rootPkg.name ?? "";
1031
+ let scope;
1032
+ let projectName;
1033
+ if (name.startsWith("@") && name.includes("/")) {
1034
+ scope = name.split("/")[0];
1035
+ projectName = name.split("/")[1];
1036
+ } else {
1037
+ projectName = name;
1038
+ scope = detectScopeFromPackages(root) ?? `@${name}`;
1039
+ }
1040
+ let bundleId = null;
1041
+ const appsDir = join2(root, "apps");
1042
+ if (existsSync(appsDir)) {
1043
+ const appDirs = readdirSync(appsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.startsWith("mobile-")).map((d) => d.name);
1044
+ for (const appDir of appDirs) {
1045
+ const appJsonPath = join2(appsDir, appDir, "app.json");
1046
+ if (existsSync(appJsonPath)) {
1047
+ try {
1048
+ const appJson = JSON.parse(readFileSync(appJsonPath, "utf-8"));
1049
+ const iosBundleId = appJson.expo?.ios?.bundleIdentifier;
1050
+ if (iosBundleId) {
1051
+ const parts = iosBundleId.split(".");
1052
+ const mobileIdx = parts.indexOf("mobile");
1053
+ if (mobileIdx > 0) {
1054
+ bundleId = parts.slice(0, mobileIdx).join(".");
1055
+ }
1056
+ break;
1057
+ }
1058
+ } catch {
1059
+ }
1060
+ }
1061
+ }
1062
+ }
1063
+ return { root, scope, projectName, bundleId };
1064
+ }
1065
+ function validateAppName(name, targetDir) {
1066
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
1067
+ return { valid: false, error: "Name must be kebab-case (lowercase letters, numbers, hyphens)" };
1068
+ }
1069
+ if (existsSync(targetDir)) {
1070
+ return { valid: false, error: `Directory already exists: ${targetDir}` };
1071
+ }
1072
+ return { valid: true };
1073
+ }
1074
+ function findNextPort(appsDir, basePort = 3004) {
1075
+ const usedPorts = /* @__PURE__ */ new Set();
1076
+ if (!existsSync(appsDir)) return basePort;
1077
+ const dirs = readdirSync(appsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1078
+ for (const dir of dirs) {
1079
+ const pkgPath = join2(appsDir, dir, "package.json");
1080
+ if (!existsSync(pkgPath)) continue;
1081
+ try {
1082
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1083
+ const devScript = pkg.scripts?.dev;
1084
+ if (devScript) {
1085
+ const portMatch = devScript.match(/--port\s+(\d+)/);
1086
+ if (portMatch) {
1087
+ usedPorts.add(parseInt(portMatch[1], 10));
1088
+ }
1089
+ }
1090
+ } catch {
1091
+ }
1092
+ }
1093
+ let port = basePort;
1094
+ while (usedPorts.has(port)) {
1095
+ port++;
1096
+ }
1097
+ return port;
1098
+ }
1099
+ function parseAddArgs(rawArgs) {
1100
+ let templateType;
1101
+ let name;
1102
+ let bundleId;
1103
+ let noInstall = false;
1104
+ for (let i = 0; i < rawArgs.length; i++) {
1105
+ const arg = rawArgs[i];
1106
+ if (arg === "--name" && i + 1 < rawArgs.length) {
1107
+ name = rawArgs[++i];
1108
+ } else if (arg.startsWith("--name=")) {
1109
+ name = arg.split("=")[1];
1110
+ } else if (arg === "--bundle-id" && i + 1 < rawArgs.length) {
1111
+ bundleId = rawArgs[++i];
1112
+ } else if (arg.startsWith("--bundle-id=")) {
1113
+ bundleId = arg.split("=")[1];
1114
+ } else if (arg === "--no-install") {
1115
+ noInstall = true;
1116
+ } else if (!arg.startsWith("-") && !templateType) {
1117
+ templateType = arg;
1118
+ }
1119
+ }
1120
+ return { templateType, name, bundleId, noInstall };
1121
+ }
1122
+ async function runAddCommand(rawArgs) {
1123
+ we(import_picocolors.default.bgCyan(import_picocolors.default.black(" create-x4 add ")));
1124
+ const root = findMonorepoRoot(process.cwd());
1125
+ if (!root) {
1126
+ v.error(
1127
+ "Could not find monorepo root (turbo.json + packages/). Run this command from inside an x4 monorepo."
1128
+ );
1129
+ process.exit(1);
1130
+ }
1131
+ const config = readMonorepoConfig(root);
1132
+ v.info(`Found monorepo: ${import_picocolors.default.bold(config.projectName)} (${config.scope})`);
1133
+ const parsed = parseAddArgs(rawArgs);
1134
+ let templateType = parsed.templateType;
1135
+ if (!templateType || !TEMPLATE_TYPES.includes(templateType)) {
1136
+ if (parsed.templateType && !TEMPLATE_TYPES.includes(parsed.templateType)) {
1137
+ v.warn(`Unknown template type: "${parsed.templateType}"`);
1138
+ }
1139
+ const choice = await de({
1140
+ message: "What would you like to add?",
1141
+ options: [
1142
+ { value: "mobile-app", label: "Mobile App", hint: "Expo + React Native" },
1143
+ { value: "web-app", label: "Web App", hint: "Next.js 15" }
1144
+ ]
1145
+ });
1146
+ if (BD(choice)) {
1147
+ ve("Cancelled.");
1148
+ process.exit(0);
1149
+ }
1150
+ templateType = choice;
1151
+ }
1152
+ let name = parsed.name;
1153
+ if (!name) {
1154
+ const input = await ue({
1155
+ message: `App name (kebab-case):`,
1156
+ placeholder: templateType === "mobile-app" ? "admin" : "portal",
1157
+ validate: (v2) => {
1158
+ if (!/^[a-z][a-z0-9-]*$/.test(v2)) {
1159
+ return "Must be kebab-case (lowercase letters, numbers, hyphens)";
1160
+ }
1161
+ return void 0;
1162
+ }
1163
+ });
1164
+ if (BD(input)) {
1165
+ ve("Cancelled.");
1166
+ process.exit(0);
1167
+ }
1168
+ name = input;
1169
+ }
1170
+ const targetDirName = templateType === "mobile-app" ? `mobile-${name}` : name;
1171
+ const targetDir = join2(root, "apps", targetDirName);
1172
+ const nameResult = validateAppName(name, targetDir);
1173
+ if (!nameResult.valid) {
1174
+ v.error(nameResult.error);
1175
+ process.exit(1);
1176
+ }
1177
+ const s = L();
1178
+ s.start(`Scaffolding ${templateType} \u2192 apps/${targetDirName}/...`);
1179
+ if (templateType === "mobile-app") {
1180
+ const bundleIdPrefix = parsed.bundleId ?? config.bundleId ?? `com.${config.projectName.replace(/-/g, "")}`;
1181
+ const mobileNameClean = name.charAt(0).toUpperCase() + name.slice(1).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1182
+ applyTemplate({
1183
+ template: MOBILE_APP_TEMPLATE,
1184
+ targetDir,
1185
+ replacements: {
1186
+ __SCOPE__: config.scope,
1187
+ __PROJECT_NAME__: config.projectName,
1188
+ __MOBILE_NAME__: name,
1189
+ __MOBILE_NAME_CLEAN__: mobileNameClean,
1190
+ __BUNDLE_ID__: `${bundleIdPrefix}.mobile.${name}`
1191
+ }
1192
+ });
1193
+ const existingMobile = readdirSync(join2(root, "apps"), { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.startsWith("mobile-") && d.name !== `mobile-${name}`).map((d) => d.name)[0];
1194
+ if (existingMobile) {
1195
+ const assetsDir = join2(root, "apps", existingMobile, "assets");
1196
+ if (existsSync(assetsDir)) {
1197
+ cpSync(assetsDir, join2(targetDir, "assets"), { recursive: true });
1198
+ } else {
1199
+ mkdirSync2(join2(targetDir, "assets"), { recursive: true });
1200
+ }
1201
+ } else {
1202
+ mkdirSync2(join2(targetDir, "assets"), { recursive: true });
1203
+ }
1204
+ } else {
1205
+ const port = findNextPort(join2(root, "apps"));
1206
+ applyTemplate({
1207
+ template: WEB_APP_TEMPLATE,
1208
+ targetDir,
1209
+ replacements: {
1210
+ __SCOPE__: config.scope,
1211
+ __PROJECT_NAME__: config.projectName,
1212
+ __WEB_NAME__: name,
1213
+ __PORT__: String(port)
1214
+ }
1215
+ });
1216
+ }
1217
+ s.stop(`Scaffolded apps/${targetDirName}/`);
1218
+ if (!parsed.noInstall) {
1219
+ s.start("Installing dependencies...");
1220
+ try {
1221
+ execSync("bun install", { cwd: root, stdio: "pipe" });
1222
+ s.stop("Installed dependencies.");
1223
+ } catch {
1224
+ s.stop(import_picocolors.default.yellow("Dependency installation failed. Run 'bun install' manually."));
1225
+ }
1226
+ }
1227
+ v.success(import_picocolors.default.green(import_picocolors.default.bold(`Added ${templateType}: apps/${targetDirName}/`)));
1228
+ v.message(import_picocolors.default.bold("Next steps:"));
1229
+ if (templateType === "mobile-app") {
1230
+ v.message(` 1. ${import_picocolors.default.dim(`cd apps/${targetDirName}`)}`);
1231
+ v.message(` 2. ${import_picocolors.default.dim("cp .env.example .env")}`);
1232
+ v.message(` 3. ${import_picocolors.default.dim("bun run dev")}`);
1233
+ } else {
1234
+ v.message(` 1. ${import_picocolors.default.dim(`cd apps/${targetDirName}`)}`);
1235
+ v.message(` 2. ${import_picocolors.default.dim("cp .env.example .env.local")}`);
1236
+ v.message(` 3. ${import_picocolors.default.dim("bun run dev")}`);
1237
+ }
1238
+ fe(import_picocolors.default.green("Happy building!"));
1239
+ }
1240
+ export {
1241
+ findMonorepoRoot,
1242
+ findNextPort,
1243
+ readMonorepoConfig,
1244
+ runAddCommand,
1245
+ validateAppName
1246
+ };