@aex.is/zero 0.1.9 → 0.1.11

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.
package/README.md CHANGED
@@ -28,14 +28,14 @@ zero
28
28
 
29
29
  - Next.js App Router + Tailwind + shadcn-ready config.
30
30
  - Expo Router + Tamagui.
31
- - Minimal layout, three routes, metadata, and icon generation.
32
- - Contact form wired to `/api/contact` (Next) and mailto/endpoint fallback (Expo).
31
+ - Minimal layout, four routes, metadata, and icon generation.
32
+ - Contact form wired to `/api/contact` (Next) and a device POST to your backend (Expo).
33
33
 
34
34
  ## Environment variables
35
35
 
36
36
  The CLI generates `.env.example` with:
37
- - Next.js: `RESEND_API_KEY`, `EMAIL_FROM`, `EMAIL_TO`.
38
- - Expo: `EXPO_PUBLIC_CONTACT_EMAIL`, `EXPO_PUBLIC_CONTACT_ENDPOINT`.
37
+ - Next.js: `RESEND_API_KEY`, `CONTACT_FROM_EMAIL`, `CONTACT_TO_EMAIL`.
38
+ - Expo: `EXPO_PUBLIC_CONTACT_ENDPOINT`.
39
39
  - Any selected module keys (Neon, Clerk, Payload, Stripe).
40
40
 
41
41
  ## Development
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -5,22 +5,18 @@ const nextBaseEnv = [
5
5
  url: 'https://resend.com'
6
6
  },
7
7
  {
8
- key: 'EMAIL_FROM',
8
+ key: 'CONTACT_FROM_EMAIL',
9
9
  description: 'Verified sender email address'
10
10
  },
11
11
  {
12
- key: 'EMAIL_TO',
12
+ key: 'CONTACT_TO_EMAIL',
13
13
  description: 'Destination email address'
14
14
  }
15
15
  ];
16
16
  const expoBaseEnv = [
17
- {
18
- key: 'EXPO_PUBLIC_CONTACT_EMAIL',
19
- description: 'Email address used for contact form'
20
- },
21
17
  {
22
18
  key: 'EXPO_PUBLIC_CONTACT_ENDPOINT',
23
- description: 'Optional contact API endpoint'
19
+ description: 'Contact API endpoint (e.g. https://yourdomain.com/api/contact)'
24
20
  }
25
21
  ];
26
22
  export function getBaseEnvHelp(framework) {
@@ -3,6 +3,10 @@ export const modules = [
3
3
  id: 'neon',
4
4
  label: 'Database (Neon)',
5
5
  description: 'Serverless Postgres with Neon.',
6
+ connect: {
7
+ label: 'Connect to Neon',
8
+ url: 'https://console.neon.tech/'
9
+ },
6
10
  envVars: [
7
11
  {
8
12
  key: 'DATABASE_URL',
@@ -19,6 +23,10 @@ export const modules = [
19
23
  id: 'clerk',
20
24
  label: 'Auth (Clerk)',
21
25
  description: 'Authentication with Clerk.',
26
+ connect: {
27
+ label: 'Connect to Clerk',
28
+ url: 'https://dashboard.clerk.com'
29
+ },
22
30
  envVars: [
23
31
  {
24
32
  key: 'CLERK_PUBLISHABLE_KEY',
@@ -40,6 +48,10 @@ export const modules = [
40
48
  id: 'payload',
41
49
  label: 'CMS (Payload)',
42
50
  description: 'Headless CMS using Payload.',
51
+ connect: {
52
+ label: 'Generate Payload Secret',
53
+ url: 'https://payloadcms.com/docs'
54
+ },
43
55
  envVars: [
44
56
  {
45
57
  key: 'PAYLOAD_SECRET',
@@ -61,6 +73,10 @@ export const modules = [
61
73
  id: 'stripe',
62
74
  label: 'Payments (Stripe)',
63
75
  description: 'Payments via Stripe SDK.',
76
+ connect: {
77
+ label: 'Connect to Stripe',
78
+ url: 'https://dashboard.stripe.com/apikeys'
79
+ },
64
80
  envVars: [
65
81
  {
66
82
  key: 'STRIPE_SECRET_KEY',
@@ -118,3 +134,21 @@ export function getModuleEnvHelp(moduleIds) {
118
134
  }
119
135
  return Array.from(map.values());
120
136
  }
137
+ export function getModuleConnections(moduleIds) {
138
+ const connections = [];
139
+ for (const id of moduleIds) {
140
+ const module = getModuleDefinition(id);
141
+ if (module.connect) {
142
+ connections.push(module.connect);
143
+ continue;
144
+ }
145
+ const fallback = module.envVars.find((item) => item.url);
146
+ if (fallback?.url) {
147
+ connections.push({
148
+ label: `Connect to ${module.label}`,
149
+ url: fallback.url
150
+ });
151
+ }
152
+ }
153
+ return connections;
154
+ }
@@ -3,7 +3,7 @@ import { promises as fs } from 'fs';
3
3
  import { execa } from 'execa';
4
4
  import { getFrameworkDefinition } from '../config/frameworks.js';
5
5
  import { getBaseEnvHelp } from '../config/base-env.js';
6
- import { getModuleEnvHelp } from '../config/modules.js';
6
+ import { getModuleConnections, getModuleEnvHelp } from '../config/modules.js';
7
7
  import { installBaseDependencies, installModulePackages } from './installers.js';
8
8
  import { writeEnvExample } from './env.js';
9
9
  import { assertAssetSources, generateExpoAssets, generateNextAssets, resolveAssetSources } from './assets.js';
@@ -130,10 +130,12 @@ async function applyNextTemplates(config, targetDir) {
130
130
  const usesSrcDir = await pathExists(srcAppDir);
131
131
  const basePath = usesSrcDir ? 'src' : '';
132
132
  const envHelp = mergeEnvHelp(getBaseEnvHelp(config.framework), getModuleEnvHelp(config.modules));
133
+ const connections = getModuleConnections(config.modules);
133
134
  const files = buildNextTemplateFiles({
134
135
  appName: config.appName,
135
136
  domain: config.domain,
136
137
  envVars: envHelp,
138
+ connections,
137
139
  basePath
138
140
  });
139
141
  await writeTemplateFiles(targetDir, files);
@@ -146,10 +148,12 @@ async function applyNextTemplates(config, targetDir) {
146
148
  }
147
149
  async function applyExpoTemplates(config, targetDir) {
148
150
  const envHelp = mergeEnvHelp(getBaseEnvHelp(config.framework), getModuleEnvHelp(config.modules));
151
+ const connections = getModuleConnections(config.modules);
149
152
  const files = buildExpoTemplateFiles({
150
153
  appName: config.appName,
151
154
  domain: config.domain,
152
155
  envVars: envHelp,
156
+ connections,
153
157
  basePath: ''
154
158
  });
155
159
  await writeTemplateFiles(targetDir, files);
@@ -20,6 +20,7 @@ export function componentsJsonTemplate(globalsPath, tailwindConfigPath) {
20
20
  export function buildNextTemplateFiles(data) {
21
21
  const base = data.basePath ? `${data.basePath}/` : '';
22
22
  const envList = renderNextEnvList(data.envVars);
23
+ const connectionSection = renderNextConnectionSection(data.connections);
23
24
  return [
24
25
  {
25
26
  path: `${base}app/layout.tsx`,
@@ -27,7 +28,7 @@ export function buildNextTemplateFiles(data) {
27
28
  },
28
29
  {
29
30
  path: `${base}app/page.tsx`,
30
- content: nextHomeTemplate(data.appName, data.domain, envList)
31
+ content: nextHomeTemplate(data.appName, data.domain, envList, connectionSection)
31
32
  },
32
33
  {
33
34
  path: `${base}app/about/page.tsx`,
@@ -37,6 +38,10 @@ export function buildNextTemplateFiles(data) {
37
38
  path: `${base}app/guide/page.tsx`,
38
39
  content: nextRouteTemplate('Guide', 'Three routes are ready. Customize and ship.')
39
40
  },
41
+ {
42
+ path: `${base}app/contact/page.tsx`,
43
+ content: nextContactPageTemplate()
44
+ },
40
45
  {
41
46
  path: `${base}app/api/contact/route.ts`,
42
47
  content: nextContactRouteTemplate(data.appName)
@@ -57,6 +62,10 @@ export function buildNextTemplateFiles(data) {
57
62
  path: `${base}components/contact-form.tsx`,
58
63
  content: nextContactFormTemplate()
59
64
  },
65
+ {
66
+ path: `${base}components/connection-guide.tsx`,
67
+ content: nextConnectionGuideTemplate(data.connections)
68
+ },
60
69
  {
61
70
  path: `${base}components/env-list.tsx`,
62
71
  content: nextEnvListTemplate(envList)
@@ -69,6 +78,7 @@ export function buildNextTemplateFiles(data) {
69
78
  }
70
79
  export function buildExpoTemplateFiles(data) {
71
80
  const envItems = renderExpoEnvItems(data.envVars);
81
+ const connectionItems = renderConnectionItems(data.connections);
72
82
  return [
73
83
  {
74
84
  path: 'app/_layout.tsx',
@@ -78,6 +88,10 @@ export function buildExpoTemplateFiles(data) {
78
88
  path: 'app/index.tsx',
79
89
  content: expoHomeTemplate(data.appName, data.domain, envItems)
80
90
  },
91
+ {
92
+ path: 'app/contact.tsx',
93
+ content: expoContactTemplate()
94
+ },
81
95
  {
82
96
  path: 'app/about.tsx',
83
97
  content: expoRouteTemplate('About', 'A concise overview of your project.')
@@ -102,6 +116,10 @@ export function buildExpoTemplateFiles(data) {
102
116
  path: 'components/env-list.tsx',
103
117
  content: expoEnvListTemplate(envItems)
104
118
  },
119
+ {
120
+ path: 'components/connection-guide.tsx',
121
+ content: expoConnectionGuideTemplate(connectionItems)
122
+ },
105
123
  {
106
124
  path: 'components/contact-form.tsx',
107
125
  content: expoContactFormTemplate()
@@ -171,9 +189,10 @@ export default function RootLayout({
171
189
  }
172
190
  `;
173
191
  }
174
- function nextHomeTemplate(appName, domain, envList) {
192
+ function nextHomeTemplate(appName, domain, envList, connectionSection) {
175
193
  return `import { EnvList } from '@/components/env-list';
176
194
  import { ContactForm } from '@/components/contact-form';
195
+ import { ConnectionGuide } from '@/components/connection-guide';
177
196
 
178
197
  export const metadata = {
179
198
  title: 'Home',
@@ -197,11 +216,13 @@ export default function Home() {
197
216
  </p>
198
217
  <EnvList />
199
218
  </div>
219
+ ${connectionSection}
200
220
  <div className="flex flex-col gap-2">
201
221
  <h2 className="text-base font-bold">Routes</h2>
202
222
  <p className="text-base">
203
- Explore <code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/about</code> and{' '}
204
- <code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/guide</code>.
223
+ Explore <code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/about</code>,{' '}
224
+ <code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/guide</code>, and{' '}
225
+ <code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/contact</code>.
205
226
  </p>
206
227
  </div>
207
228
  <ContactForm />
@@ -232,7 +253,8 @@ function nextHeaderTemplate(appName) {
232
253
  const links = [
233
254
  { href: '/', label: 'Home' },
234
255
  { href: '/about', label: 'About' },
235
- { href: '/guide', label: 'Guide' }
256
+ { href: '/guide', label: 'Guide' },
257
+ { href: '/contact', label: 'Contact' }
236
258
  ];
237
259
 
238
260
  export function SiteHeader({ appName }: { appName: string }) {
@@ -437,12 +459,12 @@ function isNonEmpty(value: unknown): value is string {
437
459
 
438
460
  export async function POST(request: Request) {
439
461
  const apiKey = process.env.RESEND_API_KEY?.trim();
440
- const from = process.env.EMAIL_FROM?.trim();
441
- const to = process.env.EMAIL_TO?.trim();
462
+ const from = process.env.CONTACT_FROM_EMAIL?.trim();
463
+ const to = process.env.CONTACT_TO_EMAIL?.trim();
442
464
 
443
465
  if (!apiKey || !from || !to) {
444
466
  return Response.json(
445
- { error: 'Set RESEND_API_KEY, EMAIL_FROM, and EMAIL_TO.' },
467
+ { error: 'Set RESEND_API_KEY, CONTACT_FROM_EMAIL, and CONTACT_TO_EMAIL.' },
446
468
  { status: 500 }
447
469
  );
448
470
  }
@@ -472,6 +494,25 @@ export async function POST(request: Request) {
472
494
  }
473
495
  `;
474
496
  }
497
+ function nextContactPageTemplate() {
498
+ return `import { ContactForm } from '@/components/contact-form';
499
+
500
+ export const metadata = {
501
+ title: 'Contact',
502
+ description: 'Send a message to your inbox.'
503
+ };
504
+
505
+ export default function ContactPage() {
506
+ return (
507
+ <section className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-12">
508
+ <h1 className="text-base font-bold">Contact</h1>
509
+ <p className="text-base">Send a message to your inbox.</p>
510
+ <ContactForm />
511
+ </section>
512
+ );
513
+ }
514
+ `;
515
+ }
475
516
  function nextGlobalsCss() {
476
517
  return `@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;700&display=swap');
477
518
 
@@ -547,6 +588,47 @@ function renderNextEnvList(envVars) {
547
588
  })
548
589
  .join('\n');
549
590
  }
591
+ function renderNextConnectionSection(_connections) {
592
+ return '<ConnectionGuide />';
593
+ }
594
+ function nextConnectionGuideTemplate(connections) {
595
+ const items = renderConnectionItems(connections);
596
+ return `import Link from 'next/link';
597
+
598
+ const connections = ${items};
599
+
600
+ export function ConnectionGuide() {
601
+ if (connections.length === 0) {
602
+ return null;
603
+ }
604
+
605
+ return (
606
+ <div className="flex flex-col gap-3">
607
+ <h2 className="text-base font-bold">Connection guide</h2>
608
+ {connections.map((item) => (
609
+ <div key={item.label} className="flex flex-col gap-1">
610
+ <Link
611
+ href={item.url}
612
+ target="_blank"
613
+ rel="noreferrer"
614
+ className="text-base font-bold underline underline-offset-4"
615
+ >
616
+ {item.label}
617
+ </Link>
618
+ <p className="text-base">
619
+ or go to{' '}
620
+ <Link href={item.url} target="_blank" rel="noreferrer" className="underline underline-offset-4">
621
+ {item.url}
622
+ </Link>{' '}
623
+ directly.
624
+ </p>
625
+ </div>
626
+ ))}
627
+ </div>
628
+ );
629
+ }
630
+ `;
631
+ }
550
632
  function expoLayoutTemplate() {
551
633
  return `import { Stack } from 'expo-router';
552
634
  import { TamaguiProvider, Theme } from 'tamagui';
@@ -577,6 +659,7 @@ function expoHomeTemplate(appName, domain, envItems) {
577
659
  import { Text, YStack } from 'tamagui';
578
660
  import { PageShell } from '../components/page-shell';
579
661
  import { EnvList } from '../components/env-list';
662
+ import { ConnectionGuide } from '../components/connection-guide';
580
663
  import { ContactForm } from '../components/contact-form';
581
664
  import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from '../components/theme';
582
665
 
@@ -602,12 +685,13 @@ export default function Home() {
602
685
  </Text>
603
686
  <EnvList />
604
687
  </YStack>
688
+ <ConnectionGuide />
605
689
  <YStack gap="$2">
606
690
  <Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
607
691
  Routes
608
692
  </Text>
609
693
  <Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
610
- Visit /about and /guide.
694
+ Visit /about, /guide, and /contact.
611
695
  </Text>
612
696
  </YStack>
613
697
  <ContactForm />
@@ -616,6 +700,27 @@ export default function Home() {
616
700
  }
617
701
  `;
618
702
  }
703
+ function expoContactTemplate() {
704
+ return `import { Head } from 'expo-router/head';
705
+ import { PageShell } from '../components/page-shell';
706
+ import { ContactForm } from '../components/contact-form';
707
+
708
+ export default function Contact() {
709
+ return (
710
+ <PageShell title="Contact" subtitle="Send a message to your inbox.">
711
+ <Head>
712
+ <title>Contact</title>
713
+ <meta name="description" content="Send a message to your inbox." />
714
+ <meta property="og:title" content="Contact" />
715
+ <meta property="og:description" content="Send a message to your inbox." />
716
+ <meta property="twitter:card" content="summary_large_image" />
717
+ </Head>
718
+ <ContactForm />
719
+ </PageShell>
720
+ );
721
+ }
722
+ `;
723
+ }
619
724
  function expoRouteTemplate(title, body) {
620
725
  return `import { Head } from 'expo-router/head';
621
726
  import { PageShell } from '../components/page-shell';
@@ -674,7 +779,8 @@ import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
674
779
  const links = [
675
780
  { href: '/', label: 'Home' },
676
781
  { href: '/about', label: 'About' },
677
- { href: '/guide', label: 'Guide' }
782
+ { href: '/guide', label: 'Guide' },
783
+ { href: '/contact', label: 'Contact' }
678
784
  ];
679
785
 
680
786
  export function SiteHeader() {
@@ -793,13 +899,62 @@ export function EnvList() {
793
899
  }
794
900
  `;
795
901
  }
902
+ function expoConnectionGuideTemplate(connectionItems) {
903
+ return `import { Linking } from 'react-native';
904
+ import { Text, YStack } from 'tamagui';
905
+ import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
906
+
907
+ const connections = ${connectionItems};
908
+
909
+ export function ConnectionGuide() {
910
+ const { fg } = useThemeColors();
911
+
912
+ if (connections.length === 0) {
913
+ return null;
914
+ }
915
+
916
+ return (
917
+ <YStack gap="$3">
918
+ <Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
919
+ Connection guide
920
+ </Text>
921
+ {connections.map((item) => (
922
+ <YStack key={item.label} gap="$1">
923
+ <Text
924
+ fontFamily={FONT_BOLD}
925
+ fontSize={FONT_SIZE}
926
+ color={fg}
927
+ textDecorationLine="underline"
928
+ onPress={() => Linking.openURL(item.url)}
929
+ >
930
+ {item.label}
931
+ </Text>
932
+ <Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
933
+ or go to{' '}
934
+ <Text
935
+ fontFamily={FONT_REGULAR}
936
+ fontSize={FONT_SIZE}
937
+ color={fg}
938
+ textDecorationLine="underline"
939
+ onPress={() => Linking.openURL(item.url)}
940
+ >
941
+ {item.url}
942
+ </Text>{' '}
943
+ directly.
944
+ </Text>
945
+ </YStack>
946
+ ))}
947
+ </YStack>
948
+ );
949
+ }
950
+ `;
951
+ }
796
952
  function expoContactFormTemplate() {
797
953
  return `import { useState } from 'react';
798
- import { Linking, TextInput } from 'react-native';
954
+ import { TextInput } from 'react-native';
799
955
  import { Button, Text, YStack } from 'tamagui';
800
956
  import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
801
957
 
802
- const CONTACT_EMAIL = process.env.EXPO_PUBLIC_CONTACT_EMAIL?.trim() ?? '';
803
958
  const CONTACT_ENDPOINT = process.env.EXPO_PUBLIC_CONTACT_ENDPOINT?.trim() ?? '';
804
959
 
805
960
  type Status = 'idle' | 'sending' | 'sent' | 'error';
@@ -828,46 +983,34 @@ export function ContactForm() {
828
983
  return;
829
984
  }
830
985
 
986
+ if (!CONTACT_ENDPOINT) {
987
+ setStatus('error');
988
+ setError('Set EXPO_PUBLIC_CONTACT_ENDPOINT.');
989
+ return;
990
+ }
991
+
831
992
  setStatus('sending');
832
993
  setError('');
833
994
 
834
995
  let sent = false;
835
- if (CONTACT_ENDPOINT) {
836
- try {
837
- const response = await fetch(CONTACT_ENDPOINT, {
838
- method: 'POST',
839
- headers: { 'Content-Type': 'application/json' },
840
- body: JSON.stringify({
841
- name: name.trim(),
842
- email: email.trim(),
843
- message: message.trim()
844
- })
845
- });
846
- sent = response.ok;
847
- } catch {
848
- sent = false;
849
- }
850
- }
851
-
852
- if (!sent && CONTACT_EMAIL) {
853
- const subject = encodeURIComponent(\`New message from \${name.trim()}\`);
854
- const body = encodeURIComponent(\`Name: \${name.trim()}\\nEmail: \${email.trim()}\\n\\n\${message.trim()}\`);
855
- const url = \`mailto:\${CONTACT_EMAIL}?subject=\${subject}&body=\${body}\`;
856
- try {
857
- await Linking.openURL(url);
858
- sent = true;
859
- } catch {
860
- sent = false;
861
- }
996
+ try {
997
+ const response = await fetch(CONTACT_ENDPOINT, {
998
+ method: 'POST',
999
+ headers: { 'Content-Type': 'application/json' },
1000
+ body: JSON.stringify({
1001
+ name: name.trim(),
1002
+ email: email.trim(),
1003
+ message: message.trim()
1004
+ })
1005
+ });
1006
+ sent = response.ok;
1007
+ } catch {
1008
+ sent = false;
862
1009
  }
863
1010
 
864
1011
  if (!sent) {
865
1012
  setStatus('error');
866
- if (CONTACT_ENDPOINT || CONTACT_EMAIL) {
867
- setError('Unable to send message.');
868
- } else {
869
- setError('Set EXPO_PUBLIC_CONTACT_ENDPOINT or EXPO_PUBLIC_CONTACT_EMAIL.');
870
- }
1013
+ setError('Unable to send message.');
871
1014
  return;
872
1015
  }
873
1016
 
@@ -1047,6 +1190,20 @@ function renderExpoEnvItems(envVars) {
1047
1190
  ${items.join(',\n ')}
1048
1191
  ]`;
1049
1192
  }
1193
+ function renderConnectionItems(connections) {
1194
+ if (connections.length === 0) {
1195
+ return '[]';
1196
+ }
1197
+ const items = connections.map((item) => {
1198
+ return `{
1199
+ label: '${escapeTemplate(item.label)}',
1200
+ url: '${escapeTemplate(item.url)}'
1201
+ }`;
1202
+ });
1203
+ return `[
1204
+ ${items.join(',\n ')}
1205
+ ]`;
1206
+ }
1050
1207
  function escapeTemplate(value) {
1051
1208
  return value
1052
1209
  .replace(/`/g, '\\`')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aex.is/zero",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Aexis Zero scaffolding CLI",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",