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/README.md +3 -0
- package/bin/commands/build.ts +30 -19
- package/bin/commands/dev.ts +53 -5
- package/bin/commands/generate/cli/index.ts +4 -1
- package/bin/commands/generate/client.ts +61 -30
- package/bin/commands/generate/code/openapi.parser.ts +440 -163
- package/bin/commands/generate/code/route-merge.ts +26 -21
- package/bin/commands/generate/code.ts +15 -1
- package/bin/commands/generate/model.ts +4 -1
- package/bin/commands/generate/spec.ts +3 -1
- package/bin/res/client.runtime.ts +5 -0
- package/bin/util.ts +36 -90
- package/package.json +34 -9
- package/src/cookies.ts +29 -8
- package/src/extras/spec/openapi.serializer.ts +287 -99
- package/src/extras.ts +1 -1
- package/src/index.ts +378 -73
- package/src/middlewares/_auth.ts +178 -0
- package/src/middlewares/apiKey.ts +139 -0
- package/src/middlewares/basicAuth.ts +151 -0
- package/src/middlewares/bearer.ts +136 -0
- package/src/middlewares/jwt.ts +455 -0
- package/src/middlewares/logger.ts +120 -0
- package/src/middlewares/rateLimit.ts +153 -0
- package/src/middlewares/requestId.ts +94 -0
- package/src/middlewares/timing.ts +86 -0
- package/src/middlewares.ts +53 -0
- package/src/parser.ts +279 -133
- package/src/router.ts +74 -51
- package/src/routes.ts +220 -136
- package/src/schema.ts +123 -31
- package/src/server.ts +130 -70
- package/src/types.ts +368 -92
- package/src/util.ts +271 -5
- package/src/validator.compile.ts +343 -0
- package/src/validator.ts +64 -18
- package/bin/res/client.template.ts +0 -200
- package/scripts/release.ts +0 -196
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
else if (typeof response === '
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/scripts/release.ts
DELETED
|
@@ -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
|
-
})
|