@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.
- package/LICENSE +21 -0
- package/package.json +55 -0
- package/src/build-initial.ts +93 -0
- package/src/clean.ts +29 -0
- package/src/cmd.ts +105 -0
- package/src/dev-tunnel.ts +162 -0
- package/src/ensure-port.ts +79 -0
- package/src/ensure-tunnel.ts +19 -0
- package/src/ensure-zero-sqlite.ts +31 -0
- package/src/env-pull.ts +58 -0
- package/src/env-update.ts +342 -0
- package/src/exec-with-env.ts +61 -0
- package/src/helpers/args.ts +97 -0
- package/src/helpers/check-port.ts +22 -0
- package/src/helpers/deploy-health.ts +227 -0
- package/src/helpers/deploy-lock.ts +231 -0
- package/src/helpers/ensure-s3-bucket.ts +88 -0
- package/src/helpers/env-load.ts +43 -0
- package/src/helpers/get-docker-host.ts +37 -0
- package/src/helpers/get-test-env.ts +46 -0
- package/src/helpers/github-tail.ts +249 -0
- package/src/helpers/multipass.ts +174 -0
- package/src/helpers/parse-env-file.ts +71 -0
- package/src/helpers/process-compose-env.ts +57 -0
- package/src/helpers/resolve-dep-version.ts +11 -0
- package/src/helpers/run.ts +340 -0
- package/src/helpers/ssh.ts +50 -0
- package/src/helpers/uncloud-deploy.ts +162 -0
- package/src/helpers/uncloud.ts +197 -0
- package/src/helpers/wait-for-port.ts +33 -0
- package/src/helpers/zero-get-version.ts +8 -0
- package/src/release.ts +544 -0
- package/src/sst-get-environment.ts +38 -0
- package/src/typecheck.ts +21 -0
- package/src/up.ts +440 -0
- package/src/update-changelog.ts +92 -0
- package/src/wait-for-dev.ts +41 -0
|
@@ -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
|
+
}
|