@crossdelta/infrastructure 0.2.25 → 0.2.26

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 (2) hide show
  1. package/bin/generate-env.ts +178 -0
  2. package/package.json +6 -2
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Generate environment variables for local development.
4
+ *
5
+ * This script:
6
+ * 1. Loads secrets from Pulumi config (dev stack) if available
7
+ * 2. Discovers services from infra/services/*.ts (parses files without importing)
8
+ * 3. Generates service URLs and ports for localhost
9
+ * 4. Writes .env.local to the workspace root
10
+ *
11
+ * Usage: generate-env [--no-pulumi]
12
+ */
13
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+
16
+ type PulumiConfigEntry = {
17
+ value?: string
18
+ secret?: boolean
19
+ }
20
+
21
+ interface MinimalServiceConfig {
22
+ name: string
23
+ containerPort?: number
24
+ httpPort?: number
25
+ internalPorts?: number[]
26
+ internalUrl?: string
27
+ }
28
+
29
+ const toEnvKey = (name: string): string => name.toUpperCase().replace(/-/g, '_')
30
+
31
+ const resolveNumber = (value: string, fileContent: string): number | undefined => {
32
+ const literal = Number.parseInt(value, 10)
33
+ if (!Number.isNaN(literal)) return literal
34
+
35
+ const varMatch = fileContent.match(new RegExp(`(?:const|let|var)\\s+${value}\\s*=\\s*(\\d+)`))
36
+ return varMatch?.[1] ? Number.parseInt(varMatch[1], 10) : undefined
37
+ }
38
+
39
+ const extract = {
40
+ string: (content: string, pattern: RegExp): string | undefined =>
41
+ content.match(pattern)?.[1],
42
+
43
+ number: (content: string, pattern: RegExp): number | undefined => {
44
+ const match = content.match(pattern)?.[1]
45
+ return match ? resolveNumber(match, content) : undefined
46
+ },
47
+
48
+ numberArray: (content: string, pattern: RegExp): number[] | undefined => {
49
+ const match = content.match(pattern)?.[1]
50
+ if (!match) return undefined
51
+
52
+ const numbers = match
53
+ .split(',')
54
+ .map((v) => resolveNumber(v.trim(), content))
55
+ .filter((n): n is number => n !== undefined)
56
+
57
+ return numbers.length > 0 ? numbers : undefined
58
+ },
59
+ }
60
+
61
+ const findWorkspaceRoot = (): string => {
62
+ let dir = process.cwd()
63
+ while (!existsSync(join(dir, 'bun.lock')) && !existsSync(join(dir, 'package-lock.json'))) {
64
+ const parent = join(dir, '..')
65
+ if (parent === dir) throw new Error('Could not locate workspace root')
66
+ dir = parent
67
+ }
68
+ return dir
69
+ }
70
+
71
+ const loadPulumiConfig = async (infraDir: string, stack: string): Promise<string[]> => {
72
+ try {
73
+ const result = Bun.spawnSync(['pulumi', 'config', '--show-secrets', '--json', '--stack', stack, '--cwd', infraDir])
74
+ if (result.exitCode !== 0) return []
75
+
76
+ const config = JSON.parse(result.stdout.toString()) as Record<string, PulumiConfigEntry>
77
+
78
+ return Object.entries(config)
79
+ .filter(([key]) => key.includes(':'))
80
+ .filter(([, entry]) => entry.value !== undefined && entry.value !== null && entry.value !== 'undefined')
81
+ .map(([fullKey, entry]) => {
82
+ const [, rawKey] = fullKey.split(':')
83
+ return `${rawKey}=${entry.value}`
84
+ })
85
+ } catch {
86
+ return []
87
+ }
88
+ }
89
+
90
+ const discoverServices = (servicesDir: string): MinimalServiceConfig[] => {
91
+ if (!existsSync(servicesDir)) return []
92
+
93
+ const files = readdirSync(servicesDir).filter(
94
+ (file) => file.endsWith('.ts') && file !== 'index.ts'
95
+ )
96
+
97
+ return files.map((file) => {
98
+ const content = readFileSync(join(servicesDir, file), 'utf-8')
99
+
100
+ return {
101
+ name: extract.string(content, /name:\s*['"]([^'"]+)['"]/) ?? file.replace('.ts', ''),
102
+ containerPort: extract.number(content, /containerPort:\s*(\w+)/),
103
+ httpPort: extract.number(content, /httpPort:\s*(\w+)/),
104
+ internalPorts: extract.numberArray(content, /internalPorts:\s*\[([^\]]+)\]/),
105
+ internalUrl: extract.string(content, /internalUrl:\s*[`'"]([^`'"]+)[`'"]/),
106
+ }
107
+ })
108
+ }
109
+
110
+ const getServicePort = (config: MinimalServiceConfig): number | undefined => {
111
+ if (config.containerPort) return config.containerPort
112
+ if (config.httpPort) return config.httpPort
113
+ if (config.internalPorts?.[0]) return config.internalPorts[0]
114
+ return undefined
115
+ }
116
+
117
+ const getLocalUrl = (config: MinimalServiceConfig): string | undefined => {
118
+ const port = getServicePort(config)
119
+ if (!port) return undefined
120
+
121
+ if (config.internalUrl) {
122
+ const protocolMatch = config.internalUrl.match(/^(\w+):\/\//)
123
+ if (protocolMatch) {
124
+ return `${protocolMatch[1]}://localhost:${port}`
125
+ }
126
+ }
127
+
128
+ return `http://localhost:${port}`
129
+ }
130
+
131
+ const main = async () => {
132
+ const noPulumi = process.argv.includes('--no-pulumi')
133
+ const workspaceRootDir = findWorkspaceRoot()
134
+ const infraDir = join(workspaceRootDir, 'infra')
135
+ const servicesDir = join(infraDir, 'services')
136
+
137
+ const envLines: string[] = ['# Generated .env.local']
138
+
139
+ // Load Pulumi secrets if available and not disabled
140
+ if (!noPulumi && existsSync(join(infraDir, 'Pulumi.yaml'))) {
141
+ const pulumiEnvs = await loadPulumiConfig(infraDir, 'dev')
142
+ if (pulumiEnvs.length > 0) {
143
+ envLines.push('', '# Pulumi Secrets')
144
+ envLines.push(...pulumiEnvs)
145
+ }
146
+ }
147
+
148
+ // Discover services
149
+ const serviceConfigs = discoverServices(servicesDir)
150
+
151
+ if (serviceConfigs.length > 0) {
152
+ envLines.push('', '# Service URLs')
153
+ for (const config of serviceConfigs) {
154
+ const url = getLocalUrl(config)
155
+ if (url) {
156
+ envLines.push(`${toEnvKey(config.name)}_URL=${url}`)
157
+ }
158
+ }
159
+
160
+ envLines.push('', '# Service Ports')
161
+ for (const config of serviceConfigs) {
162
+ const port = getServicePort(config)
163
+ if (port) {
164
+ envLines.push(`${toEnvKey(config.name)}_PORT=${port}`)
165
+ }
166
+ }
167
+
168
+ console.log(`✅ Discovered ${serviceConfigs.length} services`)
169
+ }
170
+
171
+ writeFileSync(join(workspaceRootDir, '.env.local'), `${envLines.join('\n')}\n`)
172
+ console.log(`✅ .env.local generated at ${workspaceRootDir}`)
173
+ }
174
+
175
+ main().catch((err) => {
176
+ console.error('❌', err.message)
177
+ process.exit(1)
178
+ })
package/package.json CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "@crossdelta/infrastructure",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
+ "bin": {
10
+ "generate-env": "./bin/generate-env.ts"
11
+ },
9
12
  "main": "dist/index.cjs",
10
13
  "types": "dist/index.d.ts",
11
14
  "files": [
12
- "dist"
15
+ "dist",
16
+ "bin"
13
17
  ],
14
18
  "exports": {
15
19
  ".": {