@payload-auth-link/migrate 0.3.0 → 0.4.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 +6 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +149 -101
- package/dist/cli.js.map +1 -1
- package/dist/codemods.d.ts +19 -8
- package/dist/codemods.d.ts.map +1 -1
- package/dist/codemods.js +440 -21
- package/dist/codemods.js.map +1 -1
- package/dist/doctor/database.d.ts +5 -1
- package/dist/doctor/database.d.ts.map +1 -1
- package/dist/doctor/database.js +98 -34
- package/dist/doctor/database.js.map +1 -1
- package/dist/doctor/plugins.d.ts +15 -0
- package/dist/doctor/plugins.d.ts.map +1 -1
- package/dist/doctor/plugins.js +43 -0
- package/dist/doctor/plugins.js.map +1 -1
- package/dist/doctor/run-doctor.d.ts +11 -0
- package/dist/doctor/run-doctor.d.ts.map +1 -1
- package/dist/doctor/run-doctor.js +66 -20
- package/dist/doctor/run-doctor.js.map +1 -1
- package/dist/doctor/source-scan.d.ts +5 -0
- package/dist/doctor/source-scan.d.ts.map +1 -1
- package/dist/doctor/source-scan.js +9 -0
- package/dist/doctor/source-scan.js.map +1 -1
- package/dist/doctor/types.d.ts +12 -0
- package/dist/doctor/types.d.ts.map +1 -1
- package/dist/exporters.d.ts +7 -0
- package/dist/exporters.d.ts.map +1 -1
- package/dist/exporters.js +36 -1
- package/dist/exporters.js.map +1 -1
- package/dist/importer/apply.d.ts +5 -0
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +61 -33
- package/dist/importer/apply.js.map +1 -1
- package/dist/importer/lifecycle.d.ts +15 -6
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +131 -39
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/run-import.d.ts +8 -2
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +73 -21
- package/dist/importer/run-import.js.map +1 -1
- package/dist/importer/source.d.ts +47 -4
- package/dist/importer/source.d.ts.map +1 -1
- package/dist/importer/source.js +159 -27
- package/dist/importer/source.js.map +1 -1
- package/dist/importer/types.d.ts +33 -0
- package/dist/importer/types.d.ts.map +1 -1
- package/dist/importer/types.js +48 -0
- package/dist/importer/types.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/package.json +8 -11
- package/src/cli.ts +153 -100
- package/src/codemods.ts +540 -36
- package/src/doctor/database.ts +129 -46
- package/src/doctor/plugins.ts +51 -0
- package/src/doctor/run-doctor.ts +90 -25
- package/src/doctor/source-scan.ts +10 -0
- package/src/doctor/types.ts +9 -0
- package/src/exporters.ts +46 -1
- package/src/importer/apply.ts +78 -32
- package/src/importer/lifecycle.ts +163 -43
- package/src/importer/run-import.ts +107 -22
- package/src/importer/source.ts +214 -30
- package/src/importer/types.ts +86 -0
- package/src/index.ts +25 -2
package/src/codemods.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Codemods (
|
|
3
|
-
* payload-auth app must migrate
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Codemods (issues #53, #70): rule-based rewrites for the call sites a
|
|
5
|
+
* payload-auth app must migrate, matched on the TypeScript AST and
|
|
6
|
+
* applied as text splices over the original ranges (no printer —
|
|
7
|
+
* formatting survives).
|
|
8
|
+
*
|
|
9
|
+
* Each rule rewrites only what it can FULLY rewrite and reports the
|
|
10
|
+
* rest as manual sites ({@link findManualSites}); a half-rewritten
|
|
11
|
+
* call site is worse than an explicit one. In particular, a file with
|
|
12
|
+
* ANY unrewritable `getPayloadAuth` reference keeps its whole
|
|
13
|
+
* `get-payload-auth` state untouched, so the output never references a
|
|
14
|
+
* symbol no import provides.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
17
|
export type CodemodRule = {
|
|
@@ -21,59 +28,556 @@ export type CodemodFileResult = {
|
|
|
21
28
|
readonly applied: readonly string[]
|
|
22
29
|
/** Matches the rules could not fully rewrite (reported, never touched). */
|
|
23
30
|
readonly manual: readonly string[]
|
|
31
|
+
/** The rewritten content — equal to the input when nothing changed. */
|
|
32
|
+
readonly after: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- shared parsing helpers -----------------------------------------------
|
|
36
|
+
|
|
37
|
+
function parse(content: string): ts.SourceFile {
|
|
38
|
+
return ts.createSourceFile('codemod-input.ts', content, ts.ScriptTarget.Latest, true)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function lineOf(source: ts.SourceFile, node: ts.Node): number {
|
|
42
|
+
return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Splice = { readonly start: number; readonly end: number; readonly text: string }
|
|
46
|
+
|
|
47
|
+
function applySplices(content: string, splices: readonly Splice[]): string {
|
|
48
|
+
let out = content
|
|
49
|
+
for (const splice of [...splices].sort((a, b) => b.start - a.start)) {
|
|
50
|
+
out = out.slice(0, splice.start) + splice.text + out.slice(splice.end)
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeRegExp(text: string): string {
|
|
56
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether the node sits where `await` is legal: inside an async
|
|
61
|
+
* function-like, or at module top level (top-level await). The cheap
|
|
62
|
+
* heuristic walks to the nearest enclosing function-like and looks for
|
|
63
|
+
* the `async` keyword (#70).
|
|
64
|
+
*/
|
|
65
|
+
function awaitContext(node: ts.Node): 'awaitable' | 'sync-function' {
|
|
66
|
+
let current: ts.Node | undefined = node.parent
|
|
67
|
+
while (current !== undefined) {
|
|
68
|
+
if (
|
|
69
|
+
ts.isFunctionDeclaration(current) ||
|
|
70
|
+
ts.isMethodDeclaration(current) ||
|
|
71
|
+
ts.isArrowFunction(current) ||
|
|
72
|
+
ts.isFunctionExpression(current) ||
|
|
73
|
+
ts.isGetAccessorDeclaration(current) ||
|
|
74
|
+
ts.isSetAccessorDeclaration(current)
|
|
75
|
+
) {
|
|
76
|
+
const async =
|
|
77
|
+
current.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true
|
|
78
|
+
return async ? 'awaitable' : 'sync-function'
|
|
79
|
+
}
|
|
80
|
+
current = current.parent
|
|
81
|
+
}
|
|
82
|
+
return 'awaitable'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The module specifier of a static import declaration. */
|
|
86
|
+
function moduleSpecifierOf(statement: ts.ImportDeclaration): string {
|
|
87
|
+
const specifier = statement.moduleSpecifier
|
|
88
|
+
return ts.isStringLiteral(specifier) ? specifier.text : ''
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function soleNamedElement(statement: ts.ImportDeclaration): boolean {
|
|
92
|
+
const clause = statement.importClause
|
|
93
|
+
return (
|
|
94
|
+
clause !== undefined &&
|
|
95
|
+
clause.name === undefined &&
|
|
96
|
+
clause.namedBindings !== undefined &&
|
|
97
|
+
ts.isNamedImports(clause.namedBindings) &&
|
|
98
|
+
clause.namedBindings.elements.length === 1
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Splice that removes one named import element plus an adjacent comma,
|
|
104
|
+
* keeping the rest of the clause valid.
|
|
105
|
+
*/
|
|
106
|
+
function removeElementSplice(
|
|
107
|
+
statement: ts.ImportDeclaration,
|
|
108
|
+
element: ts.ImportSpecifier,
|
|
109
|
+
source: ts.SourceFile
|
|
110
|
+
): Splice {
|
|
111
|
+
const elements = statement.importClause?.namedBindings
|
|
112
|
+
if (elements === undefined || !ts.isNamedImports(elements)) {
|
|
113
|
+
return { start: element.getStart(source), end: element.getEnd(), text: '' }
|
|
114
|
+
}
|
|
115
|
+
const index = elements.elements.indexOf(element)
|
|
116
|
+
const next = index >= 0 ? elements.elements[index + 1] : undefined
|
|
117
|
+
if (next !== undefined) {
|
|
118
|
+
return { start: element.getStart(source), end: next.getStart(source), text: '' }
|
|
119
|
+
}
|
|
120
|
+
const previous = index > 0 ? elements.elements[index - 1] : undefined
|
|
121
|
+
if (previous !== undefined) {
|
|
122
|
+
return { start: previous.getEnd(), end: element.getEnd(), text: '' }
|
|
123
|
+
}
|
|
124
|
+
return { start: element.getStart(source), end: element.getEnd(), text: '' }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- getPayloadAuth (#70.1) -------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const payloadAuthModule = /^payload-auth(?:\/[A-Za-z0-9_./-]+)?$/
|
|
130
|
+
|
|
131
|
+
type PayloadAuthImport = {
|
|
132
|
+
readonly statement: ts.ImportDeclaration
|
|
133
|
+
readonly element: ts.ImportSpecifier
|
|
134
|
+
/** The name the file binds (`getPayloadAuth`, or its alias). */
|
|
135
|
+
readonly local: string
|
|
136
|
+
readonly typeOnly: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type PayloadAuthRef = {
|
|
140
|
+
readonly node: ts.Identifier
|
|
141
|
+
readonly kind: 'call' | 'type' | 'value'
|
|
142
|
+
/** The enclosing call expression when `kind === 'call'`. */
|
|
143
|
+
readonly call?: ts.CallExpression
|
|
144
|
+
/** The full-rewrite replacement when `kind === 'call'` and rewritable. */
|
|
145
|
+
readonly replacement?: string
|
|
146
|
+
/** The manual-site message when this reference cannot be rewritten. */
|
|
147
|
+
readonly reason?: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasPayloadConfigImport(source: ts.SourceFile): boolean {
|
|
151
|
+
let found = false
|
|
152
|
+
const visit = (node: ts.Node): void => {
|
|
153
|
+
if (ts.isImportDeclaration(node)) {
|
|
154
|
+
if (moduleSpecifierOf(node) === '@payload-config') {
|
|
155
|
+
found = true
|
|
156
|
+
}
|
|
157
|
+
} else if (
|
|
158
|
+
ts.isCallExpression(node) &&
|
|
159
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
160
|
+
node.arguments.length === 1
|
|
161
|
+
) {
|
|
162
|
+
const arg = node.arguments[0]
|
|
163
|
+
if (arg !== undefined && ts.isStringLiteral(arg) && arg.text === '@payload-config') {
|
|
164
|
+
found = true
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!found) {
|
|
168
|
+
ts.forEachChild(node, visit)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
visit(source)
|
|
172
|
+
return found
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every `getPayloadAuth` (or aliased) import and reference in the file,
|
|
177
|
+
* classified: a call that can be fully rewritten carries its
|
|
178
|
+
* replacement; anything else carries its manual-site reason.
|
|
179
|
+
*/
|
|
180
|
+
function analyzePayloadAuth(content: string): {
|
|
181
|
+
readonly source: ts.SourceFile
|
|
182
|
+
readonly imports: readonly PayloadAuthImport[]
|
|
183
|
+
readonly refs: readonly PayloadAuthRef[]
|
|
184
|
+
} {
|
|
185
|
+
const source = parse(content)
|
|
186
|
+
const imports: PayloadAuthImport[] = []
|
|
187
|
+
for (const statement of source.statements) {
|
|
188
|
+
if (
|
|
189
|
+
!ts.isImportDeclaration(statement) ||
|
|
190
|
+
!payloadAuthModule.test(moduleSpecifierOf(statement))
|
|
191
|
+
) {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
const clause = statement.importClause
|
|
195
|
+
if (
|
|
196
|
+
clause === undefined ||
|
|
197
|
+
clause.namedBindings === undefined ||
|
|
198
|
+
!ts.isNamedImports(clause.namedBindings)
|
|
199
|
+
) {
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
for (const element of clause.namedBindings.elements) {
|
|
203
|
+
const exported = element.propertyName ?? element.name
|
|
204
|
+
if (!ts.isIdentifier(exported) || exported.text !== 'getPayloadAuth') {
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
imports.push({
|
|
208
|
+
statement,
|
|
209
|
+
element,
|
|
210
|
+
local: element.name.text,
|
|
211
|
+
typeOnly: clause.isTypeOnly || element.isTypeOnly,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const locals = new Set(imports.map((imp) => imp.local))
|
|
216
|
+
const bindings = new Set<ts.Identifier>()
|
|
217
|
+
for (const imp of imports) {
|
|
218
|
+
bindings.add(imp.element.name)
|
|
219
|
+
const exported = imp.element.propertyName
|
|
220
|
+
if (exported !== undefined && ts.isIdentifier(exported)) {
|
|
221
|
+
bindings.add(exported)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const payloadConfig = hasPayloadConfigImport(source)
|
|
225
|
+
const refs: PayloadAuthRef[] = []
|
|
226
|
+
const visit = (node: ts.Node): void => {
|
|
227
|
+
if (ts.isIdentifier(node) && locals.has(node.text) && !bindings.has(node)) {
|
|
228
|
+
const parent = node.parent
|
|
229
|
+
if (ts.isCallExpression(parent) && parent.expression === node) {
|
|
230
|
+
const context = awaitContext(parent)
|
|
231
|
+
const argCount = parent.arguments.length
|
|
232
|
+
const firstArg = parent.arguments[0]
|
|
233
|
+
if (context === 'sync-function') {
|
|
234
|
+
refs.push({
|
|
235
|
+
node,
|
|
236
|
+
kind: 'call',
|
|
237
|
+
call: parent,
|
|
238
|
+
reason: `getPayloadAuth (line ${lineOf(source, parent)}) — the enclosing function is not async: make it async and use await getPayload({ config })`,
|
|
239
|
+
})
|
|
240
|
+
} else if (argCount === 0) {
|
|
241
|
+
if (payloadConfig) {
|
|
242
|
+
refs.push({
|
|
243
|
+
node,
|
|
244
|
+
kind: 'call',
|
|
245
|
+
call: parent,
|
|
246
|
+
replacement: '(await getPayload({ config }))',
|
|
247
|
+
})
|
|
248
|
+
} else {
|
|
249
|
+
refs.push({
|
|
250
|
+
node,
|
|
251
|
+
kind: 'call',
|
|
252
|
+
call: parent,
|
|
253
|
+
// The message spells the specifier without `from '…'`:
|
|
254
|
+
// shipped string literals must not read as imports
|
|
255
|
+
// (release:check scans for undeclared ones).
|
|
256
|
+
reason: `getPayloadAuth() (line ${lineOf(source, parent)}) — the no-arg form needs this file to import the payload config alias (@payload-config); add that import and use await getPayload({ config })`,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
} else if (argCount === 1 && firstArg !== undefined) {
|
|
260
|
+
refs.push({
|
|
261
|
+
node,
|
|
262
|
+
kind: 'call',
|
|
263
|
+
call: parent,
|
|
264
|
+
replacement: `(await getPayload({ config: ${firstArg.getText(source)} }))`,
|
|
265
|
+
})
|
|
266
|
+
} else {
|
|
267
|
+
refs.push({
|
|
268
|
+
node,
|
|
269
|
+
kind: 'call',
|
|
270
|
+
call: parent,
|
|
271
|
+
reason: `getPayloadAuth (line ${lineOf(source, parent)}) — unexpected call shape (${argCount} arguments); call await getPayload({ config }) by hand`,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
} else if (ts.isTypeQueryNode(parent) && parent.exprName === node) {
|
|
275
|
+
refs.push({
|
|
276
|
+
node,
|
|
277
|
+
kind: 'type',
|
|
278
|
+
reason: `getPayloadAuth type reference (line ${lineOf(source, node)}) — use payload's own types instead: import type { Payload } from 'payload'`,
|
|
279
|
+
})
|
|
280
|
+
} else {
|
|
281
|
+
refs.push({
|
|
282
|
+
node,
|
|
283
|
+
kind: 'value',
|
|
284
|
+
reason: `getPayloadAuth value reference (line ${lineOf(source, node)}) — rewrite by hand to await getPayload({ config })`,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
ts.forEachChild(node, visit)
|
|
289
|
+
}
|
|
290
|
+
visit(source)
|
|
291
|
+
return { source, imports, refs }
|
|
24
292
|
}
|
|
25
293
|
|
|
294
|
+
/**
|
|
295
|
+
* The get-payload-auth rewrite (issue #70): ALL references rewritable,
|
|
296
|
+
* or nothing. A single manual site keeps the whole file's
|
|
297
|
+
* getPayloadAuth state untouched — the output must never reference a
|
|
298
|
+
* symbol no import provides.
|
|
299
|
+
*/
|
|
300
|
+
function rewritePayloadAuth(content: string): string {
|
|
301
|
+
const { source, imports, refs } = analyzePayloadAuth(content)
|
|
302
|
+
if (imports.length === 0 && refs.length === 0) {
|
|
303
|
+
return content
|
|
304
|
+
}
|
|
305
|
+
if (refs.some((ref) => ref.reason !== undefined)) {
|
|
306
|
+
return content
|
|
307
|
+
}
|
|
308
|
+
if (imports.some((imp) => imp.typeOnly)) {
|
|
309
|
+
return content
|
|
310
|
+
}
|
|
311
|
+
const splices: Splice[] = []
|
|
312
|
+
for (const ref of refs) {
|
|
313
|
+
if (ref.kind === 'call' && ref.call !== undefined && ref.replacement !== undefined) {
|
|
314
|
+
splices.push({
|
|
315
|
+
start: ref.call.getStart(source),
|
|
316
|
+
end: ref.call.getEnd(),
|
|
317
|
+
text: ref.replacement,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (const imp of imports) {
|
|
322
|
+
if (soleNamedElement(imp.statement)) {
|
|
323
|
+
splices.push({
|
|
324
|
+
start: imp.statement.getStart(source),
|
|
325
|
+
end: imp.statement.getEnd(),
|
|
326
|
+
text: "import { getPayload } from 'payload'",
|
|
327
|
+
})
|
|
328
|
+
} else {
|
|
329
|
+
splices.push(removeElementSplice(imp.statement, imp.element, source))
|
|
330
|
+
splices.push({
|
|
331
|
+
start: imp.statement.getStart(source),
|
|
332
|
+
end: imp.statement.getStart(source),
|
|
333
|
+
text: "import { getPayload } from 'payload'\n",
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return applySplices(content, splices)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --- <expr>.betterAuth.api.getSession (#70.2) -------------------------------
|
|
341
|
+
|
|
342
|
+
/** The receiver text when the callee is exactly `<expr>.betterAuth.api.getSession`. */
|
|
343
|
+
function betterAuthSessionReceiver(call: ts.CallExpression, source: ts.SourceFile): string | null {
|
|
344
|
+
const callee = call.expression
|
|
345
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== 'getSession') {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
const api = callee.expression
|
|
349
|
+
if (!ts.isPropertyAccessExpression(api) || api.name.text !== 'api') {
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
const betterAuth = api.expression
|
|
353
|
+
if (!ts.isPropertyAccessExpression(betterAuth) || betterAuth.name.text !== 'betterAuth') {
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
// Aliased bindings (`const auth = payload.betterAuth`) are never
|
|
357
|
+
// rewritten — the rule matches only direct `.betterAuth.api` chains;
|
|
358
|
+
// findManualSites reports the surviving accessor.
|
|
359
|
+
return betterAuth.expression.getText(source)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The getSession argument text per #70: unwrap a lone `headers` object. */
|
|
363
|
+
function sessionArgsText(call: ts.CallExpression, source: ts.SourceFile): string {
|
|
364
|
+
const arg = call.arguments[0]
|
|
365
|
+
if (
|
|
366
|
+
call.arguments.length >= 1 &&
|
|
367
|
+
arg !== undefined &&
|
|
368
|
+
ts.isObjectLiteralExpression(arg) &&
|
|
369
|
+
arg.properties.length === 1
|
|
370
|
+
) {
|
|
371
|
+
const prop = arg.properties[0]
|
|
372
|
+
if (prop !== undefined) {
|
|
373
|
+
if (ts.isPropertyAssignment(prop) && prop.name.getText(source) === 'headers') {
|
|
374
|
+
return prop.initializer.getText(source)
|
|
375
|
+
}
|
|
376
|
+
if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === 'headers') {
|
|
377
|
+
return prop.name.getText(source)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return call.arguments.map((entry) => entry.getText(source)).join(', ')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function rewriteBetterAuthGetSession(content: string): string {
|
|
385
|
+
const source = parse(content)
|
|
386
|
+
const splices: Splice[] = []
|
|
387
|
+
const visit = (node: ts.Node): void => {
|
|
388
|
+
if (ts.isCallExpression(node)) {
|
|
389
|
+
const receiver = betterAuthSessionReceiver(node, source)
|
|
390
|
+
if (receiver !== null && awaitContext(node) !== 'sync-function') {
|
|
391
|
+
splices.push({
|
|
392
|
+
start: node.getStart(source),
|
|
393
|
+
end: node.getEnd(),
|
|
394
|
+
text: `(await authLinkServer(${receiver}).getSession(${sessionArgsText(node, source)}))`,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
ts.forEachChild(node, visit)
|
|
399
|
+
}
|
|
400
|
+
visit(source)
|
|
401
|
+
return applySplices(content, splices)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// --- toNextJsHandler (#70.3) ------------------------------------------------
|
|
405
|
+
|
|
406
|
+
const nextJsHandlerOutput =
|
|
407
|
+
'export const GET = createRouteHandlers.GET\nexport const POST = createRouteHandlers.POST'
|
|
408
|
+
|
|
409
|
+
type NextJsHandlerImport = {
|
|
410
|
+
readonly statement: ts.ImportDeclaration
|
|
411
|
+
readonly element: ts.ImportSpecifier
|
|
412
|
+
readonly local: string
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function collectNextJsHandlerImports(source: ts.SourceFile): readonly NextJsHandlerImport[] {
|
|
416
|
+
const imports: NextJsHandlerImport[] = []
|
|
417
|
+
for (const statement of source.statements) {
|
|
418
|
+
if (
|
|
419
|
+
!ts.isImportDeclaration(statement) ||
|
|
420
|
+
moduleSpecifierOf(statement) !== 'better-auth/next-js'
|
|
421
|
+
) {
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
const clause = statement.importClause
|
|
425
|
+
if (
|
|
426
|
+
clause === undefined ||
|
|
427
|
+
clause.namedBindings === undefined ||
|
|
428
|
+
!ts.isNamedImports(clause.namedBindings)
|
|
429
|
+
) {
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
for (const element of clause.namedBindings.elements) {
|
|
433
|
+
const imported = (element.propertyName ?? element.name).text
|
|
434
|
+
if (imported !== 'toNextJsHandler') {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
imports.push({ statement, element, local: element.name.text })
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return imports
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function rewriteNextJsHandler(content: string): string {
|
|
444
|
+
const source = parse(content)
|
|
445
|
+
const imports = collectNextJsHandlerImports(source)
|
|
446
|
+
if (imports.length === 0) {
|
|
447
|
+
return content
|
|
448
|
+
}
|
|
449
|
+
// The export-assignment shape stays textual (same output as #53),
|
|
450
|
+
// now matching the aliased local name too.
|
|
451
|
+
let current = content
|
|
452
|
+
for (const imp of imports) {
|
|
453
|
+
const pattern = new RegExp(
|
|
454
|
+
`export\\s*\\{\\s*(\\w+)\\s+as\\s+GET\\s*,\\s*(\\w+)\\s+as\\s+POST\\s*\\}\\s*=\\s*${escapeRegExp(imp.local)}\\s*\\(\\s*payload\\.betterAuth\\s*\\)`,
|
|
455
|
+
'g'
|
|
456
|
+
)
|
|
457
|
+
current = current.replace(pattern, nextJsHandlerOutput)
|
|
458
|
+
}
|
|
459
|
+
// The import rewrite runs on the rewritten content.
|
|
460
|
+
const after = parse(current)
|
|
461
|
+
const splices: Splice[] = []
|
|
462
|
+
for (const imp of collectNextJsHandlerImports(after)) {
|
|
463
|
+
if (soleNamedElement(imp.statement)) {
|
|
464
|
+
splices.push({
|
|
465
|
+
start: imp.statement.getStart(after),
|
|
466
|
+
end: imp.statement.getEnd(),
|
|
467
|
+
text: "import { createRouteHandlers } from '@payload-auth-link/next'",
|
|
468
|
+
})
|
|
469
|
+
} else {
|
|
470
|
+
splices.push(removeElementSplice(imp.statement, imp.element, after))
|
|
471
|
+
splices.push({
|
|
472
|
+
start: imp.statement.getStart(after),
|
|
473
|
+
end: imp.statement.getStart(after),
|
|
474
|
+
text: "import { createRouteHandlers } from '@payload-auth-link/next'\n",
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return applySplices(current, splices)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// --- rules -------------------------------------------------------------------
|
|
482
|
+
|
|
26
483
|
export const codemodRules: readonly CodemodRule[] = [
|
|
27
484
|
{
|
|
28
485
|
id: 'get-payload-auth',
|
|
29
|
-
description: 'getPayloadAuth() -> getPayload({ config })',
|
|
30
|
-
apply:
|
|
31
|
-
content
|
|
32
|
-
.replace(
|
|
33
|
-
/import\s*\{\s*getPayloadAuth\s*(?:,[^}]*)?\}\s*from\s*['"]payload-auth['"];?/g,
|
|
34
|
-
"import { getPayload } from 'payload'"
|
|
35
|
-
)
|
|
36
|
-
.replace(/\bgetPayloadAuth\(\s*\)/g, 'await getPayload({ config })'),
|
|
486
|
+
description: 'getPayloadAuth() -> getPayload({ config }), import -> payload',
|
|
487
|
+
apply: rewritePayloadAuth,
|
|
37
488
|
},
|
|
38
489
|
{
|
|
39
490
|
id: 'better-auth-get-session',
|
|
40
491
|
description:
|
|
41
492
|
'payload.betterAuth.api.getSession({ headers }) -> authLinkServer(payload).getSession(headers)',
|
|
42
|
-
apply:
|
|
43
|
-
content.replace(
|
|
44
|
-
/payload\.betterAuth\.api\.getSession\(\s*\{\s*headers\s*\}\s*\)/g,
|
|
45
|
-
'(await authLinkServer(payload)).getSession(headers)'
|
|
46
|
-
),
|
|
493
|
+
apply: rewriteBetterAuthGetSession,
|
|
47
494
|
},
|
|
48
495
|
{
|
|
49
496
|
id: 'to-next-js-handler',
|
|
50
497
|
description:
|
|
51
498
|
'toNextJsHandler(payload.betterAuth) -> createRouteHandlers from @payload-auth-link/next',
|
|
52
|
-
apply:
|
|
53
|
-
content
|
|
54
|
-
.replace(
|
|
55
|
-
/import\s*\{\s*toNextJsHandler\s*\}\s*from\s*['"]better-auth\/next-js['"];?/g,
|
|
56
|
-
"import { createRouteHandlers } from '@payload-auth-link/next'"
|
|
57
|
-
)
|
|
58
|
-
.replace(
|
|
59
|
-
/export\s*\{\s*(\w+)\s+as\s+GET\s*,\s*(\w+)\s+as\s+POST\s*\}\s*=\s*toNextJsHandler\(\s*payload\.betterAuth\s*\)/g,
|
|
60
|
-
'export const GET = createRouteHandlers.GET\nexport const POST = createRouteHandlers.POST'
|
|
61
|
-
),
|
|
499
|
+
apply: rewriteNextJsHandler,
|
|
62
500
|
},
|
|
63
501
|
]
|
|
64
502
|
|
|
65
503
|
/**
|
|
66
|
-
* Report call sites the rules cannot fully rewrite
|
|
67
|
-
*
|
|
504
|
+
* Report call sites the rules cannot fully rewrite (issue #70.4):
|
|
505
|
+
* EVERY remaining `getPayloadAuth` reference (calls, type references,
|
|
506
|
+
* the import itself), every surviving `payload.betterAuth` accessor
|
|
507
|
+
* (including `.betterAuth.api.<method>` and `$context`), and every
|
|
508
|
+
* `internalAdapter`. Nothing is silently ignored — the doctor counts
|
|
509
|
+
* these references, so the codemod must account for them.
|
|
68
510
|
*/
|
|
69
511
|
export function findManualSites(content: string): readonly string[] {
|
|
512
|
+
const source = parse(content)
|
|
70
513
|
const manual: string[] = []
|
|
71
|
-
|
|
72
|
-
|
|
514
|
+
const reportedLines = new Set<number>()
|
|
515
|
+
const { imports, refs } = analyzePayloadAuth(content)
|
|
516
|
+
for (const ref of refs) {
|
|
517
|
+
if (ref.reason !== undefined) {
|
|
518
|
+
manual.push(ref.reason)
|
|
519
|
+
reportedLines.add(lineOf(source, ref.node))
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
for (const imp of imports) {
|
|
523
|
+
manual.push(
|
|
524
|
+
`getPayloadAuth import (line ${lineOf(source, imp.statement)}) — replace with import { getPayload } from 'payload'`
|
|
525
|
+
)
|
|
526
|
+
reportedLines.add(lineOf(source, imp.statement))
|
|
73
527
|
}
|
|
74
|
-
|
|
75
|
-
|
|
528
|
+
const seenInternalsLines = new Set<number>()
|
|
529
|
+
const visit = (node: ts.Node): void => {
|
|
530
|
+
// A getPayloadAuth reference the import analysis did not cover (a
|
|
531
|
+
// local binding, no payload-auth import) is still reported.
|
|
532
|
+
if (
|
|
533
|
+
ts.isIdentifier(node) &&
|
|
534
|
+
node.text === 'getPayloadAuth' &&
|
|
535
|
+
!reportedLines.has(lineOf(source, node))
|
|
536
|
+
) {
|
|
537
|
+
manual.push(
|
|
538
|
+
`getPayloadAuth reference (line ${lineOf(source, node)}) — rewrite by hand to await getPayload({ config })`
|
|
539
|
+
)
|
|
540
|
+
reportedLines.add(lineOf(source, node))
|
|
541
|
+
}
|
|
542
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
543
|
+
const line = lineOf(source, node)
|
|
544
|
+
if (node.name.text === 'betterAuth') {
|
|
545
|
+
const parent = node.parent
|
|
546
|
+
if (ts.isPropertyAccessExpression(parent) && parent.name.text === 'api') {
|
|
547
|
+
const method = parent.parent
|
|
548
|
+
if (
|
|
549
|
+
ts.isPropertyAccessExpression(method) &&
|
|
550
|
+
method.name.text === 'getSession' &&
|
|
551
|
+
ts.isCallExpression(method.parent) &&
|
|
552
|
+
awaitContext(method.parent) === 'sync-function'
|
|
553
|
+
) {
|
|
554
|
+
manual.push(
|
|
555
|
+
`getSession (line ${line}) — the enclosing function is not async: make it async and use await authLinkServer(...).getSession(...)`
|
|
556
|
+
)
|
|
557
|
+
} else {
|
|
558
|
+
manual.push(
|
|
559
|
+
`payload.betterAuth.api.<method> (line ${line}) — see the authLinkServer session API (#54)`
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
manual.push(
|
|
564
|
+
`payload.betterAuth accessor (line ${line}) — see the authLinkServer session API (#54)`
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
} else if (node.name.text === '$context') {
|
|
568
|
+
seenInternalsLines.add(line)
|
|
569
|
+
manual.push(
|
|
570
|
+
`$context internals (line ${line}) — see the supported server session API (#54)`
|
|
571
|
+
)
|
|
572
|
+
} else if (node.name.text === 'internalAdapter' && !seenInternalsLines.has(line)) {
|
|
573
|
+
manual.push(
|
|
574
|
+
`internalAdapter (line ${line}) — Better Auth internals; see the supported server session API (#54)`
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
ts.forEachChild(node, visit)
|
|
76
579
|
}
|
|
580
|
+
visit(source)
|
|
77
581
|
return manual
|
|
78
582
|
}
|
|
79
583
|
|
|
@@ -89,5 +593,5 @@ export function applyCodemods(file: string, content: string): CodemodFileResult
|
|
|
89
593
|
}
|
|
90
594
|
}
|
|
91
595
|
const manual = findManualSites(current)
|
|
92
|
-
return { file, changed: current !== content, applied, manual }
|
|
596
|
+
return { file, changed: current !== content, applied, manual, after: current }
|
|
93
597
|
}
|