anhedral 0.1.3 → 0.1.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.
@@ -3,11 +3,12 @@ import path from 'node:path';
3
3
  import { writeAnhedralLogo } from '../branding.js';
4
4
  import { anhedralPrint } from '../print.js';
5
5
  import { appendGitignore, writeFile, exec, execExpect, liftNestedProject } from '../util.js';
6
+ import { FRONTEND_ADDON_DEPENDENCIES, withVersions } from '../dependencies.js';
6
7
  import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
7
- export async function scaffoldFrontend(root, { projectName, displayName }) {
8
- const dir = path.join(root, 'apps/mobile');
8
+ export async function scaffoldFrontend(root, { projectName, displayName, skipInstall }) {
9
+ const dir = path.join(root, 'Frontend');
9
10
  const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
10
- anhedralPrint.section('Mobile (Expo)');
11
+ anhedralPrint.section('Frontend (Expo)');
11
12
  anhedralPrint.step('Scaffolding Expo app with react-native-reusables');
12
13
  fs.mkdirSync(dir, { recursive: true });
13
14
  await execExpect(`pnpm dlx ${toolPackageRef('@react-native-reusables/cli', toolchain.reactNativeReusables)} init -t clerk-auth`, dir, [
@@ -17,11 +18,16 @@ export async function scaffoldFrontend(root, { projectName, displayName }) {
17
18
  ]);
18
19
  liftNestedProject(dir, projectName);
19
20
  anhedralPrint.done('Expo app scaffolded');
20
- anhedralPrint.step('Installing mobile dependencies');
21
- exec('pnpm install --no-frozen-lockfile', dir);
21
+ anhedralPrint.step('Installing frontend dependencies');
22
22
  patchFrontendPackageJson(dir);
23
+ patchExpoAppConfig(dir);
24
+ patchFrontendTsConfig(dir);
25
+ if (!skipInstall) {
26
+ exec('pnpm install --no-frozen-lockfile', dir);
27
+ exec('pnpm exec expo install --fix --pnpm', dir);
28
+ }
23
29
  appendGitignore(dir, ['.env', '.env.*', '!.env.example']);
24
- anhedralPrint.done('Mobile dependencies installed');
30
+ anhedralPrint.done(skipInstall ? 'Frontend dependency manifests written' : 'Frontend dependencies installed');
25
31
  writeApiClient(dir);
26
32
  writeConfigFile(dir);
27
33
  writeApiHook(dir);
@@ -30,190 +36,97 @@ export async function scaffoldFrontend(root, { projectName, displayName }) {
30
36
  writeAppShellFiles(dir, displayName);
31
37
  writeEnvExample(dir);
32
38
  writeEnvFile(dir);
39
+ writeVercelConfig(dir);
40
+ writeEasConfig(dir);
33
41
  writeAnhedralLogo(dir);
34
42
  writeSubscriptionProvider(dir);
35
43
  writeUseSubscription(dir);
36
- anhedralPrint.step('Installing image picker + RevenueCat SDKs');
37
- exec('pnpm exec expo install expo-image-picker', dir);
38
- exec('pnpm add react-native-purchases react-native-purchases-ui @revenuecat/purchases-js', dir);
39
- anhedralPrint.done('Additional mobile SDKs installed');
44
+ anhedralPrint.step('Installing Expo native packages');
45
+ if (skipInstall) {
46
+ anhedralPrint.info('Skipping Expo native package install (--skip-install)');
47
+ anhedralPrint.info('Run after init: pnpm --filter ./Frontend exec expo install --pnpm expo-image-picker');
48
+ }
49
+ else {
50
+ exec('pnpm exec expo install --pnpm expo-image-picker', dir);
51
+ exec('pnpm exec expo install --fix --pnpm', dir);
52
+ }
53
+ anhedralPrint.done(skipInstall ? 'Expo native package install skipped' : 'Expo native packages installed');
54
+ anhedralPrint.step('Installing RevenueCat SDKs');
55
+ const revenueCatDependencies = withVersions({
56
+ 'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
57
+ 'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
58
+ '@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
59
+ });
60
+ if (skipInstall) {
61
+ anhedralPrint.info(`Skipping RevenueCat SDK install (--skip-install). Run after init: pnpm --filter ./Frontend add ${revenueCatDependencies.join(' ')}`);
62
+ }
63
+ else {
64
+ exec(`pnpm add ${revenueCatDependencies.join(' ')}`, dir);
65
+ }
66
+ anhedralPrint.done(skipInstall ? 'RevenueCat SDK install skipped' : 'Additional Expo SDKs installed');
40
67
  }
41
68
  function patchFrontendPackageJson(dir) {
42
69
  const filePath = path.join(dir, 'package.json');
43
70
  const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
71
+ packageJson.dependencies = {
72
+ ...(packageJson.dependencies ?? {}),
73
+ '@anhedral/api-client': FRONTEND_ADDON_DEPENDENCIES['@anhedral/api-client'],
74
+ '@clerk/expo': FRONTEND_ADDON_DEPENDENCIES['@clerk/expo'],
75
+ 'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
76
+ 'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
77
+ '@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
78
+ };
44
79
  packageJson.scripts = {
45
80
  ...(packageJson.scripts ?? {}),
46
- build: 'pnpm typecheck',
81
+ build: 'pnpm typecheck && pnpm build:web',
82
+ 'build:web': 'expo export --platform web',
47
83
  typecheck: 'tsc --noEmit',
48
84
  };
49
85
  writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n');
50
86
  }
51
- function writeApiClient(dir) {
52
- writeFile(path.join(dir, 'api/client.ts'), `/**
53
- * API Client with SWR caching
54
- */
55
-
56
- const SWR_MAX_SIZE = 200;
57
- const SWR_DEFAULT_STALE_MS = 15_000;
58
- const SWR_DEFAULT_MAX_AGE_MS = 300_000;
59
-
60
- type SWREntry<T> = { data: T; fetchedAt: number; staleMs: number; maxAgeMs: number };
61
- const SWR_CACHE = new Map<string, SWREntry<unknown>>();
62
- const SWR_INFLIGHT = new Map<string, Promise<unknown>>();
63
-
64
- function swrEvict() {
65
- if (SWR_CACHE.size <= SWR_MAX_SIZE) return;
66
- const entries = [...SWR_CACHE.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
67
- const toRemove = entries.slice(0, entries.length - SWR_MAX_SIZE);
68
- for (const [key] of toRemove) SWR_CACHE.delete(key);
69
- }
70
-
71
- export class APIRequestError extends Error {
72
- constructor(
73
- public statusCode: number,
74
- message: string,
75
- public errorCode?: string,
76
- ) {
77
- super(message);
78
- this.name = 'APIRequestError';
79
- }
80
- }
81
-
82
- export class APIClient {
83
- constructor(
84
- private baseUrl: string,
85
- private getToken: () => Promise<string | null>,
86
- ) {}
87
-
88
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
89
- const token = await this.getToken();
90
- const headers: Record<string, string> = {
91
- 'Content-Type': 'application/json',
92
- 'X-Platform': 'web',
93
- };
94
- if (token) headers['Authorization'] = \`Bearer \${token}\`;
95
-
96
- const res = await fetch(\`\${this.baseUrl}\${path}\`, {
97
- method,
98
- headers,
99
- body: body ? JSON.stringify(body) : undefined,
100
- });
101
-
102
- if (!res.ok) {
103
- let err: { error?: string; message?: string } = {};
104
- try { err = await res.json(); } catch {}
105
- throw new APIRequestError(res.status, err.message || \`Request failed (\${res.status})\`, err.error);
87
+ function patchFrontendTsConfig(dir) {
88
+ const filePath = path.join(dir, 'tsconfig.json');
89
+ if (!fs.existsSync(filePath)) {
90
+ return;
106
91
  }
107
-
108
- if (res.status === 204) return {} as T;
109
- return res.json();
110
- }
111
-
112
- async get<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
113
- async post<T>(path: string, body?: unknown): Promise<T> { return this.request<T>('POST', path, body); }
114
- async delete<T>(path: string): Promise<T> { return this.request<T>('DELETE', path); }
115
-
116
- async getCached<T>(
117
- path: string,
118
- opts?: { staleMs?: number; maxAgeMs?: number },
119
- ): Promise<T> {
120
- const staleMs = opts?.staleMs ?? SWR_DEFAULT_STALE_MS;
121
- const maxAgeMs = opts?.maxAgeMs ?? SWR_DEFAULT_MAX_AGE_MS;
122
- const now = Date.now();
123
- const cached = SWR_CACHE.get(path) as SWREntry<T> | undefined;
124
-
125
- if (cached) {
126
- const age = now - cached.fetchedAt;
127
- if (age < staleMs) return cached.data;
128
- if (age < maxAgeMs) {
129
- if (!SWR_INFLIGHT.has(path)) {
130
- const p = this.get<T>(path).then((data) => {
131
- SWR_CACHE.set(path, { data, fetchedAt: Date.now(), staleMs, maxAgeMs });
132
- return data;
133
- }).finally(() => SWR_INFLIGHT.delete(path));
134
- SWR_INFLIGHT.set(path, p);
135
- }
136
- return cached.data;
137
- }
92
+ const tsConfig = JSON.parse(fs.readFileSync(filePath, 'utf8'));
93
+ if (tsConfig.extends === 'expo/tsconfig.base') {
94
+ tsConfig.extends = 'expo/tsconfig.base.json';
138
95
  }
139
-
140
- const inflight = SWR_INFLIGHT.get(path) as Promise<T> | undefined;
141
- if (inflight) return inflight;
142
-
143
- const p = this.get<T>(path).then((data) => {
144
- SWR_CACHE.set(path, { data, fetchedAt: Date.now(), staleMs, maxAgeMs });
145
- swrEvict();
146
- return data;
147
- }).finally(() => SWR_INFLIGHT.delete(path));
148
- SWR_INFLIGHT.set(path, p);
149
- return p;
150
- }
151
-
152
- invalidateCache(path: string): void { SWR_CACHE.delete(path); }
153
- invalidateCachePrefix(prefix: string): void {
154
- for (const key of SWR_CACHE.keys()) {
155
- if (key.startsWith(prefix)) SWR_CACHE.delete(key);
96
+ writeFile(filePath, JSON.stringify(tsConfig, null, 2) + '\n');
97
+ }
98
+ function patchExpoAppConfig(dir) {
99
+ const filePath = path.join(dir, 'app.json');
100
+ if (!fs.existsSync(filePath)) {
101
+ return;
156
102
  }
157
- }
158
-
159
- clearAllCaches(): void {
160
- SWR_CACHE.clear();
161
- SWR_INFLIGHT.clear();
162
- }
163
-
164
- // ── Auth endpoints ──────────────────────────────────────────────────
165
- async getMe() {
166
- return this.get<{
167
- user: {
168
- id: string;
169
- email: string;
170
- firstName?: string | null;
171
- lastName?: string | null;
172
- displayName: string;
173
- imageUrl?: string | null;
174
- avatarUrl?: string | null;
175
- creditsBalance: number;
176
- subscriptionTier: string;
177
- subscriptionStatus: string;
178
- };
179
- }>('/auth/me');
180
- }
181
-
182
- async signOut() {
183
- return this.post<{ success: boolean }>('/auth/signout');
184
- }
185
-
186
- async uploadAvatar(input: { base64: string; mimeType: string; fileName?: string }) {
187
- return this.post<{ ok: boolean; avatarUrl: string }>('/auth/avatar', input);
188
- }
189
-
190
- async deleteAccount() {
191
- return this.delete<void>('/auth/account');
192
- }
193
-
194
- // ── Subscription endpoints ───────────────────────────────────────────
195
- async getSubscriptionPricing() {
196
- return this.getCached<{
197
- tiers: { tier: string; displayName: string; description: string; priceMonthly: number | null; priceYearly: number | null; currency: string; limits: { dailyLimit: number | null }; paymentInfo?: { revenueCatEntitlementId: string; revenueCatOfferingId: string } }[];
198
- }>('/subscriptions/pricing', { staleMs: 300_000, maxAgeMs: 3_600_000 });
199
- }
103
+ const appJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
104
+ appJson.expo ??= {};
105
+ appJson.expo.web = {
106
+ ...(appJson.expo.web ?? {}),
107
+ bundler: 'metro',
108
+ output: 'static',
109
+ };
110
+ appJson.expo.plugins = Array.from(new Set([
111
+ ...(Array.isArray(appJson.expo.plugins) ? appJson.expo.plugins : []),
112
+ '@clerk/expo',
113
+ ]));
114
+ writeFile(filePath, JSON.stringify(appJson, null, 2) + '\n');
115
+ }
116
+ function writeApiClient(dir) {
117
+ writeFile(path.join(dir, 'api/client.ts'), `import { ApiClient, APIRequestError } from '@anhedral/api-client';
200
118
 
201
- async getSubscriptionEntitlements(options?: { refresh?: boolean }) {
202
- const path = options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me';
203
- return this.get<{
204
- pro: boolean; inTrial: boolean; trialEndsAt?: string; expiresAt?: string;
205
- periodStart?: string; periodEnd?: string;
206
- method?: 'trialing' | 'redeemed' | 'paid' | null;
207
- managementUrl?: string; cancelAtPeriodEnd?: boolean;
208
- }>(path);
119
+ export class APIClient extends ApiClient {
120
+ constructor(baseUrl: string, getToken: () => Promise<string | null>) {
121
+ super({ baseUrl, getToken, platform: 'frontend' });
209
122
  }
210
123
 
211
- async redeemPromoCode(code: string) {
212
- return this.post<{ ok: boolean; expiresAt: string }>('/subscriptions/redeem', { code });
124
+ redeemPromoCode(code: string) {
125
+ return this.redeemCode(code);
213
126
  }
214
127
  }
215
128
 
216
- export { SWR_CACHE };
129
+ export { APIRequestError };
217
130
  `);
218
131
  writeFile(path.join(dir, 'api/index.ts'), `export { APIClient, APIRequestError } from './client';
219
132
  `);
@@ -273,7 +186,7 @@ export function getPlatformRevenueCatApiKey(): string {
273
186
  `);
274
187
  }
275
188
  function writeApiHook(dir) {
276
- writeFile(path.join(dir, 'hooks/useAPI.ts'), `import { useAuth } from '@clerk/clerk-expo';
189
+ writeFile(path.join(dir, 'hooks/useAPI.ts'), `import { useAuth } from '@clerk/expo';
277
190
  import { useMemo } from 'react';
278
191
  import { APIClient } from '@/api/client';
279
192
  import { apiBaseUrl } from '@/lib/config';
@@ -284,8 +197,50 @@ export function useAPI() {
284
197
  }
285
198
  `);
286
199
  }
200
+ function writeVercelConfig(dir) {
201
+ writeFile(path.join(dir, 'vercel.json'), JSON.stringify({
202
+ $schema: 'https://openapi.vercel.sh/vercel.json',
203
+ buildCommand: 'pnpm build:web',
204
+ outputDirectory: 'dist',
205
+ devCommand: 'pnpm dev',
206
+ cleanUrls: true,
207
+ framework: null,
208
+ rewrites: [
209
+ {
210
+ source: '/:path*',
211
+ destination: '/',
212
+ },
213
+ ],
214
+ }, null, 2) + '\n');
215
+ }
216
+ function writeEasConfig(dir) {
217
+ writeFile(path.join(dir, 'eas.json'), JSON.stringify({
218
+ cli: {
219
+ version: '>= 16.24.0',
220
+ appVersionSource: 'remote',
221
+ },
222
+ build: {
223
+ development: {
224
+ developmentClient: true,
225
+ distribution: 'internal',
226
+ android: {
227
+ buildType: 'apk',
228
+ },
229
+ },
230
+ preview: {
231
+ distribution: 'internal',
232
+ },
233
+ production: {
234
+ autoIncrement: true,
235
+ },
236
+ },
237
+ submit: {
238
+ production: {},
239
+ },
240
+ }, null, 2) + '\n');
241
+ }
287
242
  function writeAccountHook(dir) {
288
- writeFile(path.join(dir, 'hooks/useAccount.ts'), `import { useAuth } from '@clerk/clerk-expo';
243
+ writeFile(path.join(dir, 'hooks/useAccount.ts'), `import { useAuth } from '@clerk/expo';
289
244
  import * as React from 'react';
290
245
  import { useAPI } from '@/hooks/useAPI';
291
246
 
@@ -383,7 +338,7 @@ import Purchases, { type CustomerInfo, type PurchasesOfferings } from 'react-nat
383
338
  import { Purchases as PurchasesWeb } from '@revenuecat/purchases-js';
384
339
  import { AppState, Platform } from 'react-native';
385
340
  import { subscriptionConfig, getPlatformRevenueCatApiKey } from '@/lib/config';
386
- import { useAuth, useUser } from '@clerk/clerk-expo';
341
+ import { useAuth, useUser } from '@clerk/expo';
387
342
  import { useAPI } from '@/hooks/useAPI';
388
343
  import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
389
344
 
@@ -785,9 +740,10 @@ function writeAppShellFiles(dir, displayName) {
785
740
  writeFile(path.join(dir, 'app/_layout.tsx'), `import '@/global.css';
786
741
 
787
742
  import { SubscriptionProvider } from '@/contexts/SubscriptionProvider';
743
+ import { clerkPublishableKey } from '@/lib/config';
788
744
  import { NAV_THEME } from '@/lib/theme';
789
- import { ClerkProvider, useAuth } from '@clerk/clerk-expo';
790
- import { tokenCache } from '@clerk/clerk-expo/token-cache';
745
+ import { ClerkProvider, useAuth } from '@clerk/expo';
746
+ import { tokenCache } from '@clerk/expo/token-cache';
791
747
  import { ThemeProvider } from '@react-navigation/native';
792
748
  import { PortalHost } from '@rn-primitives/portal';
793
749
  import { Stack } from 'expo-router';
@@ -802,7 +758,7 @@ export default function RootLayout() {
802
758
  const { colorScheme } = useColorScheme();
803
759
 
804
760
  return (
805
- <ClerkProvider tokenCache={tokenCache}>
761
+ <ClerkProvider publishableKey={clerkPublishableKey} tokenCache={tokenCache}>
806
762
  <ThemeProvider value={NAV_THEME[colorScheme ?? 'light']}>
807
763
  <SubscriptionProvider>
808
764
  <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
@@ -851,7 +807,7 @@ function Routes() {
851
807
  import { Button } from '@/components/ui/button';
852
808
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
853
809
  import { Text } from '@/components/ui/text';
854
- import { useAuth } from '@clerk/clerk-expo';
810
+ import { useAuth } from '@clerk/expo';
855
811
  import { Link } from 'expo-router';
856
812
  import { CloudIcon, CreditCardIcon, ShieldCheckIcon, DatabaseIcon } from 'lucide-react-native';
857
813
  import { ScrollView, View } from 'react-native';
@@ -859,7 +815,7 @@ import { ScrollView, View } from 'react-native';
859
815
  const FEATURES = [
860
816
  {
861
817
  title: 'Landing page here',
862
- description: 'Replace this placeholder copy with the actual acquisition story for your mobile and web app.',
818
+ description: 'Replace this placeholder copy with the actual acquisition story for your Expo app.',
863
819
  icon: CloudIcon,
864
820
  },
865
821
  {
@@ -1191,7 +1147,7 @@ import { Input } from '@/components/ui/input';
1191
1147
  import { Label } from '@/components/ui/label';
1192
1148
  import { Separator } from '@/components/ui/separator';
1193
1149
  import { Text } from '@/components/ui/text';
1194
- import { useSignIn } from '@clerk/clerk-expo';
1150
+ import { useSignIn } from '@clerk/expo/legacy';
1195
1151
  import { Link } from 'expo-router';
1196
1152
  import * as React from 'react';
1197
1153
  import { type TextInput, View } from 'react-native';
@@ -1305,7 +1261,7 @@ import { Input } from '@/components/ui/input';
1305
1261
  import { Label } from '@/components/ui/label';
1306
1262
  import { Separator } from '@/components/ui/separator';
1307
1263
  import { Text } from '@/components/ui/text';
1308
- import { useSignUp } from '@clerk/clerk-expo';
1264
+ import { useSignUp } from '@clerk/expo/legacy';
1309
1265
  import { Link, router } from 'expo-router';
1310
1266
  import * as React from 'react';
1311
1267
  import { TextInput, View } from 'react-native';
@@ -1409,7 +1365,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
1409
1365
  import { Text } from '@/components/ui/text';
1410
1366
  import { useAccount } from '@/hooks/useAccount';
1411
1367
  import { useSubscription } from '@/hooks/useSubscription';
1412
- import { useAuth } from '@clerk/clerk-expo';
1368
+ import { useAuth } from '@clerk/expo';
1413
1369
  import type { TriggerRef } from '@rn-primitives/popover';
1414
1370
  import { CameraIcon, CreditCardIcon, CoinsIcon, LoaderCircleIcon, LogOutIcon } from 'lucide-react-native';
1415
1371
  import * as React from 'react';
package/dist/toolchain.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { TOOLCHAIN_DEPENDENCIES, VERIFIED_AT } from './dependencies.js';
1
2
  export const TOOLCHAIN_CHANNELS = ['latest', 'stable'];
2
3
  const TOOLCHAIN_SPECS = {
3
4
  latest: {
@@ -7,13 +8,10 @@ const TOOLCHAIN_SPECS = {
7
8
  wxt: 'latest',
8
9
  },
9
10
  stable: {
10
- verifiedAt: '2026-03-22',
11
- // renovate: datasource=npm depName=shadcn
12
- shadcn: '4.1.0',
13
- // renovate: datasource=npm depName=@react-native-reusables/cli
14
- reactNativeReusables: '0.5.0',
15
- // renovate: datasource=npm depName=wxt
16
- wxt: '0.20.20',
11
+ verifiedAt: VERIFIED_AT,
12
+ shadcn: TOOLCHAIN_DEPENDENCIES.shadcn,
13
+ reactNativeReusables: TOOLCHAIN_DEPENDENCIES.reactNativeReusables,
14
+ wxt: TOOLCHAIN_DEPENDENCIES.wxt,
17
15
  },
18
16
  };
19
17
  export function resolveToolchainChannel(value) {
package/dist/util.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export declare function writeFile(filePath: string, content: string): void;
2
2
  export declare function appendGitignore(root: string, lines: string[]): void;
3
3
  export declare function exec(cmd: string, cwd: string): void;
4
+ export declare function execCommand(command: string, args: string[], cwd: string): void;
4
5
  export declare function execWithInput(cmd: string, cwd: string, stdinInput: string): void;
5
6
  export declare function liftNestedProject(root: string, nestedName: string): void;
6
7
  export declare function execExpect(cmd: string, cwd: string, prompts: [waitFor: string, answer: string][], timeoutMs?: number): Promise<void>;
package/dist/util.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
- import { execSync, spawn } from 'node:child_process';
2
+ import { execSync, spawn, spawnSync } from 'node:child_process';
3
3
  import path from 'node:path';
4
4
  export function writeFile(filePath, content) {
5
5
  mkdirSync(path.dirname(filePath), { recursive: true });
@@ -24,6 +24,9 @@ function dumpFailure(error, cmd) {
24
24
  process.stderr.write(stderrText);
25
25
  throw new Error(`Command failed (exit ${err.status ?? '?'}): ${cmd}`);
26
26
  }
27
+ function commandLabel(command, args) {
28
+ return [command, ...args].join(' ');
29
+ }
27
30
  export function exec(cmd, cwd) {
28
31
  if (VERBOSE) {
29
32
  console.log(` $ ${cmd}`);
@@ -37,6 +40,27 @@ export function exec(cmd, cwd) {
37
40
  dumpFailure(error, cmd);
38
41
  }
39
42
  }
43
+ export function execCommand(command, args, cwd) {
44
+ const label = commandLabel(command, args);
45
+ if (VERBOSE) {
46
+ console.log(` $ ${label}`);
47
+ }
48
+ const result = spawnSync(command, args, {
49
+ cwd,
50
+ encoding: 'utf8',
51
+ stdio: VERBOSE ? 'inherit' : ['ignore', 'pipe', 'pipe'],
52
+ });
53
+ if (result.status === 0) {
54
+ return;
55
+ }
56
+ if (!VERBOSE) {
57
+ if (result.stdout)
58
+ process.stderr.write(result.stdout);
59
+ if (result.stderr)
60
+ process.stderr.write(result.stderr);
61
+ }
62
+ throw new Error(`Command failed (exit ${result.status ?? '?'}): ${label}`);
63
+ }
40
64
  export function execWithInput(cmd, cwd, stdinInput) {
41
65
  if (VERBOSE) {
42
66
  console.log(` $ ${cmd}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "anhedral",
3
- "version": "0.1.3",
4
- "description": "Opinionated init CLI for Expo or Next.js fullstack workspaces with Fastify, WXT, shared packages, Neon, Drizzle, Clerk, Stripe, RevenueCat, and R2.",
3
+ "version": "0.1.5",
4
+ "description": "Opinionated init CLI for Expo, Fastify, WXT, shared packages, Neon, Drizzle, Clerk, RevenueCat, Stripe, R2, and Vercel.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "anhedral": "bin/anhedral.js"
@@ -38,9 +38,10 @@
38
38
  "prepublishOnly": "pnpm test:all",
39
39
  "test:cli": "node tests/test-cli.js",
40
40
  "test:cli:validation": "node tests/test-cli-validation.js",
41
+ "test:deps": "pnpm build && node tests/test-dependency-manifest.js",
41
42
  "test:e2e": "node tests/test-e2e-public-stacks.js",
42
43
  "test:e2e:latest": "node tests/test-e2e-public-stacks.js --toolchain latest",
43
- "test:all": "pnpm test && pnpm test:e2e",
44
+ "test:all": "pnpm test && pnpm test:deps && pnpm test:e2e",
44
45
  "release:check": "pnpm test:all && npm pack --dry-run",
45
46
  "demo:refresh": "pnpm build && node tests/refresh-demo.js"
46
47
  },
@@ -54,7 +55,7 @@
54
55
  "neondb",
55
56
  "revenuecat",
56
57
  "wxt",
57
- "shadcn"
58
+ "vercel"
58
59
  ],
59
60
  "license": "MIT",
60
61
  "packageManager": "pnpm@10.15.1",
@@ -1,2 +0,0 @@
1
- export declare function writeDefaultWebEnvExample(root: string): void;
2
- export declare function writeDefaultWebFiles(root: string, displayName: string): void;