galbe 0.15.5 → 0.16.0

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/src/validator.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { InternalServerError, type STResponse } from './index'
1
+ import { InternalServerError, type STResponse, type STResponseEntry } from './index'
2
2
  import type {
3
3
  STSchema,
4
4
  STProps,
@@ -13,8 +13,34 @@ import type {
13
13
  STString,
14
14
  STObject,
15
15
  } from './schema'
16
- import { Kind, Optional, Stream } from './schema'
17
- import { isIterator } from './util'
16
+ import { Kind, Optional, Stream, NUMBER_FORMAT_RANGES } from './schema'
17
+ import { isIterator, responseEntryFor } from './util'
18
+ import { runCompiled } from './validator.compile'
19
+
20
+ /**
21
+ * JSON Schema's `multipleOf`: the quotient must be a whole number. Compared with
22
+ * a relative tolerance because `0.3 / 0.1` is `2.9999999999999996` in binary
23
+ * floating point — an exact `%` test would make the keyword unusable on the
24
+ * decimals it is most often written for.
25
+ */
26
+ export const isMultipleOf = (value: number, multipleOf: number): boolean => {
27
+ if (!(multipleOf > 0)) return true
28
+ const q = value / multipleOf
29
+ if (!Number.isFinite(q)) return false
30
+ return Math.abs(q - Math.round(q)) <= 1e-9 * Math.max(1, Math.abs(q))
31
+ }
32
+
33
+ // Renders a value for inclusion in an error message, capped so large
34
+ // attacker-controlled input cannot be reflected verbatim into a 400 response.
35
+ export const preview = (value: any): string => {
36
+ let str: string
37
+ try {
38
+ str = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value))
39
+ } catch {
40
+ str = String(value)
41
+ }
42
+ return str.length > 100 ? `${str.slice(0, 100)}…` : str
43
+ }
18
44
 
19
45
  export const validate = (elt: any, schema: STSchema, opt?: { parse?: boolean }): any => {
20
46
  type ValidationError = string | string[] | { [key: string]: ValidationError }
@@ -22,7 +48,7 @@ export const validate = (elt: any, schema: STSchema, opt?: { parse?: boolean }):
22
48
  const iElt = elt
23
49
 
24
50
  if (schema[Kind] === 'null') {
25
- if (elt !== null) throw `Expected null value got ${iElt}`
51
+ if (elt !== null) throw `Expected null value got ${preview(iElt)}`
26
52
  } else if (schema[Kind] === 'boolean') {
27
53
  if (typeof elt === 'string') {
28
54
  if (opt?.parse) elt = elt === 'true' ? true : elt === 'false' ? false : null
@@ -44,7 +70,7 @@ export const validate = (elt: any, schema: STSchema, opt?: { parse?: boolean }):
44
70
  schemaValidation(elt, schema)
45
71
  } else if (schema[Kind] === 'literal') {
46
72
  const lit = schema as STLiteral
47
- if (elt !== lit.value) throw `Not a valid value. Found "${elt}" but expected "${lit.value}"`
73
+ if (elt !== lit.value) throw `Not a valid value. Found "${preview(elt)}" but expected "${lit.value}"`
48
74
  } else if (schema[Kind] === 'object') {
49
75
  if (opt?.parse && typeof elt === 'string') {
50
76
  try {
@@ -53,11 +79,12 @@ export const validate = (elt: any, schema: STSchema, opt?: { parse?: boolean }):
53
79
  throw `Not a valid object`
54
80
  }
55
81
  }
56
- if (typeof elt !== 'object') throw `Not a valid object`
82
+ if (elt === null || typeof elt !== 'object') throw `Not a valid object`
57
83
  if (Array.isArray(elt)) throw `Expected an object, not an array`
58
84
  const err: ValidationError = {}
59
- Object.entries((schema as STObject).props ?? {}).forEach(([k, s]) => {
60
- if (elt === null || !(k in elt)) {
85
+ const props = (schema as STObject).props ?? {}
86
+ Object.entries(props).forEach(([k, s]) => {
87
+ if (!(k in elt)) {
61
88
  if (!s?.[Optional]) err[k] = 'Required'
62
89
  return
63
90
  }
@@ -67,6 +94,24 @@ export const validate = (elt: any, schema: STSchema, opt?: { parse?: boolean }):
67
94
  err[k] = e as ValidationError
68
95
  }
69
96
  })
97
+ // `additionalProperties` governs everything the props map does not declare:
98
+ // a schema validates each of them, `false` rejects them outright.
99
+ const ap = (schema as STObject).additionalProperties
100
+ if (ap !== undefined) {
101
+ for (const k of Object.keys(elt)) {
102
+ // hasOwn, not `in`: a payload key named `toString` must not read as declared
103
+ if (Object.hasOwn(props, k)) continue
104
+ if (ap === false) {
105
+ err[k] = 'Unexpected property'
106
+ continue
107
+ }
108
+ try {
109
+ validate(elt[k], ap, opt)
110
+ } catch (e) {
111
+ err[k] = e as ValidationError
112
+ }
113
+ }
114
+ }
70
115
  if (Object.keys(err).length) errors.push(err)
71
116
  } else if (schema[Kind] === 'json') {
72
117
  elt = validate(elt, (schema as STJson).value, opt)
@@ -115,21 +160,17 @@ export const validate = (elt: any, schema: STSchema, opt?: { parse?: boolean }):
115
160
  }
116
161
 
117
162
  export const validateResponse = (response: any, schema: STResponse, status: number) => {
118
- const entry = schema?.[status] ?? schema?.['default']
163
+ const entry = responseEntryFor(schema as Partial<Record<string | number, STResponseEntry>>, status)
119
164
  if (!entry) return
120
165
  let s: STSchema | undefined
121
166
  if ((entry as STSchema)[Kind]) {
122
167
  s = entry as STSchema
123
168
  } else {
124
169
  const c = entry as any
125
- if (response instanceof Uint8Array)
126
- s = c['application/octet-stream'] ?? c['*/*']
127
- else if (typeof response === 'string')
128
- s = c['text/plain'] ?? c['application/json'] ?? c['*/*']
129
- else if (response !== null && typeof response === 'object')
130
- s = c['application/json'] ?? c['*/*']
131
- else
132
- s = c['*/*']
170
+ if (response instanceof Uint8Array) s = c['application/octet-stream'] ?? c['*/*']
171
+ else if (typeof response === 'string') s = c['text/plain'] ?? c['application/json'] ?? c['*/*']
172
+ else if (response !== null && typeof response === 'object') s = c['application/json'] ?? c['*/*']
173
+ else s = c['*/*']
133
174
  }
134
175
  if (!s) return
135
176
  if (response instanceof ReadableStream) {
@@ -138,7 +179,7 @@ export const validateResponse = (response: any, schema: STResponse, status: numb
138
179
  if (!s[Stream]) throw new InternalServerError(`Expected ${s[Kind]} response, but got Iterator`)
139
180
  } else {
140
181
  try {
141
- validate(response, s)
182
+ runCompiled(response, s)
142
183
  } catch (error) {
143
184
  throw new InternalServerError({ ResponseValidationError: error })
144
185
  }
@@ -155,6 +196,11 @@ const schemaValidation = (value: any, schema: STSchema) => {
155
196
  if ((value as number) >= n.exclusiveMax) errors.push(`Is greater or equal to ${n.exclusiveMax}`)
156
197
  if (n.min !== undefined) if ((value as number) < n.min) errors.push(`Is less than ${n.min}`)
157
198
  if (n.max !== undefined) if ((value as number) > n.max) errors.push(`Is greater than ${n.max}`)
199
+ if (n.multipleOf !== undefined && !isMultipleOf(value as number, n.multipleOf))
200
+ errors.push(`Is not a multiple of ${n.multipleOf}`)
201
+ const range = n.format ? NUMBER_FORMAT_RANGES[n.format] : undefined
202
+ if (range && ((value as number) < range[0] || (value as number) > range[1]))
203
+ errors.push(`Is out of ${n.format} range`)
158
204
  } else if (schema[Kind] === 'string') {
159
205
  const str = schema as STString
160
206
  if (str.minLength !== undefined && (value as string).length < str.minLength)
@@ -1,200 +0,0 @@
1
- export type GalbeClientConfig = {
2
- server?: { url?: string }
3
- }
4
-
5
- export const Kind = Symbol.for('json.string')
6
- type Json<T> = { T: T }
7
-
8
- interface GR<S extends number | 'default' = 'default', B = any, OKS extends number = OKStatusCode> {
9
- status: Exclude<S, 'default'>
10
- ok: S extends OKS ? true : false
11
- redirected: boolean
12
- statusText: string
13
- type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'
14
- url: string
15
- headers: Headers
16
- body: <ST extends boolean = false>(
17
- stream?: ST
18
- ) => B extends Uint8Array
19
- ? ST extends true
20
- ? Promise<AsyncGenerator<Uint8Array, void, unknown>>
21
- : B extends Json<infer T>
22
- ? Promise<T>
23
- : Promise<B>
24
- : B extends string
25
- ? ST extends true
26
- ? Promise<AsyncGenerator<string, void, unknown>>
27
- : B extends Json<infer T>
28
- ? Promise<T>
29
- : Promise<B>
30
- : B extends Json<infer T>
31
- ? Promise<T>
32
- : Promise<B>
33
- }
34
-
35
- export type OKStatusCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226
36
- // prettier-ignore
37
- export type HttpStatusCode = 100 | 101 | 102 | 103 | OKStatusCode | 300 | 301 | 302 | 303 | 304 | 305 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511
38
- type PGR<
39
- S extends number | 'default' = 'default',
40
- B = any,
41
- O extends number = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226
42
- > = Promise<GR<S, B, O>>
43
-
44
- type ContentType = 'byteArray' | 'text' | 'json' | 'urlForm' | 'multipart' | 'default'
45
- type RequestOptions<
46
- H = any,
47
- Q = any,
48
- B extends Partial<Record<ContentType, any>> = Partial<Record<ContentType, any>>,
49
- C extends keyof B = keyof B
50
- > = {
51
- method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'
52
- headers?: H
53
- query?: Q
54
- contentType?: C
55
- body?: B[C]
56
- }
57
-
58
- const decoder = new TextDecoder()
59
- const DEFAULT_HEADERS = {
60
- 'user-agent': 'Galbe//*%(()=>version)()%*/',
61
- }
62
-
63
- const formdata = (data: Record<string, string | string[] | Blob>): FormData => {
64
- const form = new FormData()
65
- for (const [k, v] of Object.entries(data)) {
66
- if (Array.isArray(v)) for (const v2 of v) form.append(String(k), String(v2))
67
- else form.append(String(k), String(v))
68
- }
69
- return form
70
- }
71
-
72
- // Typescript types
73
- /*%
74
- Object.entries(types).map(([tk, t])=>{
75
- return `export type ${tk} = ${t}`
76
- })
77
- %*/
78
-
79
- export default class GalbeClient {
80
- /*%
81
- Object.entries(routes).map(([method, list])=>{
82
- return`${method} = {\n${list.map( r => {
83
- let p = Object.entries(r.params)
84
- let schemas = Object.keys(r.schemas).length ?
85
- `<${r.schemas.headers??'any'},${r.schemas.query??'any'},${r.schemas.body??'any'},CT>`:
86
- ''
87
- let oks = Object.keys(r.schemas?.response||{}).filter(s=>s>=200&&s<300)
88
- let responses = Object.keys(r.schemas?.response||{}).length ?
89
- `${Object.entries(r.schemas.response).filter(([s,_])=>s!=='"default"').map(([k,v])=>`PGR<${k},${v}${oks?.length?`,${oks.join('|')}`:''}>`).join('|')}|PGR<Exclude<HttpStatusCode,${Object.keys(r.schemas.response).filter(s=>s!=='"default"').join('|')}>,${'"default"' in r.schemas.response ? r.schemas.response['"default"'] : 'any'}${oks?.length?`,${oks.join('|')}`:''}>`:
90
- `PGR<HttpStatusCode,any${oks?.length?`,${oks.join('|')}`:',any'}>`
91
- return ` '${r.path}':<CT extends ${r.contentTypes}>(${p.length?p.map(([k,v])=>`${k}:${v.type}`).join(',')+', ':''}options:RequestOptions${schemas}={})=>this.fetch(\`${r.pathT}\`,{...options,method:'${r.method.toUpperCase()}'}) as ${responses}`
92
- }).join(',\n')}\n}`
93
- }).join('\n')
94
- %*/
95
-
96
- constructor(private readonly config?: GalbeClientConfig) {}
97
-
98
- async fetch(path: string, options: RequestOptions) {
99
- let url = `${this?.config?.server?.url ?? ''}${path}`
100
- const params = new URLSearchParams(options?.query || {})
101
- url = `${url}?${params.toString()}`
102
- let res = await fetch(url, {
103
- method: options?.method || 'GET',
104
- headers: {
105
- ...DEFAULT_HEADERS,
106
- ...(options?.contentType && ['byteArray', 'text', 'json', 'urlForm'].includes(options.contentType)
107
- ? {
108
- 'content-type': {
109
- byteArray: 'application/octet-stream',
110
- text: 'text/plain',
111
- json: 'application/json',
112
- urlForm: 'application/x-www-form-urlencoded',
113
- }[options.contentType as 'byteArray' | 'text' | 'json' | 'urlForm'],
114
- }
115
- : {}),
116
- ...(options?.headers || {}),
117
- },
118
- ...(options?.body
119
- ? { body: options?.contentType === 'multipart' ? formdata(options?.body) : JSON.stringify(options.body) }
120
- : {}),
121
- })
122
- return {
123
- headers: res.headers,
124
- ok: res.ok,
125
- redirected: res.redirected,
126
- status: res.status,
127
- statusText: res.statusText,
128
- type: res.type,
129
- url: res.url,
130
- body: async (stream = false) => {
131
- if (res.headers.get('content-type') === 'application/json') return res.json()
132
- else if (res.headers.get('content-type') === 'text/event-stream') {
133
- const reader = res.body?.getReader()
134
- return async function* () {
135
- while (reader) {
136
- const { value, done } = await reader.read()
137
- if (done) break
138
- yield decoder.decode(value)
139
- }
140
- }
141
- } else if (res.headers.get('content-type')?.match(/^text\//)) {
142
- if (stream) {
143
- const reader = res.body?.getReader()
144
- return (async function* () {
145
- while (reader) {
146
- const { value, done } = await reader.read()
147
- if (done) break
148
- yield decoder.decode(value)
149
- }
150
- })()
151
- } else return res.text()
152
- } else if (res.headers.get('content-type') === 'application/octet-stream') {
153
- const reader = res.body?.getReader()
154
- if (stream) {
155
- return (async function* () {
156
- while (reader) {
157
- const { value, done } = await reader.read()
158
- if (done) break
159
- yield value
160
- }
161
- })()
162
- } else {
163
- let body = new Uint8Array()
164
- while (reader) {
165
- const { value, done } = await reader.read()
166
- if (done) break
167
- let buff = new Uint8Array(body.length + value.length)
168
- buff.set(body)
169
- buff.set(value, body.length)
170
- body = buff
171
- }
172
- return body
173
- }
174
- }
175
- return res.body
176
- },
177
- }
178
- }
179
-
180
- // Aliases
181
- /*%
182
- Object.entries(routes).map(([method, list])=>{
183
- return list.filter(r=>r.alias).map(r => {
184
- let p = Object.entries(r.params)
185
- let schemas = Object.keys(r.schemas).length ?
186
- `<${r.schemas.headers??'any'},${r.schemas.query??'any'},${r.schemas.body??'any'},CT>`:
187
- ''
188
- let oks = Object.keys(r.schemas?.response||{}).filter(s=>s>=200&&s<300)
189
- let responses = Object.keys(r.schemas?.response||{}).length ?
190
- `${Object.entries(r.schemas.response).filter(([s,_])=>s!=='"default"').map(([k,v])=>`PGR<${k},${v}${oks?.length?`,${oks.join('|')}`:''}>`).join('|')}|PGR<Exclude<HttpStatusCode,${Object.keys(r.schemas.response).filter(s=>s!=='"default"').join('|')}>,${'"default"' in r.schemas.response ? r.schemas.response['"default"'] : 'any'}${oks?.length?`,${oks.join('|')}`:''}>`:
191
- `PGR<HttpStatusCode,any${oks?.length?`,${oks.join('|')}`:',any'}>`
192
- let summary = r.summary ? ` * ${r.summary || ''}\n *\n` : ''
193
- let description = r.description ? ` * ${r.description.replace(/\n/g,'\n * ')}` : ''
194
- let params = Object.entries(r.schema.params || {}).map( ([k,v])=>`\n * @param ${k} - ${v.description?.replace(/\n/g,'\n ')}` ).join('')
195
- let query = Object.entries(r.schema.query || {}).map( ([k,v])=>`\n * @param options.query.${k} - ${v.description?.replace(/\n/g,'\n ')}` ).join('')
196
- return `/**\n${summary}${description}\n *${params}${query}\n *\/\n ${r.alias}<CT extends ${r.contentTypes}>(${p.length ? p.map(([k,v])=>`${k}: ${v.type}`).join(', ')+', ':''}options: RequestOptions${schemas} = {}){return this.fetch(\`${r.pathT}\`, {...options, method: '${r.method.toUpperCase()}'}) as ${responses}}\n`
197
- }).join(' ')
198
- })
199
- %*/
200
- }
@@ -1,196 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- import { $ } from 'bun'
4
- import { existsSync } from 'fs'
5
-
6
- const PKG_PATH = new URL('../package.json', import.meta.url).pathname
7
- const CHANGELOG_PATH = new URL('../CHANGELOG.md', import.meta.url).pathname
8
- const REMOTE = 'git@github.com:pierre-cm/galbe.git'
9
-
10
- type BumpType = 'major' | 'minor' | 'patch'
11
- type CommitKind = 'breaking' | 'feat' | 'fix'
12
-
13
- interface Commit {
14
- kind: CommitKind
15
- scope?: string
16
- message: string
17
- }
18
-
19
- // --- arg parsing ---
20
-
21
- const argv = process.argv.slice(2)
22
- const isCI = argv.includes('--ci')
23
- const scopeIdx = argv.indexOf('--scope')
24
- const scopeArg = scopeIdx !== -1 ? argv[scopeIdx + 1] : null
25
-
26
- if (scopeArg && !['patch', 'minor', 'major'].includes(scopeArg)) {
27
- console.error(`error: --scope must be patch, minor, or major`)
28
- process.exit(1)
29
- }
30
-
31
- // --- helpers ---
32
-
33
- function classify(subject: string): Commit | null {
34
- if (/^(Release \d|Merge branch)/.test(subject)) return null
35
-
36
- if (subject.includes('BREAKING CHANGE') || /^[^:]+!:/.test(subject))
37
- return { kind: 'breaking', message: subject.replace(/^[^:]+!?:\s*/, '') }
38
-
39
- const feat = subject.match(/^feat(?:\(([^)]+)\))?:\s*(.+)/)
40
- if (feat) return { kind: 'feat', scope: feat[1], message: feat[2] }
41
-
42
- const fix = subject.match(/^fix(?:\(([^)]+)\))?:\s*(.+)/)
43
- if (fix) return { kind: 'fix', scope: fix[1], message: fix[2] }
44
-
45
- return null
46
- }
47
-
48
- function bump(version: string, type: BumpType): string {
49
- const [maj, min, pat] = version.split('.').map(Number)
50
- if (type === 'major') return `${maj + 1}.0.0`
51
- if (type === 'minor') return `${maj}.${min + 1}.0`
52
- return `${maj}.${min}.${pat + 1}`
53
- }
54
-
55
- function suggest(commits: Commit[], preRelease: boolean): BumpType {
56
- if (!preRelease && commits.some(c => c.kind === 'breaking')) return 'major'
57
- if (commits.some(c => c.kind === 'breaking') || commits.some(c => c.kind === 'feat')) return 'minor'
58
- return 'patch'
59
- }
60
-
61
- function formatEntry(version: string, commits: Commit[], date: string): string {
62
- const sections: string[] = []
63
- const fmt = (c: Commit) => `- ${c.scope ? `**${c.scope}**: ` : ''}${c.message}`
64
-
65
- const breaking = commits.filter(c => c.kind === 'breaking')
66
- const feats = commits.filter(c => c.kind === 'feat')
67
- const fixes = commits.filter(c => c.kind === 'fix')
68
-
69
- if (breaking.length) sections.push(`### Breaking Changes\n${breaking.map(fmt).join('\n')}`)
70
- if (feats.length) sections.push(`### Features\n${feats.map(fmt).join('\n')}`)
71
- if (fixes.length) sections.push(`### Fixes\n${fixes.map(fmt).join('\n')}`)
72
-
73
- return `## ${version} — ${date}\n\n${sections.join('\n\n')}`
74
- }
75
-
76
- function prependChangelog(entry: string, existing: string): string {
77
- if (!existing) return `# Changelog\n\n${entry}\n`
78
- if (existing.startsWith('# Changelog')) {
79
- const body = existing.replace(/^# Changelog\s*/, '')
80
- return `# Changelog\n\n${entry}\n\n${body}`
81
- }
82
- return `# Changelog\n\n${entry}\n\n${existing}`
83
- }
84
-
85
- // --- main ---
86
-
87
- async function main() {
88
- if (!isCI) {
89
- const branch = (await $`git rev-parse --abbrev-ref HEAD`.quiet().text()).trim()
90
- if (branch !== 'main') {
91
- console.error(`error: must be on main branch (currently on '${branch}')`)
92
- process.exit(1)
93
- }
94
-
95
- const dirty = (await $`git status --porcelain`.quiet().text()).trim()
96
- if (dirty) {
97
- console.error('error: working tree has uncommitted changes')
98
- process.exit(1)
99
- }
100
- }
101
-
102
- const lastTag = await $`git describe --tags --abbrev=0`
103
- .quiet()
104
- .text()
105
- .catch(() => '')
106
- .then(t => t.trim())
107
-
108
- const logRange = lastTag ? `${lastTag}..HEAD` : 'HEAD'
109
- const rawLog = (await $`git log --pretty=format:%s ${logRange}`.quiet().text()).trim()
110
-
111
- const commits = rawLog ? (rawLog.split('\n').map(classify).filter(Boolean) as Commit[]) : []
112
-
113
- const pkg = await Bun.file(PKG_PATH).json()
114
- const current: string = pkg.version
115
- const preRelease = current.startsWith('0.')
116
-
117
- console.log(`\ncurrent: ${current}${lastTag ? ` (since tag ${lastTag})` : ''}`)
118
-
119
- if (!commits.length && !scopeArg) {
120
- console.log('no feat/fix commits since last release — nothing to do.')
121
- process.exit(0)
122
- }
123
-
124
- let next: string
125
-
126
- if (scopeArg) {
127
- // CI / non-interactive: scope is given directly
128
- next = bump(current, scopeArg as BumpType)
129
- console.log(`scope: ${scopeArg} → ${next}`)
130
- } else {
131
- // Interactive
132
- const breaking = commits.filter(c => c.kind === 'breaking')
133
- const feats = commits.filter(c => c.kind === 'feat')
134
- const fixes = commits.filter(c => c.kind === 'fix')
135
-
136
- if (breaking.length) { console.log('\n breaking:'); breaking.forEach(c => console.log(` - ${c.message}`)) }
137
- if (feats.length) { console.log('\n features:'); feats.forEach(c => console.log(` - ${c.scope ? `[${c.scope}] ` : ''}${c.message}`)) }
138
- if (fixes.length) { console.log('\n fixes:'); fixes.forEach(c => console.log(` - ${c.scope ? `[${c.scope}] ` : ''}${c.message}`)) }
139
-
140
- const recommended = suggest(commits, preRelease)
141
- const versions = { patch: bump(current, 'patch'), minor: bump(current, 'minor'), major: bump(current, 'major') }
142
- const defaultChoice = recommended === 'patch' ? '1' : recommended === 'minor' ? '2' : '3'
143
-
144
- console.log(`\n 1) patch → ${versions.patch}${recommended === 'patch' ? ' ←' : ''}`)
145
- console.log(` 2) minor → ${versions.minor}${recommended === 'minor' ? ' ←' : ''}`)
146
- console.log(` 3) major → ${versions.major}${recommended === 'major' ? ' ←' : ''}`)
147
- console.log(` 4) custom`)
148
- if (preRelease) console.log(`\n note: ${current} is pre-release (0.x) — breaking changes capped to minor`)
149
-
150
- const choice = prompt(`\nbump type [${defaultChoice}]:`)?.trim() || defaultChoice
151
-
152
- if (choice === '1') next = versions.patch
153
- else if (choice === '2') next = versions.minor
154
- else if (choice === '3') next = versions.major
155
- else if (choice === '4') {
156
- next = prompt('version:')?.trim() ?? ''
157
- if (!/^\d+\.\d+\.\d+$/.test(next)) {
158
- console.error('error: invalid semver format')
159
- process.exit(1)
160
- }
161
- } else {
162
- console.error('error: invalid choice')
163
- process.exit(1)
164
- }
165
-
166
- const ok = prompt(`\nrelease ${next}? (y/N):`)?.trim().toLowerCase()
167
- if (ok !== 'y') { console.log('aborted.'); process.exit(0) }
168
- }
169
-
170
- console.log()
171
-
172
- const date = new Date().toISOString().split('T')[0]
173
- const entry = formatEntry(next, commits, date)
174
-
175
- const existing = existsSync(CHANGELOG_PATH) ? await Bun.file(CHANGELOG_PATH).text() : ''
176
- await Bun.write(CHANGELOG_PATH, prependChangelog(entry, existing))
177
- console.log('updated CHANGELOG.md')
178
-
179
- pkg.version = next
180
- await Bun.write(PKG_PATH, JSON.stringify(pkg, null, 2) + '\n')
181
- console.log(`bumped package.json → ${next}`)
182
-
183
- await $`git add package.json CHANGELOG.md`
184
- await $`git commit -m "Release ${next}"`
185
- await $`git tag ${next}`
186
- await $`git push ${REMOTE} main --tags`
187
- console.log(`pushed commit and tag ${next}`)
188
-
189
- await $`gh release create ${next} --title "Release ${next}" --notes ${entry}`
190
- console.log(`\ncreated GitHub release ${next}`)
191
- }
192
-
193
- main().catch(e => {
194
- console.error(e.message ?? e)
195
- process.exit(1)
196
- })