@o/cli 0.16.18 → 0.16.19-canary.1790191946359

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ import pc from 'picocolors'
2
+
3
+ import type { EnvVariable, EnvCategory } from '../types'
4
+
5
+ export const envCategories: EnvCategory[] = [
6
+ {
7
+ id: 'core',
8
+ name: 'Core Configuration',
9
+ description: 'Essential configuration for your production deployment',
10
+ required: true,
11
+ variables: [
12
+ {
13
+ key: 'BETTER_AUTH_SECRET',
14
+ label: 'Authentication Secret',
15
+ description: 'Secret key for session encryption and JWT signing',
16
+ instructions: `Generate a secure random key:\n${pc.cyan('openssl rand -hex 32')}`,
17
+ required: true,
18
+ type: 'secret',
19
+ generator: () => {
20
+ const crypto = require('node:crypto')
21
+ return crypto.randomBytes(32).toString('hex')
22
+ },
23
+ },
24
+ {
25
+ key: 'BETTER_AUTH_URL',
26
+ label: 'Authentication URL',
27
+ description: 'The public URL where your app will be hosted',
28
+ instructions: 'Enter your production domain (e.g., https://your-app.com)',
29
+ required: true,
30
+ type: 'text',
31
+ placeholder: 'https://your-app.com',
32
+ },
33
+ {
34
+ key: 'ONE_SERVER_URL',
35
+ label: 'Server URL',
36
+ description: 'The URL for your main server',
37
+ instructions: 'Usually the same as your authentication URL',
38
+ required: true,
39
+ type: 'text',
40
+ placeholder: 'https://your-app.com',
41
+ },
42
+ {
43
+ key: 'VITE_ZERO_HOSTNAME',
44
+ label: 'Zero Sync Server Host',
45
+ description: 'Hostname for real-time sync server',
46
+ instructions:
47
+ 'Just the hostname, e.g., zero.your-app.com (https:// assumed in production)',
48
+ required: true,
49
+ type: 'text',
50
+ placeholder: 'zero.your-app.com',
51
+ },
52
+ ],
53
+ },
54
+ {
55
+ id: 'database',
56
+ name: 'Database (PostgreSQL)',
57
+ description:
58
+ 'Production database configuration - set by deployment platform (SST/uncloud)',
59
+ required: false,
60
+ variables: [
61
+ // Note: ZERO_UPSTREAM_DB, ZERO_CVR_DB, and ZERO_CHANGE_DB are set dynamically
62
+ // by the deployment platform (SST or uncloud) and should NOT be configured here
63
+ ],
64
+ },
65
+ {
66
+ id: 'storage',
67
+ name: 'File Storage (S3/R2)',
68
+ description: 'Object storage for user uploads and media files',
69
+ required: false,
70
+ variables: [
71
+ {
72
+ key: 'CLOUDFLARE_R2_ENDPOINT',
73
+ label: 'Storage Endpoint',
74
+ description: 'S3-compatible storage endpoint',
75
+ instructions: `Options:\n${pc.cyan('Cloudflare R2')}: https://[account-id].r2.cloudflarestorage.com\n${pc.cyan('AWS S3')}: https://s3.[region].amazonaws.com\n${pc.cyan('DigitalOcean Spaces')}: https://[region].digitaloceanspaces.com`,
76
+ required: false,
77
+ type: 'text',
78
+ placeholder: 'https://account-id.r2.cloudflarestorage.com',
79
+ },
80
+ {
81
+ key: 'CLOUDFLARE_R2_ACCESS_KEY',
82
+ label: 'Storage Access Key',
83
+ description: 'Access key ID for your storage service',
84
+ instructions: 'Get this from your storage provider dashboard',
85
+ required: false,
86
+ type: 'text',
87
+ },
88
+ {
89
+ key: 'CLOUDFLARE_R2_SECRET_KEY',
90
+ label: 'Storage Secret Key',
91
+ description: 'Secret access key for your storage service',
92
+ instructions: 'Keep this secure - it provides full access to your storage',
93
+ required: false,
94
+ type: 'secret',
95
+ },
96
+ {
97
+ key: 'CLOUDFLARE_R2_PUBLIC_URL',
98
+ label: 'Public Storage URL',
99
+ description: 'Public URL for serving stored files',
100
+ instructions: 'Usually a CDN URL or custom domain pointing to your bucket',
101
+ required: false,
102
+ type: 'text',
103
+ placeholder: 'https://cdn.your-app.com',
104
+ },
105
+ ],
106
+ },
107
+ {
108
+ id: 'apple',
109
+ name: 'Apple App Store',
110
+ description: 'Configuration for iOS app and push notifications',
111
+ required: false,
112
+ variables: [
113
+ {
114
+ key: 'APNS_TEAM_ID',
115
+ label: 'Apple Team ID',
116
+ description: 'Your Apple Developer Team ID',
117
+ instructions: `Find in Apple Developer Portal:\n1. Go to ${pc.cyan('https://developer.apple.com/account')}\n2. Look for "Team ID" in Membership Details`,
118
+ required: false,
119
+ type: 'text',
120
+ placeholder: 'XXXXXXXXXX',
121
+ },
122
+ {
123
+ key: 'APNS_KEY_ID',
124
+ label: 'APNs Key ID',
125
+ description: 'Push notification authentication key ID',
126
+ instructions: `Create in Apple Developer Portal:\n1. Go to Certificates, Identifiers & Profiles\n2. Keys → Create a Key\n3. Check "Apple Push Notifications service (APNs)"\n4. Download the .p8 file and note the Key ID`,
127
+ required: false,
128
+ type: 'text',
129
+ placeholder: 'XXXXXXXXXX',
130
+ },
131
+ {
132
+ key: 'APNS_KEY',
133
+ label: 'APNs Key Content',
134
+ description: 'Contents of your APNs .p8 key file',
135
+ instructions: 'Paste the entire contents of the .p8 file you downloaded',
136
+ required: false,
137
+ type: 'multiline',
138
+ },
139
+ {
140
+ key: 'APNS_ENDPOINT',
141
+ label: 'APNs Endpoint',
142
+ description: 'Apple Push Notification service endpoint',
143
+ instructions: `Production: ${pc.green('https://api.push.apple.com')}\nSandbox: ${pc.yellow('https://api.sandbox.push.apple.com')}`,
144
+ required: false,
145
+ type: 'text',
146
+ default: 'https://api.push.apple.com',
147
+ },
148
+ ],
149
+ },
150
+ {
151
+ id: 'email',
152
+ name: 'Email Service',
153
+ description: 'Transactional email configuration',
154
+ required: false,
155
+ variables: [
156
+ {
157
+ key: 'POSTMARK_SERVER_TOKEN',
158
+ label: 'Postmark Server Token',
159
+ description: 'API token for sending emails via Postmark',
160
+ instructions: `Get your token:\n1. Sign up at ${pc.cyan('https://postmarkapp.com')}\n2. Create a Server\n3. Go to Servers → API Tokens\n4. Copy your Server API Token\n\n${pc.yellow('Note: Free tier includes 100 emails/month')}`,
161
+ required: false,
162
+ type: 'text',
163
+ },
164
+ ],
165
+ },
166
+ {
167
+ id: 'github',
168
+ name: 'GitHub OAuth',
169
+ description: 'Enable GitHub sign-in for your app',
170
+ required: false,
171
+ variables: [
172
+ {
173
+ key: 'ONECHAT_GITHUB_CLIENT_ID',
174
+ label: 'GitHub OAuth Client ID',
175
+ description: 'Client ID for GitHub OAuth application',
176
+ instructions: `Create GitHub OAuth App:\n1. Go to ${pc.cyan('https://github.com/settings/developers')}\n2. New OAuth App\n3. Set Authorization callback URL:\n ${pc.green('https://your-app.com/api/auth/callback/github')}\n4. Copy the Client ID`,
177
+ required: false,
178
+ type: 'text',
179
+ },
180
+ {
181
+ key: 'ONECHAT_GITHUB_CLIENT_SECRET',
182
+ label: 'GitHub OAuth Client Secret',
183
+ description: 'Client secret for GitHub OAuth application',
184
+ instructions: 'Copy the Client Secret from your GitHub OAuth App settings',
185
+ required: false,
186
+ type: 'secret',
187
+ },
188
+ ],
189
+ },
190
+ {
191
+ id: 'aws',
192
+ name: 'AWS Deployment',
193
+ description: 'AWS credentials for SST deployment',
194
+ required: false,
195
+ setupTime: '~30 minutes',
196
+ variables: [
197
+ {
198
+ key: 'AWS_ACCESS_KEY_ID',
199
+ label: 'AWS Access Key ID',
200
+ description: 'Access key for AWS deployment',
201
+ instructions: `${pc.yellow('⚠️ Setting up AWS takes about 30 minutes')}\n\nFollow the SST guide for AWS setup:\n${pc.cyan('https://sst.dev/docs/aws-accounts/')}\n\nQuick steps:\n1. Create AWS account or use existing\n2. Create IAM user with AdministratorAccess\n3. Generate access keys\n4. Copy Access Key ID`,
202
+ required: false,
203
+ type: 'text',
204
+ },
205
+ {
206
+ key: 'AWS_SECRET_ACCESS_KEY',
207
+ label: 'AWS Secret Access Key',
208
+ description: 'Secret key for AWS deployment',
209
+ instructions: 'Copy the Secret Access Key from IAM user creation',
210
+ required: false,
211
+ type: 'secret',
212
+ },
213
+ {
214
+ key: 'AWS_REGION',
215
+ label: 'AWS Region',
216
+ description: 'AWS region for deployment',
217
+ instructions:
218
+ 'Choose a region close to your users (e.g., us-west-1, us-east-1, eu-west-1)',
219
+ required: false,
220
+ type: 'text',
221
+ default: 'us-west-1',
222
+ placeholder: 'us-west-1',
223
+ },
224
+ ],
225
+ },
226
+ ]
227
+
228
+ export function getCategoryById(id: string): EnvCategory | undefined {
229
+ return envCategories.find((cat) => cat.id === id)
230
+ }
231
+
232
+ export function getRequiredCategories(): EnvCategory[] {
233
+ return envCategories.filter((cat) => cat.required)
234
+ }
235
+
236
+ export function getOptionalCategories(): EnvCategory[] {
237
+ return envCategories.filter((cat) => !cat.required)
238
+ }
239
+
240
+ export function getAllVariables(): EnvVariable[] {
241
+ return envCategories.flatMap((cat) => cat.variables)
242
+ }
243
+
244
+ export function getVariableByKey(key: string): EnvVariable | undefined {
245
+ return getAllVariables().find((v) => v.key === key)
246
+ }
@@ -0,0 +1,335 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import * as clack from '@clack/prompts'
5
+ import pc from 'picocolors'
6
+
7
+ import { copyEnvFile, envFileExists, readEnvVariable, updateEnvVariable } from './env'
8
+ import { envCategories } from './env-categories'
9
+
10
+ import type { EnvCategory, EnvVariable } from '../types'
11
+
12
+ interface SetupOptions {
13
+ skipOptional?: boolean
14
+ envFile?: string
15
+ onlyCategory?: string
16
+ interactive?: boolean
17
+ }
18
+
19
+ export async function setupProductionEnv(
20
+ cwd: string,
21
+ options: SetupOptions = {}
22
+ ): Promise<boolean> {
23
+ const envFile = options.envFile || '.env.production'
24
+ const fullPath = resolve(cwd, envFile)
25
+
26
+ // set up interrupt handler
27
+ const cleanup = () => {
28
+ console.info('\n' + pc.yellow('Setup interrupted. You can resume anytime with:'))
29
+ console.info(pc.cyan(' bun takeout env:setup'))
30
+ console.info(
31
+ pc.gray('\nNote: All environment variables are optional for local development.')
32
+ )
33
+ process.exit(0)
34
+ }
35
+
36
+ process.on('SIGINT', cleanup)
37
+ process.on('SIGTERM', cleanup)
38
+
39
+ try {
40
+ clack.intro(pc.bgCyan(pc.black(' Production Environment Setup ')))
41
+
42
+ console.info(pc.gray('All environment variables are optional.'))
43
+ console.info(pc.gray('You can skip any section or re-run this setup anytime.\n'))
44
+
45
+ // check if .env.production exists, if not create from example
46
+ if (!envFileExists(cwd, envFile)) {
47
+ const createFile = await clack.confirm({
48
+ message: `${envFile} doesn't exist. Create it from example?`,
49
+ initialValue: true,
50
+ })
51
+
52
+ if (clack.isCancel(createFile) || !createFile) {
53
+ clack.cancel('Setup cancelled')
54
+ process.removeListener('SIGINT', cleanup)
55
+ process.removeListener('SIGTERM', cleanup)
56
+ return false
57
+ }
58
+
59
+ const exampleFile = '.env.production.example'
60
+ if (envFileExists(cwd, exampleFile)) {
61
+ copyEnvFile(cwd, exampleFile, envFile)
62
+ console.info(pc.green(`✓ Created ${envFile} from example\n`))
63
+ } else {
64
+ // create empty file
65
+ writeFileSync(fullPath, '# Production Environment Variables\n')
66
+ console.info(pc.green(`✓ Created empty ${envFile}\n`))
67
+ }
68
+ }
69
+
70
+ // filter categories if specific one requested
71
+ let categoriesToSetup = options.onlyCategory
72
+ ? envCategories.filter((cat) => cat.id === options.onlyCategory)
73
+ : envCategories
74
+
75
+ if (categoriesToSetup.length === 0) {
76
+ clack.cancel(`Category "${options.onlyCategory}" not found`)
77
+ process.removeListener('SIGINT', cleanup)
78
+ process.removeListener('SIGTERM', cleanup)
79
+ return false
80
+ }
81
+
82
+ // ask about setting up production env
83
+ if (options.interactive !== false && !options.onlyCategory) {
84
+ const setupProd = await clack.confirm({
85
+ message: 'Do you want to set up production environment variables?',
86
+ initialValue: false,
87
+ })
88
+
89
+ if (clack.isCancel(setupProd) || !setupProd) {
90
+ clack.note(
91
+ `You can set up production environment later with:\n${pc.cyan('bun takeout env:setup')}`,
92
+ pc.yellow('Skipping production setup')
93
+ )
94
+ process.removeListener('SIGINT', cleanup)
95
+ process.removeListener('SIGTERM', cleanup)
96
+ return true
97
+ }
98
+ }
99
+
100
+ // process each category
101
+ for (const category of categoriesToSetup) {
102
+ const shouldSetup = await setupCategory(category, envFile, cwd, options)
103
+ if (!shouldSetup) {
104
+ continue
105
+ }
106
+ }
107
+
108
+ // summary
109
+ const configuredVars: string[] = []
110
+ const skippedVars: string[] = []
111
+
112
+ for (const category of categoriesToSetup) {
113
+ for (const variable of category.variables) {
114
+ const value = readEnvVariable(cwd, variable.key, envFile)
115
+ if (value && value !== '' && !value.includes('your-')) {
116
+ configuredVars.push(variable.key)
117
+ } else if (variable.required) {
118
+ skippedVars.push(variable.key)
119
+ }
120
+ }
121
+ }
122
+
123
+ if (configuredVars.length > 0) {
124
+ clack.outro(pc.green('✓ Environment setup complete!'))
125
+ console.info(
126
+ pc.gray(`\nConfigured ${configuredVars.length} variables in ${envFile}`)
127
+ )
128
+
129
+ if (skippedVars.length > 0) {
130
+ console.info(
131
+ pc.yellow(
132
+ `\nNote: Some required variables were skipped. You'll need to configure these before deploying:`
133
+ )
134
+ )
135
+ skippedVars.forEach((v) => console.info(pc.gray(` - ${v}`)))
136
+ }
137
+
138
+ console.info(pc.cyan('\nYou can re-run this setup anytime with:'))
139
+ console.info(pc.gray(' bun takeout env:setup'))
140
+ console.info(pc.cyan('\nOr set up specific categories:'))
141
+ console.info(pc.gray(' bun takeout env:setup --category aws'))
142
+ console.info(pc.gray(' bun takeout env:setup --category apple'))
143
+ } else {
144
+ clack.outro(pc.yellow('No variables configured'))
145
+ console.info(pc.gray('\nYou can re-run this setup anytime with:'))
146
+ console.info(pc.gray(' bun takeout env:setup'))
147
+ }
148
+
149
+ process.removeListener('SIGINT', cleanup)
150
+ process.removeListener('SIGTERM', cleanup)
151
+ return true
152
+ } catch (error) {
153
+ process.removeListener('SIGINT', cleanup)
154
+ process.removeListener('SIGTERM', cleanup)
155
+ if (error instanceof Error && error.message.includes('cancelled')) {
156
+ return false
157
+ }
158
+ throw error
159
+ }
160
+ }
161
+
162
+ async function setupCategory(
163
+ category: EnvCategory,
164
+ envFile: string,
165
+ cwd: string,
166
+ options: SetupOptions
167
+ ): Promise<boolean> {
168
+ const spinner = clack.spinner()
169
+
170
+ // build category prompt message
171
+ let message = `Set up ${pc.bold(category.name)}?`
172
+ if (!category.required) {
173
+ message += pc.gray(' (optional)')
174
+ }
175
+ if (category.setupTime) {
176
+ message += pc.yellow(` ${category.setupTime}`)
177
+ }
178
+
179
+ console.info('') // add spacing
180
+ const setupCategory = await clack.confirm({
181
+ message,
182
+ initialValue: category.required,
183
+ })
184
+
185
+ if (clack.isCancel(setupCategory) || !setupCategory) {
186
+ console.info(pc.gray(` Skipping ${category.name}`))
187
+ return false
188
+ }
189
+
190
+ console.info(pc.gray(`\n${category.description}\n`))
191
+
192
+ // process each variable in the category
193
+ for (const variable of category.variables) {
194
+ await setupVariable(variable, envFile, cwd)
195
+ }
196
+
197
+ return true
198
+ }
199
+
200
+ async function setupVariable(
201
+ variable: EnvVariable,
202
+ envFile: string,
203
+ cwd: string
204
+ ): Promise<void> {
205
+ // check if already configured
206
+ const existingValue = readEnvVariable(cwd, variable.key, envFile)
207
+ const hasValue =
208
+ existingValue && existingValue !== '' && !existingValue.includes('your-')
209
+
210
+ console.info('') // spacing
211
+ console.info(pc.bold(variable.label))
212
+ console.info(pc.gray(variable.description))
213
+
214
+ if (hasValue) {
215
+ const update = await clack.confirm({
216
+ message: `${pc.green('✓')} Already configured. Update?`,
217
+ initialValue: false,
218
+ })
219
+
220
+ if (clack.isCancel(update) || !update) {
221
+ return
222
+ }
223
+ }
224
+
225
+ // show instructions
226
+ console.info('')
227
+ console.info(pc.cyan('Instructions:'))
228
+ variable.instructions.split('\n').forEach((line) => {
229
+ console.info(pc.gray(` ${line}`))
230
+ })
231
+ console.info('')
232
+
233
+ // handle different input types
234
+ let value: string | undefined
235
+
236
+ if (variable.generator) {
237
+ const generate = await clack.confirm({
238
+ message: `Generate ${variable.label} automatically?`,
239
+ initialValue: true,
240
+ })
241
+
242
+ if (!clack.isCancel(generate) && generate) {
243
+ value = variable.generator()
244
+ console.info(pc.green(`✓ Generated ${variable.label}`))
245
+ }
246
+ }
247
+
248
+ if (!value) {
249
+ if (variable.type === 'multiline') {
250
+ console.info(pc.gray('Paste content (press Enter twice when done):'))
251
+ value = await readMultilineInput()
252
+ } else if (variable.type === 'secret') {
253
+ const result = await clack.password({
254
+ message: `Enter ${variable.label}:`,
255
+ mask: '*',
256
+ })
257
+ if (!clack.isCancel(result)) {
258
+ value = result
259
+ }
260
+ } else {
261
+ const result = await clack.text({
262
+ message: `Enter ${variable.label}:`,
263
+ placeholder: variable.placeholder,
264
+ defaultValue: variable.default,
265
+ validate: (val) => {
266
+ if (variable.required && !val) {
267
+ return 'This field is required'
268
+ }
269
+ return undefined
270
+ },
271
+ })
272
+ if (!clack.isCancel(result)) {
273
+ value = result
274
+ }
275
+ }
276
+ }
277
+
278
+ if (value && !clack.isCancel(value)) {
279
+ updateEnvVariable(cwd, variable.key, value, envFile)
280
+ console.info(pc.green(`✓ Saved ${variable.key}`))
281
+ } else if (variable.required) {
282
+ console.info(pc.yellow(`⚠ Skipped required variable: ${variable.key}`))
283
+ } else {
284
+ console.info(pc.gray(` Skipped ${variable.key}`))
285
+ }
286
+ }
287
+
288
+ async function readMultilineInput(): Promise<string> {
289
+ return new Promise((resolve) => {
290
+ const lines: string[] = []
291
+ let emptyLineCount = 0
292
+
293
+ const reader = process.stdin
294
+ reader.setEncoding('utf8')
295
+
296
+ const onData = (chunk: string) => {
297
+ const chunkLines = chunk.split('\n')
298
+ for (const line of chunkLines) {
299
+ if (line === '') {
300
+ emptyLineCount++
301
+ if (emptyLineCount >= 2) {
302
+ reader.removeListener('data', onData)
303
+ resolve(lines.join('\n'))
304
+ return
305
+ }
306
+ } else {
307
+ if (emptyLineCount === 1) {
308
+ lines.push('') // add the single empty line
309
+ }
310
+ emptyLineCount = 0
311
+ lines.push(line)
312
+ }
313
+ }
314
+ }
315
+
316
+ reader.on('data', onData)
317
+ })
318
+ }
319
+
320
+ export function listCategories(): void {
321
+ console.info(pc.bold('\nAvailable environment categories:\n'))
322
+
323
+ for (const category of envCategories) {
324
+ const status = category.required ? pc.red('required') : pc.gray('optional')
325
+ console.info(` ${pc.cyan(category.id.padEnd(12))} - ${category.name} ${status}`)
326
+ console.info(` ${pc.gray(category.description)}`)
327
+ if (category.setupTime) {
328
+ console.info(` ${pc.yellow(category.setupTime)}`)
329
+ }
330
+ console.info('')
331
+ }
332
+
333
+ console.info(pc.gray('Run setup for a specific category:'))
334
+ console.info(pc.cyan(' bun takeout env:setup --category <id>\n'))
335
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Environment file operations
3
+ */
4
+
5
+ import { randomBytes } from 'node:crypto'
6
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ export function generateSecret(length = 32): string {
10
+ return randomBytes(length).toString('hex')
11
+ }
12
+
13
+ export function envFileExists(cwd: string, filename = '.env'): boolean {
14
+ return existsSync(join(cwd, filename))
15
+ }
16
+
17
+ export function copyEnvFile(
18
+ cwd: string,
19
+ source: string,
20
+ target: string
21
+ ): { success: boolean; error?: string } {
22
+ const sourcePath = join(cwd, source)
23
+ const targetPath = join(cwd, target)
24
+
25
+ if (!existsSync(sourcePath)) {
26
+ return { success: false, error: `Source file ${source} not found` }
27
+ }
28
+
29
+ if (existsSync(targetPath)) {
30
+ return { success: false, error: `Target file ${target} already exists` }
31
+ }
32
+
33
+ try {
34
+ copyFileSync(sourcePath, targetPath)
35
+ return { success: true }
36
+ } catch (error) {
37
+ return {
38
+ success: false,
39
+ error: error instanceof Error ? error.message : 'Unknown error',
40
+ }
41
+ }
42
+ }
43
+
44
+ export function updateEnvVariable(
45
+ cwd: string,
46
+ key: string,
47
+ value: string,
48
+ filename = '.env'
49
+ ): { success: boolean; error?: string } {
50
+ const envPath = join(cwd, filename)
51
+
52
+ if (!existsSync(envPath)) {
53
+ return { success: false, error: `${filename} not found` }
54
+ }
55
+
56
+ try {
57
+ let content = readFileSync(envPath, 'utf-8')
58
+
59
+ // Check if key exists
60
+ const keyRegex = new RegExp(`^${key}=.*$`, 'm')
61
+
62
+ if (keyRegex.test(content)) {
63
+ // Replace existing value
64
+ content = content.replace(keyRegex, `${key}=${value}`)
65
+ } else {
66
+ // Append new key-value
67
+ content = content.trimEnd() + `\n${key}=${value}\n`
68
+ }
69
+
70
+ writeFileSync(envPath, content, 'utf-8')
71
+ return { success: true }
72
+ } catch (error) {
73
+ return {
74
+ success: false,
75
+ error: error instanceof Error ? error.message : 'Unknown error',
76
+ }
77
+ }
78
+ }
79
+
80
+ export function createEnvLocal(cwd: string): { success: boolean; error?: string } {
81
+ const envLocalPath = join(cwd, '.env.local')
82
+
83
+ if (existsSync(envLocalPath)) {
84
+ return { success: true } // Already exists, that's fine
85
+ }
86
+
87
+ const template = `# Local environment overrides
88
+ # This file is gitignored and never committed
89
+ # Add your personal secrets and local configuration here
90
+ # These values override .env
91
+
92
+ # Example:
93
+ # BETTER_AUTH_SECRET=your-secret-here
94
+ # AWS_ACCESS_KEY_ID=your-key-here
95
+ `
96
+
97
+ try {
98
+ writeFileSync(envLocalPath, template, 'utf-8')
99
+ return { success: true }
100
+ } catch (error) {
101
+ return {
102
+ success: false,
103
+ error: error instanceof Error ? error.message : 'Unknown error',
104
+ }
105
+ }
106
+ }
107
+
108
+ export function readEnvVariable(
109
+ cwd: string,
110
+ key: string,
111
+ filename = '.env'
112
+ ): string | null {
113
+ const envPath = join(cwd, filename)
114
+
115
+ if (!existsSync(envPath)) {
116
+ return null
117
+ }
118
+
119
+ try {
120
+ const content = readFileSync(envPath, 'utf-8')
121
+ const keyRegex = new RegExp(`^${key}=(.*)$`, 'm')
122
+ const match = content.match(keyRegex)
123
+ return match?.[1]?.trim().replace(/^["']|["']$/g, '') || null
124
+ } catch {
125
+ return null
126
+ }
127
+ }