@o/scripts 0.16.19-canary.1790165522484

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,227 @@
1
+ /**
2
+ * deployment health check helpers for uncloud deployments
3
+ * shared across repos via @o/scripts/helpers/deploy-health
4
+ */
5
+
6
+ export interface SSHContext {
7
+ sshOpts: string
8
+ deployUser: string
9
+ deployHost: string
10
+ $: (
11
+ strings: TemplateStringsArray,
12
+ ...values: any[]
13
+ ) => { quiet: () => Promise<{ stdout: Buffer }> }
14
+ }
15
+
16
+ export interface ContainerInfo {
17
+ id: string
18
+ status: string
19
+ state: string
20
+ names: string
21
+ isRestarting: boolean
22
+ }
23
+
24
+ /**
25
+ * check for containers stuck in restart loop
26
+ * fails fast if any container is crash-looping
27
+ * excludes init containers (minio-init, migrate) that are expected to exit
28
+ */
29
+ export async function checkRestartLoops(
30
+ ctx: SSHContext,
31
+ options?: { excludePatterns?: string[] }
32
+ ): Promise<{
33
+ hasLoop: boolean
34
+ services: string[]
35
+ }> {
36
+ const { sshOpts, deployUser, deployHost, $ } = ctx
37
+ // default exclude patterns for init/oneshot containers
38
+ const excludePatterns = options?.excludePatterns ?? ['init', 'migrate']
39
+
40
+ try {
41
+ const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
42
+ const result =
43
+ await $`${sshCmd.split(' ')} "docker ps -a --format '{{.Names}} {{.Status}}'"`.quiet()
44
+ const lines = result.stdout.toString().trim().split('\n').filter(Boolean)
45
+
46
+ const loopingServices: string[] = []
47
+ for (const line of lines) {
48
+ if (line.includes('Restarting')) {
49
+ const name = line.split(' ')[0] || ''
50
+ // skip init/oneshot containers
51
+ const isExcluded = excludePatterns.some((pattern) => name.includes(pattern))
52
+ if (!isExcluded) {
53
+ loopingServices.push(name)
54
+ }
55
+ }
56
+ }
57
+
58
+ return { hasLoop: loopingServices.length > 0, services: loopingServices }
59
+ } catch {
60
+ return { hasLoop: false, services: [] }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * get container status for all running containers
66
+ * extracts service name from container names (handles various naming patterns)
67
+ */
68
+ export async function getContainerStatus(
69
+ ctx: SSHContext,
70
+ options?: { verbose?: boolean }
71
+ ): Promise<Map<string, ContainerInfo>> {
72
+ const { sshOpts, deployUser, deployHost, $ } = ctx
73
+ try {
74
+ const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
75
+ // use -a to see all containers including stopped/restarting
76
+ const result = await $`${sshCmd.split(' ')} "docker ps -a --format json"`.quiet()
77
+ const lines = result.stdout.toString().trim().split('\n').filter(Boolean)
78
+
79
+ const containers = new Map<string, ContainerInfo>()
80
+ for (const line of lines) {
81
+ try {
82
+ const container = JSON.parse(line)
83
+ const name = container.Names || ''
84
+ let serviceName: string | null = null
85
+
86
+ // try various naming patterns
87
+ // pattern 1: "projectname-service-1" or "projectname_service_1"
88
+ let match = name.match(/[^_-]+[_-]([^_-]+)[_-]\w+/)
89
+ if (match) {
90
+ serviceName = match[1]
91
+ } else {
92
+ // pattern 2: "service-xxxx" where xxxx is alphanumeric
93
+ match = name.match(/^([^_-]+)[_-]\w+$/)
94
+ if (match) {
95
+ serviceName = match[1]
96
+ } else {
97
+ // pattern 3: just the name before any dash or underscore
98
+ match = name.match(/^([^_-]+)/)
99
+ if (match) {
100
+ serviceName = match[1]
101
+ }
102
+ }
103
+ }
104
+
105
+ if (serviceName) {
106
+ const status = container.Status || ''
107
+ const state = container.State || ''
108
+ const isRestarting = state === 'restarting' || status.includes('Restarting')
109
+
110
+ containers.set(serviceName, {
111
+ id: container.ID,
112
+ status,
113
+ state,
114
+ names: container.Names,
115
+ isRestarting,
116
+ })
117
+ }
118
+ } catch {
119
+ // skip invalid json lines
120
+ }
121
+ }
122
+
123
+ return containers
124
+ } catch (error) {
125
+ if (options?.verbose) {
126
+ console.error('failed to get container status:', error)
127
+ }
128
+ return new Map()
129
+ }
130
+ }
131
+
132
+ /**
133
+ * check health status of a specific container
134
+ * returns 'healthy', 'checking' (mid-probe), or 'unhealthy'
135
+ */
136
+ export async function checkContainerHealth(
137
+ ctx: SSHContext,
138
+ containerId: string
139
+ ): Promise<'healthy' | 'checking' | 'unhealthy'> {
140
+ const { sshOpts, deployUser, deployHost, $ } = ctx
141
+ try {
142
+ const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
143
+ const healthResult =
144
+ await $`${sshCmd.split(' ')} "docker inspect ${containerId} --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}'"`.quiet()
145
+ const healthStatus = healthResult.stdout.toString().trim()
146
+
147
+ if (healthStatus === 'healthy') {
148
+ return 'healthy'
149
+ } else if (healthStatus === 'no-healthcheck') {
150
+ // fall back to checking if running for containers without healthcheck
151
+ const statusResult =
152
+ await $`${sshCmd.split(' ')} "docker inspect ${containerId} --format '{{.State.Status}}'"`.quiet()
153
+ const status = statusResult.stdout.toString().trim()
154
+ return status === 'running' ? 'healthy' : 'unhealthy'
155
+ } else if (healthStatus === 'starting') {
156
+ // mid-healthcheck probe, don't treat as failure yet
157
+ return 'checking'
158
+ }
159
+
160
+ return 'unhealthy'
161
+ } catch {
162
+ return 'unhealthy'
163
+ }
164
+ }
165
+
166
+ /**
167
+ * check http health endpoint from inside web container
168
+ */
169
+ export async function checkHttpHealth(
170
+ ctx: SSHContext,
171
+ options?: { endpoint?: string; containerFilter?: string }
172
+ ): Promise<{
173
+ healthy: boolean
174
+ status?: number
175
+ message?: string
176
+ }> {
177
+ const { sshOpts, deployUser, deployHost, $ } = ctx
178
+ const endpoint = options?.endpoint || 'http://localhost:8081/'
179
+ const containerFilter = options?.containerFilter || 'web'
180
+
181
+ try {
182
+ const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
183
+ const containersResult =
184
+ await $`${sshCmd.split(' ')} "docker ps --filter 'name=${containerFilter}' --format '{{.Names}}'"`.quiet()
185
+ const webContainer = containersResult.stdout.toString().trim()
186
+
187
+ if (!webContainer) {
188
+ return { healthy: false, message: 'web container not found' }
189
+ }
190
+
191
+ const result =
192
+ await $`${sshCmd.split(' ')} "docker exec ${webContainer} curl -so /dev/null -w '%{http_code}' -m 5 ${endpoint}"`.quiet()
193
+ const output = result.stdout.toString().trim()
194
+ const statusCode = parseInt(output, 10)
195
+
196
+ if (statusCode >= 200 && statusCode < 400) {
197
+ return { healthy: true, status: statusCode }
198
+ } else if (statusCode >= 400 && statusCode < 500) {
199
+ return { healthy: true, status: statusCode, message: 'server running' }
200
+ } else if (statusCode >= 500) {
201
+ return { healthy: false, status: statusCode, message: 'server error' }
202
+ }
203
+
204
+ return { healthy: false, message: 'app starting...' }
205
+ } catch {
206
+ return { healthy: false, message: 'app starting...' }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * get recent logs from a container
212
+ */
213
+ export async function getContainerLogs(
214
+ ctx: SSHContext,
215
+ containerId: string,
216
+ lines = 20
217
+ ): Promise<string[]> {
218
+ const { sshOpts, deployUser, deployHost, $ } = ctx
219
+ try {
220
+ const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
221
+ const result =
222
+ await $`${sshCmd.split(' ')} "docker logs ${containerId} --tail ${lines} 2>&1"`.quiet()
223
+ return result.stdout.toString().trim().split('\n')
224
+ } catch {
225
+ return []
226
+ }
227
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * deploy lock helper — prevents concurrent deploys with auto-expiry
3
+ * shared across repos via @o/scripts/helpers/deploy-lock
4
+ *
5
+ * uses mkdir for atomic lock acquisition (no race window).
6
+ * writes a meta.json inside the lock dir so stale locks can self-diagnose:
7
+ * when a deploy dies without releasing, the next deploy prints who left the
8
+ * lock (pid, host, git sha, CI run URL) instead of just a timestamp.
9
+ */
10
+
11
+ import os from 'node:os'
12
+
13
+ import { run } from './run'
14
+
15
+ const DEFAULT_LOCK_PATH = '/tmp/deploy.lock'
16
+ const DEFAULT_MAX_AGE_MIN = 15
17
+
18
+ interface DeployLockOptions {
19
+ path?: string
20
+ maxAgeMin?: number
21
+ /**
22
+ * called before lock release on all exit paths: normal return, thrown
23
+ * error, and signal (SIGTERM/SIGINT/SIGHUP). use this to roll back
24
+ * partial state — e.g. restart a service that was stopped mid-deploy.
25
+ * cleanup errors are logged but do not prevent lock release.
26
+ */
27
+ cleanup?: () => Promise<void>
28
+ }
29
+
30
+ interface LockMeta {
31
+ pid: number
32
+ host: string
33
+ startedAt: string
34
+ gitSha: string
35
+ ciRunId: string
36
+ ciRunUrl: string
37
+ ciWorkflow: string
38
+ ciActor: string
39
+ }
40
+
41
+ function buildMeta(): LockMeta {
42
+ const repo = process.env.GITHUB_REPOSITORY || ''
43
+ const runId = process.env.GITHUB_RUN_ID || ''
44
+ const server = process.env.GITHUB_SERVER_URL || 'https://github.com'
45
+ const ciRunUrl = repo && runId ? `${server}/${repo}/actions/runs/${runId}` : ''
46
+ return {
47
+ pid: process.pid,
48
+ host: os.hostname(),
49
+ startedAt: new Date().toISOString(),
50
+ gitSha:
51
+ process.env.GIT_SHA || process.env.GITHUB_SHA || process.env.CI_COMMIT_SHA || '',
52
+ ciRunId: runId,
53
+ ciRunUrl,
54
+ ciWorkflow: process.env.GITHUB_WORKFLOW || '',
55
+ ciActor: process.env.GITHUB_ACTOR || '',
56
+ }
57
+ }
58
+
59
+ function toBase64(s: string): string {
60
+ return Buffer.from(s, 'utf-8').toString('base64')
61
+ }
62
+
63
+ async function sshBash(ssh: string, script: string, captureOutput = true) {
64
+ // base64-encode the script so we never have to escape quotes, $, backticks,
65
+ // newlines etc. at multiple layers (local bash → ssh → remote bash).
66
+ const b64 = toBase64(script)
67
+ return run(`${ssh} "echo ${b64} | base64 -d | bash"`, {
68
+ captureOutput,
69
+ silent: true,
70
+ })
71
+ }
72
+
73
+ export async function acquireDeployLock(
74
+ ssh: string,
75
+ opts?: DeployLockOptions
76
+ ): Promise<void> {
77
+ const lockPath = opts?.path || DEFAULT_LOCK_PATH
78
+ const maxAge = opts?.maxAgeMin || DEFAULT_MAX_AGE_MIN
79
+
80
+ // atomic cleanup-and-acquire in one round-trip:
81
+ // 1. if a stale lock exists (older than maxAge), print its metadata so the
82
+ // caller can log who left it behind, then rm it.
83
+ // 2. try mkdir — atomic; succeeds iff no concurrent deploy holds the lock.
84
+ const acquireScript = `
85
+ set -u
86
+ LOCK=${lockPath}
87
+ MAXAGE=${maxAge}
88
+ if [ -d "$LOCK" ]; then
89
+ if [ -n "$(find "$LOCK" -maxdepth 0 -mmin +$MAXAGE 2>/dev/null)" ]; then
90
+ echo "STALE_META_START"
91
+ cat "$LOCK/meta.json" 2>/dev/null || echo "(no metadata file)"
92
+ echo "STALE_META_END"
93
+ rm -rf "$LOCK"
94
+ fi
95
+ fi
96
+ if mkdir "$LOCK" 2>/dev/null; then
97
+ echo ACQUIRED
98
+ else
99
+ echo LOCKED
100
+ fi
101
+ `
102
+
103
+ const { stdout: acquireOut } = await sshBash(ssh, acquireScript)
104
+
105
+ // log any stale cleanup so the next deploy's logs show who left it
106
+ const staleMatch = acquireOut.match(/STALE_META_START\n([\s\S]*?)\nSTALE_META_END/)
107
+ if (staleMatch?.[1]) {
108
+ console.warn(`deploy-lock: cleared stale lock (older than ${maxAge}m) left by:`)
109
+ for (const line of staleMatch[1].split('\n')) {
110
+ console.warn(` ${line}`)
111
+ }
112
+ }
113
+
114
+ if (!acquireOut.trim().endsWith('ACQUIRED')) {
115
+ // read the live holder's metadata so the error tells the user exactly
116
+ // which deploy is blocking — not just "a lock file exists".
117
+ const diagScript = `
118
+ LOCK=${lockPath}
119
+ echo "meta.json:"
120
+ cat "$LOCK/meta.json" 2>/dev/null || echo " (no metadata)"
121
+ echo "mtime:"
122
+ stat -c " %y" "$LOCK" 2>/dev/null || echo " (unknown)"
123
+ `
124
+ let heldBy = '(diagnostics unavailable)'
125
+ try {
126
+ const { stdout } = await sshBash(ssh, diagScript)
127
+ heldBy = stdout.trim() || heldBy
128
+ } catch {
129
+ // best-effort
130
+ }
131
+
132
+ throw new Error(
133
+ `another deploy is in progress (${lockPath} exists on server)\n` +
134
+ ` held by:\n${heldBy
135
+ .split('\n')
136
+ .map((l) => ` ${l}`)
137
+ .join('\n')}\n` +
138
+ ` will auto-expire after ${maxAge} minutes, or remove manually: ${ssh} "rm -rf ${lockPath}"`
139
+ )
140
+ }
141
+
142
+ // write metadata inside the lock dir (best-effort; lock is already held
143
+ // so metadata failures are non-fatal — they only hurt future diagnostics).
144
+ const meta = buildMeta()
145
+ const metaJson = JSON.stringify(meta, null, 2)
146
+ const writeScript = `
147
+ LOCK=${lockPath}
148
+ cat > "$LOCK/meta.json" << 'DEPLOY_LOCK_META_EOF'
149
+ ${metaJson}
150
+ DEPLOY_LOCK_META_EOF
151
+ `
152
+ try {
153
+ await sshBash(ssh, writeScript, false)
154
+ } catch {
155
+ // non-fatal
156
+ }
157
+ }
158
+
159
+ export async function releaseDeployLock(
160
+ ssh: string,
161
+ opts?: DeployLockOptions
162
+ ): Promise<void> {
163
+ const lockPath = opts?.path || DEFAULT_LOCK_PATH
164
+ try {
165
+ await run(`${ssh} "rm -rf ${lockPath}"`, { silent: true })
166
+ } catch {
167
+ // best-effort, lock will auto-expire
168
+ }
169
+ }
170
+
171
+ /**
172
+ * run `fn` with the deploy lock held, releasing on ANY exit path —
173
+ * normal return, thrown error, or process signal (SIGTERM from CI cancel,
174
+ * SIGINT from ctrl-c, SIGHUP from terminal close).
175
+ *
176
+ * prefer this over raw acquire/release: a bare try/finally does NOT catch
177
+ * signals, so a CI-cancelled deploy would leak the lock for 15 minutes and
178
+ * block the next run. this helper installs signal handlers that release
179
+ * before exit, then removes them once `fn` completes.
180
+ *
181
+ * if `opts.cleanup` is provided, it runs before lock release on every exit
182
+ * path. use it to roll back partial deploy state (e.g. restart a stopped
183
+ * service) so a mid-flight crash doesn't leave prod in a broken state.
184
+ */
185
+ export async function withDeployLock<T>(
186
+ ssh: string,
187
+ fn: () => Promise<T>,
188
+ opts?: DeployLockOptions
189
+ ): Promise<T> {
190
+ await acquireDeployLock(ssh, opts)
191
+
192
+ let released = false
193
+ const runCleanupAndRelease = async () => {
194
+ if (released) return
195
+ released = true
196
+ if (opts?.cleanup) {
197
+ try {
198
+ await opts.cleanup()
199
+ } catch (err) {
200
+ console.warn('deploy-lock: cleanup threw (non-fatal):', err)
201
+ }
202
+ }
203
+ await releaseDeployLock(ssh, opts)
204
+ }
205
+
206
+ // signal handlers — fire-and-forget cleanup+release with a safety timeout
207
+ // so the process always exits even if ssh hangs. the 15s budget covers
208
+ // user cleanup (e.g. restarting a service) plus the release rm call.
209
+ const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP']
210
+ const handlers = new Map<NodeJS.Signals, () => void>()
211
+ for (const sig of signals) {
212
+ const handler = () => {
213
+ const done = runCleanupAndRelease().catch(() => {})
214
+ const timeout = new Promise<void>((r) => setTimeout(r, 15_000))
215
+ Promise.race([done, timeout]).finally(() => {
216
+ process.exit(sig === 'SIGINT' ? 130 : 143)
217
+ })
218
+ }
219
+ handlers.set(sig, handler)
220
+ process.once(sig, handler)
221
+ }
222
+
223
+ try {
224
+ return await fn()
225
+ } finally {
226
+ for (const [sig, handler] of handlers) {
227
+ process.off(sig, handler)
228
+ }
229
+ await runCleanupAndRelease()
230
+ }
231
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ CreateBucketCommand,
3
+ HeadBucketCommand,
4
+ PutBucketLifecycleConfigurationCommand,
5
+ PutBucketVersioningCommand,
6
+ S3Client,
7
+ } from '@aws-sdk/client-s3'
8
+
9
+ export async function ensureS3Bucket(
10
+ bucketName: string,
11
+ options?: {
12
+ region?: string
13
+ retentionDays?: number
14
+ }
15
+ ): Promise<void> {
16
+ const region = options?.region ?? 'us-west-1'
17
+ const retentionDays = options?.retentionDays ?? 30
18
+
19
+ const s3Client = new S3Client({
20
+ region,
21
+ credentials: {
22
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
23
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
24
+ },
25
+ })
26
+
27
+ try {
28
+ // check if bucket exists
29
+ await s3Client.send(new HeadBucketCommand({ Bucket: bucketName }))
30
+ console.info(`✅ S3 bucket exists: ${bucketName}`)
31
+ } catch (error: any) {
32
+ if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
33
+ console.info(`📦 Creating S3 bucket: ${bucketName}`)
34
+
35
+ // create bucket
36
+ await s3Client.send(
37
+ new CreateBucketCommand({
38
+ Bucket: bucketName,
39
+ ...(region !== 'us-east-1' && {
40
+ CreateBucketConfiguration: {
41
+ LocationConstraint: region as any,
42
+ },
43
+ }),
44
+ })
45
+ )
46
+
47
+ // enable versioning for safety
48
+ await s3Client.send(
49
+ new PutBucketVersioningCommand({
50
+ Bucket: bucketName,
51
+ VersioningConfiguration: {
52
+ Status: 'Enabled',
53
+ },
54
+ })
55
+ )
56
+
57
+ // set lifecycle policy to automatically delete old backups
58
+ await s3Client.send(
59
+ new PutBucketLifecycleConfigurationCommand({
60
+ Bucket: bucketName,
61
+ LifecycleConfiguration: {
62
+ Rules: [
63
+ {
64
+ ID: 'delete-old-backups',
65
+ Status: 'Enabled',
66
+ Filter: {
67
+ Prefix: 'db-backups/',
68
+ },
69
+ Expiration: {
70
+ Days: retentionDays,
71
+ },
72
+ NoncurrentVersionExpiration: {
73
+ NoncurrentDays: 7,
74
+ },
75
+ },
76
+ ],
77
+ },
78
+ })
79
+ )
80
+
81
+ console.info(
82
+ `✅ S3 bucket created with ${retentionDays} day retention: ${bucketName}`
83
+ )
84
+ } else {
85
+ throw error
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,43 @@
1
+ import { join } from 'node:path'
2
+
3
+ export async function loadEnv(
4
+ environment: 'development' | 'production' | 'dev-prod',
5
+ options?: { optional?: string[] }
6
+ ) {
7
+ const { loadEnv: vxrnLoadEnv } = await import('vxrn/loadEnv')
8
+
9
+ // loads env into process.env
10
+ await vxrnLoadEnv(environment)
11
+
12
+ const previousMode = process.env.TAKEOUT_ENV_MODE
13
+ if (environment === 'development' || environment === 'production') {
14
+ process.env.TAKEOUT_ENV_MODE = environment
15
+ } else {
16
+ delete process.env.TAKEOUT_ENV_MODE
17
+ }
18
+
19
+ let Environment: Record<string, string>
20
+ try {
21
+ // import src/env.ts to get the env config applied (side effect: populates process.env)
22
+ const envModule = await import(join(process.cwd(), 'src/env.ts'))
23
+ Environment = envModule.server || {}
24
+ } finally {
25
+ if (previousMode === undefined) {
26
+ delete process.env.TAKEOUT_ENV_MODE
27
+ } else {
28
+ process.env.TAKEOUT_ENV_MODE = previousMode
29
+ }
30
+ }
31
+
32
+ // validate
33
+ for (const key in Environment) {
34
+ if (options?.optional?.includes(key)) {
35
+ continue
36
+ }
37
+ if (typeof Environment[key as keyof typeof Environment] === 'undefined') {
38
+ console.warn(`Missing key: ${key}`)
39
+ }
40
+ }
41
+
42
+ return Environment
43
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { execSync } from 'node:child_process'
4
+ import { platform } from 'node:os'
5
+
6
+ /**
7
+ * Gets the appropriate host address for Docker containers to connect back to the host machine
8
+ * - On macOS/Windows: host.docker.internal works out of the box
9
+ * - On Linux: We need to get the actual host IP from the docker bridge network
10
+ */
11
+ export function getDockerHost(): string {
12
+ // In CI or on Linux, we need the actual host IP
13
+ if (platform() === 'linux') {
14
+ try {
15
+ // Get the host IP from docker network
16
+ const result = execSync(
17
+ "docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'",
18
+ { encoding: 'utf-8' }
19
+ ).trim()
20
+
21
+ if (result) {
22
+ console.info(`Using Docker bridge gateway IP: ${result}`)
23
+ return result
24
+ }
25
+ } catch (error) {
26
+ console.warn('Failed to get Docker bridge IP, falling back to host.docker.internal')
27
+ }
28
+ }
29
+
30
+ // Default for macOS/Windows or fallback
31
+ return 'host.docker.internal'
32
+ }
33
+
34
+ // If run directly, output the host
35
+ if (import.meta.main) {
36
+ console.info(getDockerHost())
37
+ }
@@ -0,0 +1,46 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { loadEnv } from './env-load'
4
+ import { getDockerHost } from './get-docker-host'
5
+
6
+ export async function getTestEnv() {
7
+ const dockerHost = getDockerHost()
8
+ const devEnv = await loadEnv('development')
9
+
10
+ // import src/env.ts for server env fallback values
11
+ const envModule = await import(join(process.cwd(), 'src/env.ts'))
12
+ const serverEnvFallback = envModule.server || {}
13
+
14
+ // ports come from VITE_PORT_* in .env.development (loaded by loadEnv)
15
+ const dbPort = process.env.VITE_PORT_POSTGRES || '5433'
16
+ const appPort = process.env.VITE_PORT_WEB || '8081'
17
+ const minioPort = process.env.VITE_PORT_MINIO || '9200'
18
+
19
+ const dockerDbBase = `postgresql://user:password@127.0.0.1:${dbPort}`
20
+
21
+ return {
22
+ ...serverEnvFallback,
23
+ ...devEnv,
24
+ CI: 'true',
25
+ // Child test processes may import src/env.ts directly. Keep them pinned to
26
+ // development mode even when the parent release flow started from
27
+ // production-flavored env.
28
+ TAKEOUT_ENV_MODE: 'development',
29
+ ...(!process.env.DEBUG_BACKEND && {
30
+ ZERO_LOG_LEVEL: 'warn',
31
+ }),
32
+ DO_NOT_TRACK: '1',
33
+ // force localhost urls so cross-subdomain cookies don't activate in test
34
+ BETTER_AUTH_URL: `http://localhost:${appPort}`,
35
+ ONE_SERVER_URL: `http://localhost:${appPort}`,
36
+ ZERO_MUTATE_URL: `http://${dockerHost}:${appPort}/api/zero/push`,
37
+ ZERO_QUERY_URL: `http://${dockerHost}:${appPort}/api/zero/pull`,
38
+ ZERO_UPSTREAM_DB: `${dockerDbBase}/postgres`,
39
+ ZERO_CVR_DB: `${dockerDbBase}/zero_cvr`,
40
+ ZERO_CHANGE_DB: `${dockerDbBase}/zero_cdb`,
41
+ CLOUDFLARE_R2_ENDPOINT: `http://127.0.0.1:${minioPort}`,
42
+ CLOUDFLARE_R2_PUBLIC_URL: `http://127.0.0.1:${minioPort}`,
43
+ CLOUDFLARE_R2_ACCESS_KEY: 'minio',
44
+ CLOUDFLARE_R2_SECRET_KEY: 'minio_password',
45
+ }
46
+ }