anhedral 0.4.1 → 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.
- package/CHANGELOG.md +46 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +68 -55
- package/SKILL.md +107 -12
- package/dist/architecture/index.d.ts +2 -2
- package/dist/architecture/index.js +1 -1
- package/dist/architecture/modules.d.ts +5 -1
- package/dist/architecture/modules.js +4 -1
- package/dist/bin.js +113 -84
- package/dist/cli.d.ts +7 -7
- package/dist/cli.js +62 -16
- package/dist/dependencies.d.ts +1 -0
- package/dist/dependencies.js +11 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/project.d.ts +30 -0
- package/dist/project.js +1 -0
- package/dist/prompts.d.ts +4 -3
- package/dist/prompts.js +29 -7
- package/dist/scaffold.d.ts +12 -24
- package/dist/scaffold.js +330 -123
- package/dist/templates/api.d.ts +1 -1
- package/dist/templates/api.js +67 -2
- package/dist/templates/assets-private-proxy.d.ts +1 -1
- package/dist/templates/desktop.d.ts +1 -1
- package/dist/templates/desktop.js +189 -36
- package/dist/templates/electron-updater.d.ts +1 -1
- package/dist/templates/electron-updater.js +1 -1
- package/dist/templates/extension.d.ts +1 -1
- package/dist/templates/extension.js +138 -14
- package/dist/templates/first-run.d.ts +2 -0
- package/dist/templates/first-run.js +167 -0
- package/dist/templates/mobile.d.ts +1 -1
- package/dist/templates/mobile.js +158 -17
- package/dist/templates/shared.d.ts +1 -1
- package/dist/templates/shared.js +64 -7
- package/dist/templates/web.d.ts +1 -1
- package/dist/templates/web.js +185 -28
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/NORTHSTAR.md +114 -0
- package/docs/conventions.md +6 -4
- package/docs/engineering-principles.md +88 -0
- package/docs/output-tree-contract.md +16 -5
- package/docs/references/manual-scaffolding.md +22 -4
- package/docs/references/provisioning.md +434 -77
- package/package.json +3 -1
|
@@ -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 (!
|
|
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,
|
|
509
|
-
React.useEffect(() => {
|
|
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 (!
|
|
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) =>
|
|
635
|
+
onError: (cause) => {
|
|
636
|
+
if (identityRef.current === identity) {
|
|
637
|
+
setLoadedIdentity(identity);
|
|
638
|
+
setError(cause.message);
|
|
639
|
+
}
|
|
640
|
+
},
|
|
522
641
|
});
|
|
523
|
-
}, [api,
|
|
524
|
-
return {
|
|
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,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 '../
|
|
1
|
+
import type { ProjectOptions } from '../project.js';
|
|
2
2
|
export declare function scaffoldMobile(root: string, options: ProjectOptions): Promise<void>;
|
package/dist/templates/mobile.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
|
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 (!
|
|
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,
|
|
654
|
+
}, [api, identity]);
|
|
530
655
|
|
|
531
|
-
React.useEffect(() => {
|
|
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 (!
|
|
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) =>
|
|
679
|
+
onError: (cause) => {
|
|
680
|
+
if (identityRef.current === identity) {
|
|
681
|
+
setLoadedIdentity(identity);
|
|
682
|
+
setError(cause.message);
|
|
683
|
+
}
|
|
684
|
+
},
|
|
548
685
|
});
|
|
549
|
-
}, [api,
|
|
550
|
-
return {
|
|
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
|
}
|