@o/scripts 0.16.19

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,342 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ import { cmd } from './cmd'
7
+
8
+ const PRESETS: Record<string, string[]> = {
9
+ takeout: ['.github/workflows/*.yml', 'src/uncloud/*.yml', '.env*'],
10
+ }
11
+
12
+ const IGNORE_DIRS = new Set([
13
+ 'node_modules',
14
+ '.git',
15
+ 'dist',
16
+ 'build',
17
+ '.next',
18
+ '.cache',
19
+ '.docker',
20
+ ])
21
+
22
+ await cmd`sync environment variables from src/env.ts to matching files`
23
+ .args('--preset string')
24
+ .run(async ({ args }) => {
25
+ // import env var definitions from src/env.ts (the single source of truth)
26
+ const envModule = await import(path.resolve('src/env.ts'))
27
+ const production = envModule.production as Record<string, string | symbol>
28
+ const versions = (envModule.versions || {}) as Record<string, string>
29
+
30
+ if (!production || typeof production !== 'object') {
31
+ console.error('no production export found in src/env.ts')
32
+ process.exit(1)
33
+ }
34
+
35
+ // the expected symbol from @o/env
36
+ const expectedSymbol = Symbol.for('take-out/env/expected')
37
+
38
+ // merge resolved versions into the var registry
39
+ const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'))
40
+ const depResolved = { ...versions }
41
+ for (const [key, value] of Object.entries(versions)) {
42
+ production[key] = value
43
+ }
44
+
45
+ // normalize: expected symbol → required, string → defaultValue
46
+ type EnvEntry = { key: string; required: boolean; defaultValue: string }
47
+ const entries: EnvEntry[] = Object.entries(production)
48
+ .map(([key, value]) => ({
49
+ key,
50
+ required: value === expectedSymbol,
51
+ defaultValue: typeof value === 'string' ? value : '',
52
+ }))
53
+ .sort((a, b) => a.key.localeCompare(b.key))
54
+
55
+ // load .gitignore patterns
56
+ const gitignorePatterns: string[] = []
57
+ try {
58
+ const gi = fs.readFileSync('.gitignore', 'utf-8')
59
+ for (const line of gi.split('\n')) {
60
+ const trimmed = line.trim()
61
+ if (trimmed && !trimmed.startsWith('#')) {
62
+ gitignorePatterns.push(trimmed.replace(/\/$/, ''))
63
+ }
64
+ }
65
+ } catch {}
66
+
67
+ function isIgnored(filePath: string): boolean {
68
+ const parts = filePath.split('/')
69
+ for (const part of parts) {
70
+ if (IGNORE_DIRS.has(part)) return true
71
+ }
72
+ for (const pattern of gitignorePatterns) {
73
+ if (filePath.startsWith(pattern) || parts.some((p) => p === pattern)) {
74
+ return true
75
+ }
76
+ }
77
+ return false
78
+ }
79
+
80
+ // simple glob: supports *.ext, dir/*.ext, dir/**/*.ext, .prefix*
81
+ function expandPattern(pattern: string): string[] {
82
+ const results: string[] = []
83
+
84
+ if (!pattern.includes('*')) {
85
+ if (fs.existsSync(pattern)) results.push(pattern)
86
+ return results
87
+ }
88
+
89
+ const isRecursive = pattern.includes('**')
90
+ const parts = pattern.split('**/')
91
+ const baseDir = parts.length > 1 ? parts[0]!.replace(/\/$/, '') || '.' : '.'
92
+ const filePattern =
93
+ parts.length > 1
94
+ ? parts[1]!
95
+ : pattern.includes('/')
96
+ ? pattern.split('/').pop()!
97
+ : pattern
98
+
99
+ // for non-recursive patterns with a dir prefix like "src/uncloud/*.yml"
100
+ const nonRecursiveDir =
101
+ !isRecursive && pattern.includes('/')
102
+ ? pattern.slice(0, pattern.lastIndexOf('/'))
103
+ : null
104
+
105
+ const matchRe = new RegExp(
106
+ '^' + filePattern.replace(/\./g, '\\.').replace(/\*/g, '[^/]*') + '$'
107
+ )
108
+
109
+ function walk(dir: string) {
110
+ let dirEntries: fs.Dirent[]
111
+ try {
112
+ dirEntries = fs.readdirSync(dir, { withFileTypes: true })
113
+ } catch {
114
+ return
115
+ }
116
+ for (const entry of dirEntries) {
117
+ const full = dir === '.' ? entry.name : `${dir}/${entry.name}`
118
+ if (isRecursive && isIgnored(full)) continue
119
+ if (entry.isDirectory()) {
120
+ if (isRecursive) walk(full)
121
+ } else if (matchRe.test(entry.name)) {
122
+ results.push(full)
123
+ }
124
+ }
125
+ }
126
+
127
+ walk(nonRecursiveDir || baseDir)
128
+ return results
129
+ }
130
+
131
+ // resolve file list: --preset > CLI args > package.json config > preset:takeout
132
+ let patterns: string[]
133
+ if (args.preset) {
134
+ const preset = PRESETS[args.preset]
135
+ if (!preset) {
136
+ console.error(
137
+ `Unknown preset: ${args.preset}\nAvailable: ${Object.keys(PRESETS).join(', ')}`
138
+ )
139
+ process.exit(1)
140
+ }
141
+ patterns = preset
142
+ } else if (args.rest.length) {
143
+ patterns = args.rest
144
+ } else {
145
+ patterns = packageJson.envUpdateFiles || PRESETS.takeout!
146
+ }
147
+
148
+ // expand all patterns
149
+ const files: string[] = []
150
+ for (const p of patterns) {
151
+ files.push(...expandPattern(p))
152
+ }
153
+
154
+ // ensure .env is always processed (may not exist yet for fresh projects)
155
+ if (!files.includes('.env')) {
156
+ files.push('.env')
157
+ }
158
+
159
+ // markers
160
+ const markerStart = '🔒 start - this is generated by "bun env:update"'
161
+ const markerEnd = '🔒 end - this is generated by "bun env:update"'
162
+ const yamlStart = `# ${markerStart}`
163
+ const yamlEnd = `# ${markerEnd}`
164
+ // .env auto-generated section markers
165
+ const dotenvSectionStart = '# ---- BEGIN AUTO-GENERATED (DO NOT EDIT) ----'
166
+ const dotenvSectionEnd = '# ---- END AUTO-GENERATED ----'
167
+ const typescriptArrayStart = '// 🔒 env array start - generated by "bun env:update"'
168
+ const typescriptArrayEnd = '// 🔒 env array end - generated by "bun env:update"'
169
+
170
+ type Strategy =
171
+ | 'yaml-markers'
172
+ | 'dotenv-section'
173
+ | 'dotenv-inline'
174
+ | 'typescript-array-markers'
175
+
176
+ function detectStrategy(filePath: string, content: string): Strategy | null {
177
+ if (content.includes(yamlStart) && content.includes(yamlEnd)) {
178
+ return 'yaml-markers'
179
+ }
180
+ if (
181
+ content.includes(typescriptArrayStart) &&
182
+ content.includes(typescriptArrayEnd)
183
+ ) {
184
+ return 'typescript-array-markers'
185
+ }
186
+ const basename = path.basename(filePath)
187
+ if (basename === '.env') {
188
+ return 'dotenv-section'
189
+ }
190
+ if (/^\.env/.test(basename) && Object.keys(depResolved).length > 0) {
191
+ return 'dotenv-inline'
192
+ }
193
+ return null
194
+ }
195
+
196
+ function escapeRegExp(s: string): string {
197
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
198
+ }
199
+
200
+ function replaceMarkerSection(
201
+ content: string,
202
+ startMarker: string,
203
+ endMarker: string,
204
+ newSection: string
205
+ ): string {
206
+ const re = new RegExp(
207
+ `^([ \\t]*)${escapeRegExp(startMarker)}(.|\n)*?${escapeRegExp(endMarker)}`,
208
+ 'gm'
209
+ )
210
+ return content.replace(re, (_match, indent) => {
211
+ return `${indent}${startMarker}\n${newSection}\n${indent}${endMarker}`
212
+ })
213
+ }
214
+
215
+ function applyYamlMarkers(filePath: string, content: string): string {
216
+ const markerMatch = content.match(
217
+ new RegExp(`^(\\s*)${escapeRegExp(yamlStart)}`, 'm')
218
+ )
219
+ const indent = markerMatch?.[1] || ' '
220
+
221
+ const beforeMarker = content.slice(0, content.indexOf(yamlStart))
222
+ const isMap = /x-[\w-]+:.*&[\w-]+/s.test(beforeMarker)
223
+ const isGithubActions = filePath.includes('.github/workflows')
224
+
225
+ const envLines = entries
226
+ .map(({ key, defaultValue }) => {
227
+ const dep = depResolved[key]
228
+ const val = dep || defaultValue
229
+
230
+ if (isGithubActions) {
231
+ // deps-derived versions track package.json — emit a literal so a
232
+ // stale repo secret can never shadow them (a stale ZERO_VERSION
233
+ // secret once pinned an old zero-cache protocol against a newer
234
+ // client). matches the .env codegen below, which already does this.
235
+ return dep
236
+ ? `${indent}${key}: '${dep}'`
237
+ : `${indent}${key}: \${{ secrets.${key} }}`
238
+ }
239
+
240
+ return isMap
241
+ ? `${indent}${key}: \${${key}:-${val}}`
242
+ : `${indent}- ${key}=\${${key}:-${val}}`
243
+ })
244
+ .join('\n')
245
+
246
+ return replaceMarkerSection(content, yamlStart, yamlEnd, envLines)
247
+ }
248
+
249
+ function applyDotenvSection(_filePath: string, content: string): string {
250
+ const lines: string[] = [dotenvSectionStart]
251
+
252
+ for (const { key, required, defaultValue } of entries) {
253
+ const dep = depResolved[key]
254
+ if (dep) {
255
+ lines.push(`${key}=${dep}`)
256
+ } else if (!required && defaultValue !== '') {
257
+ lines.push(`${key}=${defaultValue}`)
258
+ }
259
+ }
260
+
261
+ lines.push(dotenvSectionEnd)
262
+ const section = lines.join('\n')
263
+
264
+ const beginIndex = content.indexOf(dotenvSectionStart)
265
+ const endIndex = content.indexOf(dotenvSectionEnd)
266
+
267
+ if (beginIndex !== -1 && endIndex !== -1 && endIndex > beginIndex) {
268
+ const before = content.substring(0, beginIndex).trimEnd()
269
+ const after = content.substring(endIndex + dotenvSectionEnd.length).trimStart()
270
+ return [before, section, after].filter(Boolean).join('\n\n')
271
+ }
272
+
273
+ // no existing section — append
274
+ const trimmed = content.trimEnd()
275
+ return trimmed ? `${trimmed}\n\n${section}` : section
276
+ }
277
+
278
+ function applyDotenvInline(_filePath: string, content: string): string {
279
+ let result = content
280
+ for (const [key, value] of Object.entries(depResolved)) {
281
+ const re = new RegExp(`^(${key})=(.+)$`, 'm')
282
+ const match = result.match(re)
283
+ if (match && match[2] !== value) {
284
+ result = result.replace(re, `$1=${value}`)
285
+ }
286
+ }
287
+ return result
288
+ }
289
+
290
+ function applyTypescriptArrayMarkers(_filePath: string, content: string): string {
291
+ const markerMatch = content.match(
292
+ new RegExp(`^(\\s*)${escapeRegExp(typescriptArrayStart)}`, 'm')
293
+ )
294
+ const indent = markerMatch?.[1] || ' '
295
+ const envLines = entries.map(({ key }) => `${indent}'${key}',`).join('\n')
296
+ return replaceMarkerSection(
297
+ content,
298
+ typescriptArrayStart,
299
+ typescriptArrayEnd,
300
+ envLines
301
+ )
302
+ }
303
+
304
+ const strategies: Record<Strategy, (filePath: string, content: string) => string> = {
305
+ 'yaml-markers': applyYamlMarkers,
306
+ 'dotenv-section': applyDotenvSection,
307
+ 'dotenv-inline': applyDotenvInline,
308
+ 'typescript-array-markers': applyTypescriptArrayMarkers,
309
+ }
310
+
311
+ let updated = 0
312
+
313
+ for (const filePath of files) {
314
+ let content = ''
315
+ try {
316
+ content = fs.readFileSync(filePath, 'utf-8')
317
+ } catch {
318
+ // allow .env to be created from scratch
319
+ if (path.basename(filePath) === '.env') {
320
+ content = ''
321
+ } else {
322
+ continue
323
+ }
324
+ }
325
+
326
+ const strategy = detectStrategy(filePath, content)
327
+ if (!strategy) continue
328
+
329
+ const newContent = strategies[strategy](filePath, content)
330
+ if (newContent !== content) {
331
+ fs.writeFileSync(filePath, newContent, 'utf-8')
332
+ console.info(`✅ ${filePath} (${strategy})`)
333
+ updated++
334
+ }
335
+ }
336
+
337
+ if (updated > 0) {
338
+ console.info(`\n✅ Updated ${updated} file${updated > 1 ? 's' : ''}`)
339
+ } else {
340
+ console.info('All files up to date')
341
+ }
342
+ })
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { ensureExists } from '@o/helpers'
4
+
5
+ import { cmd } from './cmd'
6
+
7
+ export async function execWithEnvironment(
8
+ environment: 'development' | 'production',
9
+ command: string
10
+ ) {
11
+ const { spawnSync } = await import('node:child_process')
12
+ const { loadEnv } = await import('./helpers/env-load')
13
+
14
+ const { USE_LOCAL_SERVER } = process.env
15
+
16
+ let envFileEnv: Record<string, string>
17
+
18
+ if (process.env.REMOTE) {
19
+ await import('./ensure-tunnel')
20
+ const { getEnvironment } = await import('./sst-get-environment')
21
+ envFileEnv = getEnvironment(process.env.REMOTE)
22
+ ensureExists(envFileEnv)
23
+ } else {
24
+ envFileEnv = await loadEnv(environment)
25
+ }
26
+
27
+ console.info(
28
+ `($): ${command} | env ${environment} ${USE_LOCAL_SERVER ? '| using local endpoints' : ''}`
29
+ )
30
+
31
+ const devEnv = USE_LOCAL_SERVER ? await loadEnv('development') : null
32
+
33
+ const env = {
34
+ ...envFileEnv,
35
+ ...process.env,
36
+ // running local server but hitting most prod endpoints except auth
37
+ ...(USE_LOCAL_SERVER && {
38
+ ONE_SERVER_URL: devEnv?.ONE_SERVER_URL,
39
+ // vite reads from .env.production for us unless defined into process.env
40
+ VITE_ZERO_HOSTNAME: 'start.chat',
41
+ }),
42
+ } as any as Record<string, string>
43
+
44
+ const parts = command.split(' ')
45
+ return spawnSync(parts[0]!, parts.slice(1), {
46
+ stdio: ['ignore', 'inherit', 'inherit'],
47
+ env,
48
+ })
49
+ }
50
+
51
+ if (import.meta.main) {
52
+ await cmd`execute command with environment variables loaded from local or remote`.run(
53
+ async () => {
54
+ const result = await execWithEnvironment(
55
+ (process.env.NODE_ENV as 'development' | 'production') || 'development',
56
+ process.argv.slice(3).join(' ')
57
+ )
58
+ process.exit(result.status ?? 1)
59
+ }
60
+ )
61
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ultra simple typed arg parsing
3
+ *
4
+ * @example
5
+ * const { port, verbose, rest } = args`--port number --verbose boolean`
6
+ * // port: number | undefined
7
+ * // verbose: boolean
8
+ * // rest: string[] - positional args that don't match any flag
9
+ */
10
+
11
+ type ParseType<T extends string> = T extends 'number'
12
+ ? number | undefined
13
+ : T extends 'boolean'
14
+ ? boolean
15
+ : T extends 'string'
16
+ ? string | undefined
17
+ : never
18
+
19
+ type ParseFlag<S extends string> = S extends `--${infer Name} ${infer Type}`
20
+ ? { [K in Name as KebabToCamel<K>]: ParseType<Type> }
21
+ : S extends `-${infer Name} ${infer Type}`
22
+ ? { [K in Name as KebabToCamel<K>]: ParseType<Type> }
23
+ : object
24
+
25
+ type KebabToCamel<S extends string> = S extends `${infer A}-${infer B}`
26
+ ? `${A}${Capitalize<KebabToCamel<B>>}`
27
+ : S
28
+
29
+ // trim leading/trailing whitespace from string type
30
+ type Trim<S extends string> = S extends ` ${infer R}`
31
+ ? Trim<R>
32
+ : S extends `${infer R} `
33
+ ? Trim<R>
34
+ : S extends `\n${infer R}`
35
+ ? Trim<R>
36
+ : S extends `${infer R}\n`
37
+ ? Trim<R>
38
+ : S
39
+
40
+ // split on -- allowing any whitespace between flags
41
+ type ParseFlags<S extends string> =
42
+ Trim<S> extends `${infer Flag} --${infer Rest}`
43
+ ? ParseFlag<Trim<Flag>> & ParseFlags<`--${Rest}`>
44
+ : Trim<S> extends `${infer Flag}\n--${infer Rest}`
45
+ ? ParseFlag<Trim<Flag>> & ParseFlags<`--${Rest}`>
46
+ : Trim<S> extends `${infer Flag} -${infer Rest}`
47
+ ? ParseFlag<Trim<Flag>> & ParseFlags<`-${Rest}`>
48
+ : Trim<S> extends `${infer Flag}\n-${infer Rest}`
49
+ ? ParseFlag<Trim<Flag>> & ParseFlags<`-${Rest}`>
50
+ : ParseFlag<Trim<S>>
51
+
52
+ // flatten intersection into single object for nice hover display
53
+ type Prettify<T> = { [K in keyof T]: T[K] } & {}
54
+
55
+ export type Args<S extends string> = Prettify<ParseFlags<S> & { rest: string[] }>
56
+
57
+ export function args<const S extends string>(spec: S): Args<S>
58
+ export function args<const S extends string>(strings: TemplateStringsArray | S): Args<S> {
59
+ const spec = typeof strings === 'string' ? strings : strings[0] || ''
60
+ const argv = process.argv.slice(2)
61
+
62
+ // parse spec: "--port number --verbose boolean"
63
+ const schema: Record<string, 'string' | 'number' | 'boolean'> = {}
64
+ const matches = spec.matchAll(/--?([a-z0-9-]+)\s+(string|number|boolean)/gi)
65
+
66
+ for (const [, flag, type] of matches) {
67
+ const camel = flag!.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())
68
+ schema[camel] = type as 'string' | 'number' | 'boolean'
69
+ }
70
+
71
+ const result: Record<string, string | number | boolean | undefined> = {}
72
+ const rest: string[] = []
73
+
74
+ for (let i = 0; i < argv.length; i++) {
75
+ const arg = argv[i]!
76
+
77
+ if (arg.startsWith('-')) {
78
+ const key = arg
79
+ .replace(/^--?/, '')
80
+ .replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())
81
+ const type = schema[key]
82
+
83
+ if (type === 'boolean') {
84
+ result[key] = true
85
+ } else if (type === 'string' || type === 'number') {
86
+ const val = argv[++i]
87
+ result[key] = type === 'number' ? Number(val) : val
88
+ } else {
89
+ rest.push(arg)
90
+ }
91
+ } else {
92
+ rest.push(arg)
93
+ }
94
+ }
95
+
96
+ return { ...result, rest } as Args<S>
97
+ }
@@ -0,0 +1,22 @@
1
+ import { Socket } from 'node:net'
2
+
3
+ export function checkPort(port: number, host = '127.0.0.1'): Promise<boolean> {
4
+ return new Promise((resolve) => {
5
+ const tester = new Socket()
6
+
7
+ tester.once('connect', () => {
8
+ tester.destroy()
9
+ resolve(true)
10
+ })
11
+
12
+ tester.once('error', (err: NodeJS.ErrnoException) => {
13
+ if (err.code === 'ECONNREFUSED') {
14
+ resolve(false)
15
+ } else {
16
+ resolve(true)
17
+ }
18
+ })
19
+
20
+ tester.connect(port, host)
21
+ })
22
+ }