anhedral 0.4.0 → 0.4.2

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 (52) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/README.md +76 -57
  4. package/SKILL.md +107 -12
  5. package/assets/anhedral-cli-init.svg +238 -0
  6. package/assets/images/svg/ably-symbol-color.svg +8 -0
  7. package/assets/images/svg/logo-white-subtract.svg +4 -0
  8. package/dist/architecture/index.d.ts +2 -2
  9. package/dist/architecture/index.js +1 -1
  10. package/dist/architecture/modules.d.ts +5 -1
  11. package/dist/architecture/modules.js +4 -1
  12. package/dist/bin.js +113 -84
  13. package/dist/cli.d.ts +7 -7
  14. package/dist/cli.js +62 -16
  15. package/dist/dependencies.d.ts +1 -0
  16. package/dist/dependencies.js +11 -8
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +1 -1
  19. package/dist/project.d.ts +30 -0
  20. package/dist/project.js +1 -0
  21. package/dist/prompts.d.ts +4 -3
  22. package/dist/prompts.js +29 -7
  23. package/dist/scaffold.d.ts +12 -24
  24. package/dist/scaffold.js +345 -124
  25. package/dist/templates/api.d.ts +1 -1
  26. package/dist/templates/api.js +67 -2
  27. package/dist/templates/assets-private-proxy.d.ts +1 -1
  28. package/dist/templates/desktop.d.ts +1 -1
  29. package/dist/templates/desktop.js +189 -36
  30. package/dist/templates/electron-updater.d.ts +1 -1
  31. package/dist/templates/electron-updater.js +1 -1
  32. package/dist/templates/extension.d.ts +1 -1
  33. package/dist/templates/extension.js +138 -14
  34. package/dist/templates/first-run.d.ts +2 -0
  35. package/dist/templates/first-run.js +167 -0
  36. package/dist/templates/mobile.d.ts +1 -1
  37. package/dist/templates/mobile.js +158 -17
  38. package/dist/templates/shared.d.ts +1 -1
  39. package/dist/templates/shared.js +64 -7
  40. package/dist/templates/web.d.ts +1 -1
  41. package/dist/templates/web.js +185 -28
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/docs/NORTHSTAR.md +114 -0
  45. package/docs/conventions.md +6 -4
  46. package/docs/engineering-principles.md +88 -0
  47. package/docs/master-stack-map.md +7 -0
  48. package/docs/output-tree-contract.md +16 -5
  49. package/docs/references/manual-scaffolding.md +22 -4
  50. package/docs/references/provisioning.md +434 -77
  51. package/package.json +10 -2
  52. package/scripts/render-master-stack-map.mjs +510 -0
@@ -40,12 +40,14 @@ export async function scaffoldExtension(root, options) {
40
40
  writeAuthContext(dir);
41
41
  if (options.apps.api)
42
42
  writeApiClient(dir);
43
+ if (options.apps.api && options.features.database)
44
+ writeItemList(dir, options.features.auth);
43
45
  if (options.features.billing)
44
46
  writeEntitlementHook(dir);
45
47
  writeBackground(dir, options.features.auth);
46
48
  writeSidepanelEntry(dir, options.features.auth);
47
49
  writeSidepanelHtml(dir, displayName);
48
- writeSidepanelApp(dir, options.features.auth, options.features.billing);
50
+ writeSidepanelApp(dir, options.features.auth, options.features.billing, options.apps.api && options.features.database);
49
51
  writeStyles(dir);
50
52
  anhedralPrint.done('Extension source files written');
51
53
  }
@@ -427,7 +429,7 @@ function AuthProviderInner({ children }: { children: React.ReactNode }) {
427
429
 
428
430
  export function AuthProvider({ children }: { children: React.ReactNode }) {
429
431
  const backgroundError = useBackgroundAuthError();
430
- if (!CLERK_PUBLISHABLE_KEY) {
432
+ if (!CLERK_PUBLISHABLE_KEY || CLERK_PUBLISHABLE_KEY.includes('***')) {
431
433
  return <ConfigurationError message="Set VITE_CLERK_PUBLISHABLE_KEY before building the extension." />;
432
434
  }
433
435
  const apiError = apiConfigurationError();
@@ -485,6 +487,104 @@ export function createApiClient(getToken?: () => Promise<string | null>) {
485
487
  }
486
488
  `);
487
489
  }
490
+ function writeItemList(dir, hasAuth) {
491
+ const apiImport = hasAuth
492
+ ? "import { useAuth } from '../contexts/auth-context';\n"
493
+ : "import { createApiClient } from '../lib/api';\n";
494
+ const apiState = hasAuth
495
+ ? ' const { api, userId } = useAuth();\n const identity = userId;\n'
496
+ : " const api = React.useMemo(() => createApiClient(), []);\n const identity = 'public';\n";
497
+ writeFile(path.join(dir, 'src/components/item-list.tsx'), `import { createItem, listItems, type Item } from '@shared/api-client';
498
+ ${apiImport}import * as React from 'react';
499
+ import { Button } from './ui/button';
500
+
501
+ export function ItemList() {
502
+ ${apiState} const [items, setItems] = React.useState<Item[]>([]);
503
+ const [loadedIdentity, setLoadedIdentity] = React.useState<string | null>(null);
504
+ const [name, setName] = React.useState('');
505
+ const [status, setStatus] = React.useState<'loading' | 'ready' | 'saving'>('loading');
506
+ const [error, setError] = React.useState<string | null>(null);
507
+ const identityRef = React.useRef(identity);
508
+ const visibleItems = loadedIdentity === identity ? items : [];
509
+ const visibleError = loadedIdentity === identity ? error : null;
510
+ const isLoading = status === 'loading' || loadedIdentity !== identity;
511
+
512
+ React.useEffect(() => {
513
+ identityRef.current = identity;
514
+ if (!identity) return;
515
+ let active = true;
516
+ setLoadedIdentity(identity);
517
+ setItems([]);
518
+ setName('');
519
+ setError(null);
520
+ setStatus('loading');
521
+ void listItems(api).then((nextItems) => {
522
+ if (active) setItems(nextItems);
523
+ }).catch((cause: unknown) => {
524
+ if (active) setError(cause instanceof Error ? cause.message : 'Unable to load items');
525
+ }).finally(() => {
526
+ if (active) setStatus('ready');
527
+ });
528
+ return () => { active = false; };
529
+ }, [api, identity]);
530
+
531
+ async function submit(event: React.FormEvent<HTMLFormElement>) {
532
+ event.preventDefault();
533
+ const nextName = name.trim();
534
+ if (!identity || !nextName || status !== 'ready' || loadedIdentity !== identity) return;
535
+ const submittedIdentity = identity;
536
+ setStatus('saving');
537
+ try {
538
+ const created = await createItem(api, { name: nextName });
539
+ if (identityRef.current !== submittedIdentity) return;
540
+ setItems((current) => [created, ...current]);
541
+ setName('');
542
+ setError(null);
543
+ } catch (cause) {
544
+ if (identityRef.current === submittedIdentity) {
545
+ setError(cause instanceof Error ? cause.message : 'Unable to create item');
546
+ }
547
+ } finally {
548
+ if (identityRef.current === submittedIdentity) setStatus('ready');
549
+ }
550
+ }
551
+
552
+ return (
553
+ <section aria-labelledby="starter-feature-title" style={{ display: 'grid', gap: 10, marginTop: 20 }}>
554
+ <h3 id="starter-feature-title" style={{ margin: 0 }}>Working starter feature</h3>
555
+ <form onSubmit={(event) => void submit(event)} style={{ display: 'grid', gap: 8 }}>
556
+ <label htmlFor="item-name">Item name</label>
557
+ <input
558
+ id="item-name"
559
+ maxLength={120}
560
+ onChange={(event) => setName(event.target.value)}
561
+ placeholder="Your first item"
562
+ style={{ minHeight: 36, border: '1px solid hsl(var(--input))', borderRadius: 6, padding: '0 10px' }}
563
+ value={name}
564
+ />
565
+ <Button disabled={status !== 'ready' || loadedIdentity !== identity || !name.trim()} type="submit">
566
+ {status === 'saving' ? 'Adding…' : 'Add item'}
567
+ </Button>
568
+ </form>
569
+ {visibleError ? (
570
+ <p aria-live="assertive" role="alert" style={{ color: 'hsl(var(--destructive))' }}>
571
+ {visibleError}. Check DATABASE_URL, run pnpm db:migrate, and make sure the API is running.
572
+ </p>
573
+ ) : null}
574
+ {isLoading ? <p role="status">Loading items…</p> : null}
575
+ {!isLoading && visibleItems.length === 0 && !visibleError
576
+ ? <p>Your database is connected. Add the first item.</p>
577
+ : null}
578
+ {visibleItems.length > 0 ? (
579
+ <ul style={{ margin: 0, paddingLeft: 20 }}>
580
+ {visibleItems.map((item) => <li key={item.id}>{item.name}</li>)}
581
+ </ul>
582
+ ) : null}
583
+ </section>
584
+ );
585
+ }
586
+ `);
587
+ }
488
588
  function writeEntitlementHook(dir) {
489
589
  writeFile(path.join(dir, 'src/hooks/use-entitlement.ts'), `import { subscribeToSubscriptionChanges } from '@shared/realtime';
490
590
  import * as React from 'react';
@@ -492,36 +592,58 @@ import { useAuth } from '../contexts/auth-context';
492
592
 
493
593
  export function useEntitlement() {
494
594
  const { api, isSignedIn, userId } = useAuth();
595
+ const identity = isSignedIn ? userId : null;
495
596
  const [entitlement, setEntitlement] = React.useState<Awaited<ReturnType<typeof api.getEntitlement>> | null>(null);
597
+ const [loadedIdentity, setLoadedIdentity] = React.useState<string | null>(null);
496
598
  const [error, setError] = React.useState<string | null>(null);
497
599
  const revision = React.useRef(0);
600
+ const identityRef = React.useRef(identity);
498
601
  const refresh = React.useCallback(async () => {
499
- if (!isSignedIn) return;
602
+ if (!identity) return;
500
603
  try {
501
604
  const next = await api.getEntitlement();
605
+ if (identityRef.current !== identity) return;
502
606
  revision.current = Math.max(revision.current, next.revision);
503
607
  setEntitlement(next);
608
+ setLoadedIdentity(identity);
504
609
  setError(null);
505
610
  } catch (cause) {
611
+ if (identityRef.current !== identity) return;
612
+ setLoadedIdentity(identity);
506
613
  setError(cause instanceof Error ? cause.message : 'Unable to load subscription');
507
614
  }
508
- }, [api, isSignedIn]);
509
- React.useEffect(() => { void refresh(); }, [refresh]);
615
+ }, [api, identity]);
616
+ React.useEffect(() => {
617
+ identityRef.current = identity;
618
+ revision.current = 0;
619
+ setEntitlement(null);
620
+ setLoadedIdentity(null);
621
+ setError(null);
622
+ void refresh();
623
+ }, [identity, refresh]);
510
624
  React.useEffect(() => {
511
625
  const onVisibility = () => { if (document.visibilityState === 'visible') void refresh(); };
512
626
  document.addEventListener('visibilitychange', onVisibility);
513
627
  return () => document.removeEventListener('visibilitychange', onVisibility);
514
628
  }, [refresh]);
515
629
  React.useEffect(() => {
516
- if (!userId || !isSignedIn) return;
630
+ if (!identity) return;
517
631
  return subscribeToSubscriptionChanges({
518
- userId,
632
+ userId: identity,
519
633
  getTokenRequest: () => api.getRealtimeToken(),
520
634
  onChange: (nextRevision) => { if (nextRevision > revision.current) void refresh(); },
521
- onError: (cause) => setError(cause.message),
635
+ onError: (cause) => {
636
+ if (identityRef.current === identity) {
637
+ setLoadedIdentity(identity);
638
+ setError(cause.message);
639
+ }
640
+ },
522
641
  });
523
- }, [api, isSignedIn, refresh, userId]);
524
- return { entitlement, error };
642
+ }, [api, identity, refresh]);
643
+ return {
644
+ entitlement: loadedIdentity === identity ? entitlement : null,
645
+ error: loadedIdentity === identity ? error : null,
646
+ };
525
647
  }
526
648
  `);
527
649
  }
@@ -532,7 +654,7 @@ function writeBackground(dir, hasAuth) {
532
654
  const clerkInitialization = hasAuth
533
655
  ? ` const backgroundAuthErrorKey = 'anhedralClerkBackgroundError';
534
656
  const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.trim();
535
- if (!publishableKey) {
657
+ if (!publishableKey || publishableKey.includes('***')) {
536
658
  void chrome.storage.local.set({
537
659
  [backgroundAuthErrorKey]: 'Set VITE_CLERK_PUBLISHABLE_KEY before building the extension.',
538
660
  });
@@ -604,7 +726,7 @@ function writeSidepanelHtml(dir, displayName) {
604
726
  </html>
605
727
  `);
606
728
  }
607
- function writeSidepanelApp(dir, hasAuth, hasBilling) {
729
+ function writeSidepanelApp(dir, hasAuth, hasBilling, hasItems) {
608
730
  const authImports = hasAuth
609
731
  ? "import { useAuth } from '../../contexts/auth-context';\nimport { SignIn } from '@clerk/chrome-extension';\n"
610
732
  : '';
@@ -636,8 +758,10 @@ function writeSidepanelApp(dir, hasAuth, hasBilling) {
636
758
  {entitlementError ? <p role="alert" style={{ color: 'hsl(var(--destructive))' }}>{entitlementError}</p> : null}
637
759
  `
638
760
  : '';
761
+ const itemListImport = hasItems ? "import { ItemList } from '../../components/item-list';\n" : '';
762
+ const itemList = hasItems ? ' <ItemList />\n' : '';
639
763
  writeFile(path.join(dir, 'src/entrypoints/sidepanel/app.tsx'), `import * as React from 'react';
640
- ${authImports}${entitlementImport}import { Button } from '../../components/ui/button';
764
+ ${authImports}${entitlementImport}${itemListImport}import { Button } from '../../components/ui/button';
641
765
 
642
766
  type PageSnapshot = {
643
767
  title: string;
@@ -680,7 +804,7 @@ ${authGuards}
680
804
  </div>
681
805
  ) : null}
682
806
  {pageError ? <p role="alert" aria-live="assertive" style={{ color: 'hsl(var(--destructive))' }}>{pageError}</p> : null}
683
- ${entitlementStatus}${signOutButton} </div>
807
+ ${itemList}${entitlementStatus}${signOutButton} </div>
684
808
  );
685
809
  }
686
810
  `);
@@ -0,0 +1,2 @@
1
+ import type { ProjectOptions } from '../project.js';
2
+ export declare function generatedFirstRunScript(options: ProjectOptions): string;
@@ -0,0 +1,167 @@
1
+ function environmentFiles(options) {
2
+ return [
3
+ options.apps.api ? {
4
+ example: 'apps/api/.env.example',
5
+ target: 'apps/api/.env',
6
+ optional: options.features.storage ? ['CLOUDFLARE_API_TOKEN'] : [],
7
+ } : null,
8
+ options.features.database ? {
9
+ example: 'packages/db/.env.example',
10
+ target: 'packages/db/.env',
11
+ optional: [],
12
+ } : null,
13
+ options.apps.web ? {
14
+ example: 'apps/web/.env.example',
15
+ target: 'apps/web/.env.local',
16
+ optional: [],
17
+ } : null,
18
+ options.apps.mobile ? {
19
+ example: 'apps/mobile/.env.example',
20
+ target: 'apps/mobile/.env',
21
+ optional: [],
22
+ } : null,
23
+ options.apps.desktop ? {
24
+ example: 'apps/desktop/.env.example',
25
+ target: 'apps/desktop/.env',
26
+ optional: [],
27
+ } : null,
28
+ options.features.electronUpdater ? {
29
+ example: 'apps/desktop/electron-builder.env.example',
30
+ target: 'apps/desktop/electron-builder.env',
31
+ optional: [],
32
+ } : null,
33
+ options.apps.extension ? {
34
+ example: 'apps/extension/.env.example',
35
+ target: 'apps/extension/.env',
36
+ optional: [
37
+ ...(options.features.auth ? ['VITE_CLERK_SYNC_HOST'] : []),
38
+ 'VITE_CRX_PUBLIC_KEY',
39
+ ],
40
+ } : null,
41
+ ].filter((entry) => entry !== null);
42
+ }
43
+ function nextCommands(options) {
44
+ return [
45
+ 'pnpm ready',
46
+ options.features.database ? 'pnpm db:generate' : null,
47
+ options.features.database ? 'git add packages/db/migrations' : null,
48
+ 'pnpm verify',
49
+ options.features.database ? 'pnpm db:migrate' : null,
50
+ 'pnpm dev',
51
+ ].filter((entry) => entry !== null);
52
+ }
53
+ export function generatedFirstRunScript(options) {
54
+ return `import { copyFileSync, existsSync, readFileSync } from 'node:fs';
55
+
56
+ const environmentFiles = ${JSON.stringify(environmentFiles(options), null, 2)};
57
+ const args = new Set(process.argv.slice(2));
58
+ const supportedArgs = new Set(['--check', '--json']);
59
+ const unknownArgs = [...args].filter((arg) => !supportedArgs.has(arg));
60
+ if (unknownArgs.length > 0) {
61
+ console.error(\`Unknown first-run option: \${unknownArgs[0]}. Use --check and/or --json.\`);
62
+ process.exit(2);
63
+ }
64
+
65
+ const checkOnly = args.has('--check');
66
+ const json = args.has('--json');
67
+ const created = [];
68
+ const kept = [];
69
+ const missing = [];
70
+ const unresolved = [];
71
+
72
+ function unquote(value) {
73
+ const trimmed = value.trim();
74
+ if (trimmed.length >= 2) {
75
+ const first = trimmed[0];
76
+ const last = trimmed[trimmed.length - 1];
77
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
78
+ return trimmed.slice(1, -1).trim();
79
+ }
80
+ }
81
+ return trimmed;
82
+ }
83
+
84
+ function isPlaceholder(value) {
85
+ const normalized = unquote(value).toLowerCase();
86
+ return normalized.includes('***')
87
+ || normalized.includes('your_')
88
+ || normalized.includes('your-')
89
+ || normalized.includes('example.com')
90
+ || /^(?:change|replace)(?:[-_ ]?me)?(?:[-_ ].*)?$/.test(normalized)
91
+ || /^(?:placeholder|your value|your-value|your_value)$/.test(normalized);
92
+ }
93
+
94
+ function environmentValues(source) {
95
+ const values = new Map();
96
+ for (const line of source.split(/\\r?\\n/)) {
97
+ const match = /^(?:export\\s+)?([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
98
+ if (match) values.set(match[1], unquote(match[2]));
99
+ }
100
+ return values;
101
+ }
102
+
103
+ for (const { example, target, optional } of environmentFiles) {
104
+ if (!existsSync(target)) {
105
+ if (checkOnly) {
106
+ missing.push(target);
107
+ continue;
108
+ }
109
+ copyFileSync(example, target);
110
+ created.push(target);
111
+ } else {
112
+ kept.push(target);
113
+ }
114
+ const optionalNames = new Set(optional);
115
+ const expected = environmentValues(readFileSync(example, 'utf8'));
116
+ const actual = environmentValues(readFileSync(target, 'utf8'));
117
+ for (const name of expected.keys()) {
118
+ if (!optionalNames.has(name)) {
119
+ const value = actual.get(name);
120
+ if (value === undefined || !value || isPlaceholder(value)) {
121
+ unresolved.push(\`\${target}: \${name}\`);
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ const blockers = [
128
+ ...missing.map((target) => \`\${target}: file is missing\`),
129
+ ...unresolved,
130
+ ];
131
+ const result = {
132
+ operation: checkOnly ? 'ready' : 'first-run',
133
+ ok: blockers.length === 0,
134
+ created,
135
+ kept,
136
+ missing,
137
+ unresolved,
138
+ nextCommands: ${JSON.stringify(nextCommands(options))},
139
+ };
140
+
141
+ if (json) {
142
+ console.log(JSON.stringify(result, null, 2));
143
+ } else {
144
+ for (const target of created) console.log(\`created \${target}\`);
145
+ for (const target of kept) console.log(\`kept \${target}\`);
146
+
147
+ if (blockers.length > 0) {
148
+ console.log('\\nConfiguration is not ready:');
149
+ for (const item of blockers) console.log(\` - \${item}\`);
150
+ console.log(checkOnly
151
+ ? '\\nRun pnpm first-run to create missing files, configure the listed values, then run pnpm ready again.'
152
+ : '\\nConfigure the listed values, then run pnpm ready.');
153
+ } else {
154
+ console.log('\\nConfiguration is ready.');
155
+ ${options.features.database
156
+ ? "console.log('Before verification, review the SQL created by pnpm db:generate before staging it.');"
157
+ : ''}
158
+ console.log('\\nNext:');
159
+ for (const command of result.nextCommands.filter((command) => command !== 'pnpm ready')) {
160
+ console.log(\` \${command}\`);
161
+ }
162
+ }
163
+ }
164
+
165
+ if (checkOnly && blockers.length > 0) process.exitCode = 1;
166
+ `;
167
+ }
@@ -1,2 +1,2 @@
1
- import type { ProjectOptions } from '../scaffold.js';
1
+ import type { ProjectOptions } from '../project.js';
2
2
  export declare function scaffoldMobile(root: string, options: ProjectOptions): Promise<void>;
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { anhedralPrint } from '../print.js';
3
3
  import { appendGitignore, writeFile } from '../util.js';
4
4
  import { childPackageName, jsString } from '../render.js';
5
- import { FRONTEND_ADDON_DEPENDENCIES, MOBILE_APP_DEPENDENCIES, MOBILE_NATIVEWIND_DEPENDENCIES, MOBILE_UNIWIND_DEPENDENCIES, TOOLCHAIN_DEPENDENCIES, } from '../dependencies.js';
5
+ import { CLERK_SOLANA_PEER_DEPENDENCIES, FRONTEND_ADDON_DEPENDENCIES, MOBILE_APP_DEPENDENCIES, MOBILE_NATIVEWIND_DEPENDENCIES, MOBILE_UNIWIND_DEPENDENCIES, TOOLCHAIN_DEPENDENCIES, } from '../dependencies.js';
6
6
  function selectedDependencies(options) {
7
7
  const dependencies = { ...(MOBILE_APP_DEPENDENCIES.dependencies ?? {}) };
8
8
  Object.assign(dependencies, (options.nativeStyling ?? 'nativewind') === 'uniwind'
@@ -13,11 +13,10 @@ function selectedDependencies(options) {
13
13
  if (!options.features.billing)
14
14
  delete dependencies['@shared/realtime'];
15
15
  if (options.features.auth) {
16
- for (const name of [
17
- '@clerk/expo',
18
- 'expo-secure-store',
19
- ])
16
+ Object.assign(dependencies, CLERK_SOLANA_PEER_DEPENDENCIES);
17
+ for (const name of ['@clerk/expo', 'expo-secure-store']) {
20
18
  dependencies[name] = FRONTEND_ADDON_DEPENDENCIES[name];
19
+ }
21
20
  }
22
21
  if (options.features.nativeSubscriptions) {
23
22
  dependencies['react-native-purchases'] = FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'];
@@ -314,7 +313,7 @@ import { ${options.features.auth ? 'syncRevenueCatUser, type SubscriptionState'
314
313
  ` : '';
315
314
  const layoutBody = options.features.auth
316
315
  ? ` const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
317
- if (!publishableKey) {
316
+ if (!publishableKey || publishableKey.includes('***')) {
318
317
  return (
319
318
  <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', padding: 32 }}>
320
319
  <View style={{ gap: 8 }}>
@@ -349,6 +348,7 @@ ${layoutBody}
349
348
  : null,
350
349
  options.features.auth ? "import { AccountControls } from '../components/account-controls';" : null,
351
350
  options.features.billing ? "import { useEntitlement } from '../hooks/use-entitlement';" : null,
351
+ options.apps.api && options.features.database ? "import { ItemList } from '../components/item-list';" : null,
352
352
  ].filter((value) => value !== null).join('\n');
353
353
  const reactNativeImports = options.features.nativeSubscriptions
354
354
  ? 'Button, ScrollView, StyleSheet, Text, View'
@@ -390,6 +390,7 @@ ${layoutBody}
390
390
  ? ' <SubscriptionControls />\n'
391
391
  : '';
392
392
  const accountControls = options.features.auth ? ' <AccountControls />\n' : '';
393
+ const itemList = options.apps.api && options.features.database ? ' <ItemList />\n' : '';
393
394
  writeFile(path.join(dir, 'app/index.tsx'), `import { StatusBar } from 'expo-status-bar';
394
395
  import { ${reactNativeImports} } from 'react-native';
395
396
  ${homeImports}
@@ -404,7 +405,7 @@ ${subscriptionComponent}export default function HomeScreen() {
404
405
  <Text accessibilityRole="header" style={styles.title}>{${nameLiteral}}</Text>
405
406
  <Text selectable style={styles.subtitle}>Deterministic Expo application generated by Anhedral.</Text>
406
407
  {enabledModules.map((moduleName) => <Text selectable key={moduleName} style={styles.module}>• {moduleName}</Text>)}
407
- ${accountControls}${nativeSubscriptionAction} </View>
408
+ ${accountControls}${itemList}${nativeSubscriptionAction} </View>
408
409
  </ScrollView>
409
410
  );
410
411
  }
@@ -421,6 +422,117 @@ const styles = StyleSheet.create({
421
422
  error: { color: '#b91c1c', fontSize: 14 },
422
423
  });
423
424
  `);
425
+ if (options.apps.api && options.features.database) {
426
+ const authImport = options.features.auth ? "import { useAuth } from '@clerk/expo';\n" : '';
427
+ const authState = options.features.auth ? ' const { isLoaded, isSignedIn, userId } = useAuth();\n' : '';
428
+ const identityState = options.features.auth
429
+ ? ' const identity = isLoaded && isSignedIn ? userId ?? null : null;\n'
430
+ : " const identity = 'public';\n";
431
+ const authGuard = options.features.auth
432
+ ? ` if (!isLoaded || (isSignedIn && !userId)) return <Text selectable style={styles.status}>Loading account…</Text>;
433
+ if (!isSignedIn) return <Text selectable style={styles.status}>Sign in to use the working starter feature.</Text>;
434
+
435
+ `
436
+ : '';
437
+ writeFile(path.join(dir, 'components/item-list.tsx'), `import { createItem, listItems, type Item } from '@shared/api-client';
438
+ ${authImport}import * as React from 'react';
439
+ import { Button, StyleSheet, Text, TextInput, View } from 'react-native';
440
+ import { useApiClient } from '../hooks/use-api-client';
441
+
442
+ export function ItemList() {
443
+ ${authState}${identityState} const api = useApiClient();
444
+ const [items, setItems] = React.useState<Item[]>([]);
445
+ const [loadedIdentity, setLoadedIdentity] = React.useState<string | null>(null);
446
+ const [name, setName] = React.useState('');
447
+ const [status, setStatus] = React.useState<'loading' | 'ready' | 'saving'>('loading');
448
+ const [error, setError] = React.useState<string | null>(null);
449
+ const identityRef = React.useRef(identity);
450
+ const visibleItems = loadedIdentity === identity ? items : [];
451
+ const visibleError = loadedIdentity === identity ? error : null;
452
+ const isLoading = status === 'loading' || loadedIdentity !== identity;
453
+
454
+ React.useEffect(() => {
455
+ identityRef.current = identity;
456
+ if (!identity) return;
457
+ let active = true;
458
+ setLoadedIdentity(identity);
459
+ setItems([]);
460
+ setName('');
461
+ setError(null);
462
+ setStatus('loading');
463
+ void listItems(api).then((nextItems) => {
464
+ if (active) setItems(nextItems);
465
+ }).catch((cause: unknown) => {
466
+ if (active) setError(cause instanceof Error ? cause.message : 'Unable to load items');
467
+ }).finally(() => {
468
+ if (active) setStatus('ready');
469
+ });
470
+ return () => { active = false; };
471
+ }, [api, identity]);
472
+
473
+ const submit = async () => {
474
+ const nextName = name.trim();
475
+ if (!identity || !nextName || status !== 'ready' || loadedIdentity !== identity) return;
476
+ const submittedIdentity = identity;
477
+ setStatus('saving');
478
+ try {
479
+ const created = await createItem(api, { name: nextName });
480
+ if (identityRef.current !== submittedIdentity) return;
481
+ setItems((current) => [created, ...current]);
482
+ setName('');
483
+ setError(null);
484
+ } catch (cause) {
485
+ if (identityRef.current === submittedIdentity) {
486
+ setError(cause instanceof Error ? cause.message : 'Unable to create item');
487
+ }
488
+ } finally {
489
+ if (identityRef.current === submittedIdentity) setStatus('ready');
490
+ }
491
+ };
492
+
493
+ ${authGuard} return (
494
+ <View accessibilityLabel="Working starter feature" style={styles.card}>
495
+ <Text accessibilityRole="header" style={styles.heading}>Working starter feature</Text>
496
+ <View style={styles.form}>
497
+ <TextInput
498
+ accessibilityLabel="Item name"
499
+ maxLength={120}
500
+ onChangeText={setName}
501
+ placeholder="Your first item"
502
+ style={styles.input}
503
+ value={name}
504
+ />
505
+ <Button
506
+ disabled={status !== 'ready' || loadedIdentity !== identity || !name.trim()}
507
+ onPress={() => void submit()}
508
+ title={status === 'saving' ? 'Adding…' : 'Add item'}
509
+ />
510
+ </View>
511
+ {visibleError ? (
512
+ <Text accessibilityLiveRegion="assertive" selectable style={styles.error}>
513
+ {visibleError}. Check DATABASE_URL, run pnpm db:migrate, and make sure the API is running.
514
+ </Text>
515
+ ) : null}
516
+ {isLoading ? <Text selectable style={styles.status}>Loading items…</Text> : null}
517
+ {!isLoading && visibleItems.length === 0 && !visibleError
518
+ ? <Text selectable style={styles.status}>Your database is connected. Add the first item.</Text>
519
+ : null}
520
+ {visibleItems.map((item) => <Text key={item.id} selectable style={styles.item}>• {item.name}</Text>)}
521
+ </View>
522
+ );
523
+ }
524
+
525
+ const styles = StyleSheet.create({
526
+ card: { gap: 10, marginTop: 12, padding: 16, borderRadius: 12, backgroundColor: '#ffffff' },
527
+ heading: { color: '#0f172a', fontSize: 18, fontWeight: '700' },
528
+ form: { gap: 8 },
529
+ input: { minHeight: 44, borderWidth: 1, borderColor: '#cbd5e1', borderRadius: 8, paddingHorizontal: 12, color: '#0f172a' },
530
+ item: { color: '#1e293b', fontSize: 15 },
531
+ status: { color: '#475569', fontSize: 14 },
532
+ error: { color: '#b91c1c', fontSize: 14 },
533
+ });
534
+ `);
535
+ }
424
536
  if (options.features.auth) {
425
537
  writeFile(path.join(dir, 'components/account-controls.tsx'), `import { Show, useClerk, useUser } from '@clerk/expo';
426
538
  import { useState } from 'react';
@@ -491,8 +603,8 @@ export function createApiClient(getToken?: () => Promise<string | null>) {
491
603
  }
492
604
  `);
493
605
  }
494
- if (options.apps.api && options.features.auth) {
495
- writeFile(path.join(dir, 'hooks/use-api-client.ts'), `import { useAuth } from '@clerk/expo';
606
+ if (options.apps.api) {
607
+ writeFile(path.join(dir, 'hooks/use-api-client.ts'), options.features.auth ? `import { useAuth } from '@clerk/expo';
496
608
  import { useMemo } from 'react';
497
609
  import { createApiClient } from '../lib/api';
498
610
 
@@ -500,6 +612,12 @@ export function useApiClient() {
500
612
  const { getToken } = useAuth();
501
613
  return useMemo(() => createApiClient(() => getToken()), [getToken]);
502
614
  }
615
+ ` : `import { useMemo } from 'react';
616
+ import { createApiClient } from '../lib/api';
617
+
618
+ export function useApiClient() {
619
+ return useMemo(() => createApiClient(), []);
620
+ }
503
621
  `);
504
622
  }
505
623
  if (options.features.billing) {
@@ -513,22 +631,36 @@ ${options.features.nativeSubscriptions ? "import { subscribeToRevenueCatUpdates
513
631
  export function useEntitlement() {
514
632
  const api = useApiClient();
515
633
  const { isLoaded, isSignedIn, userId } = useAuth();
634
+ const identity = isLoaded && isSignedIn ? userId : null;
516
635
  const [entitlement, setEntitlement] = React.useState<Awaited<ReturnType<typeof api.getEntitlement>> | null>(null);
636
+ const [loadedIdentity, setLoadedIdentity] = React.useState<string | null>(null);
517
637
  const [error, setError] = React.useState<string | null>(null);
518
638
  const revision = React.useRef(0);
639
+ const identityRef = React.useRef(identity);
519
640
  const refresh = React.useCallback(async (reconcile = false) => {
520
- if (!isLoaded || !isSignedIn) return;
641
+ if (!identity) return;
521
642
  try {
522
643
  const next = reconcile ? await api.refreshEntitlement() : await api.getEntitlement();
644
+ if (identityRef.current !== identity) return;
523
645
  revision.current = Math.max(revision.current, next.revision);
524
646
  setEntitlement(next);
647
+ setLoadedIdentity(identity);
525
648
  setError(null);
526
649
  } catch (cause) {
650
+ if (identityRef.current !== identity) return;
651
+ setLoadedIdentity(identity);
527
652
  setError(cause instanceof Error ? cause.message : 'Unable to load subscription');
528
653
  }
529
- }, [api, isLoaded, isSignedIn]);
654
+ }, [api, identity]);
530
655
 
531
- React.useEffect(() => { void refresh(); }, [refresh]);
656
+ React.useEffect(() => {
657
+ identityRef.current = identity;
658
+ revision.current = 0;
659
+ setEntitlement(null);
660
+ setLoadedIdentity(null);
661
+ setError(null);
662
+ void refresh();
663
+ }, [identity, refresh]);
532
664
  ${options.features.nativeSubscriptions ? 'React.useEffect(() => subscribeToRevenueCatUpdates(() => { void refresh(true); }), [refresh]);' : ''}
533
665
  React.useEffect(() => {
534
666
  const subscription = AppState.addEventListener('change', (state) => {
@@ -537,17 +669,26 @@ export function useEntitlement() {
537
669
  return () => subscription.remove();
538
670
  }, [refresh]);
539
671
  React.useEffect(() => {
540
- if (!userId || !isSignedIn) return;
672
+ if (!identity) return;
541
673
  return subscribeToSubscriptionChanges({
542
- userId,
674
+ userId: identity,
543
675
  getTokenRequest: () => api.getRealtimeToken(),
544
676
  onChange: (nextRevision) => {
545
677
  if (nextRevision > revision.current) void refresh();
546
678
  },
547
- onError: (cause) => setError(cause.message),
679
+ onError: (cause) => {
680
+ if (identityRef.current === identity) {
681
+ setLoadedIdentity(identity);
682
+ setError(cause.message);
683
+ }
684
+ },
548
685
  });
549
- }, [api, isSignedIn, refresh, userId]);
550
- return { entitlement, error, refresh };
686
+ }, [api, identity, refresh]);
687
+ return {
688
+ entitlement: loadedIdentity === identity ? entitlement : null,
689
+ error: loadedIdentity === identity ? error : null,
690
+ refresh,
691
+ };
551
692
  }
552
693
  `);
553
694
  }