@lobehub/lobehub 2.0.0-next.339 → 2.0.0-next.340
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/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/docs/self-hosting/advanced/auth/clerk-to-betterauth.mdx +366 -0
- package/docs/self-hosting/advanced/auth/clerk-to-betterauth.zh-CN.mdx +360 -0
- package/docs/self-hosting/advanced/auth/legacy.mdx +4 -0
- package/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx +4 -0
- package/docs/self-hosting/advanced/auth.mdx +55 -30
- package/docs/self-hosting/advanced/auth.zh-CN.mdx +55 -30
- package/locales/ar/auth.json +1 -1
- package/locales/ar/desktop-onboarding.json +1 -0
- package/locales/ar/metadata.json +2 -2
- package/locales/ar/models.json +23 -5
- package/locales/ar/providers.json +0 -1
- package/locales/ar/setting.json +19 -0
- package/locales/bg-BG/auth.json +1 -1
- package/locales/bg-BG/desktop-onboarding.json +1 -0
- package/locales/bg-BG/metadata.json +2 -2
- package/locales/bg-BG/models.json +5 -5
- package/locales/bg-BG/providers.json +0 -1
- package/locales/bg-BG/setting.json +19 -0
- package/locales/de-DE/auth.json +1 -1
- package/locales/de-DE/desktop-onboarding.json +1 -0
- package/locales/de-DE/metadata.json +2 -2
- package/locales/de-DE/models.json +31 -10
- package/locales/de-DE/providers.json +0 -1
- package/locales/de-DE/setting.json +19 -0
- package/locales/en-US/auth.json +3 -2
- package/locales/en-US/metadata.json +2 -2
- package/locales/en-US/models.json +10 -11
- package/locales/en-US/providers.json +0 -1
- package/locales/es-ES/auth.json +1 -1
- package/locales/es-ES/desktop-onboarding.json +1 -0
- package/locales/es-ES/metadata.json +2 -2
- package/locales/es-ES/models.json +32 -5
- package/locales/es-ES/providers.json +0 -1
- package/locales/es-ES/setting.json +19 -0
- package/locales/fa-IR/auth.json +1 -1
- package/locales/fa-IR/desktop-onboarding.json +1 -0
- package/locales/fa-IR/metadata.json +2 -2
- package/locales/fa-IR/models.json +35 -5
- package/locales/fa-IR/providers.json +0 -1
- package/locales/fa-IR/setting.json +19 -0
- package/locales/fr-FR/auth.json +1 -1
- package/locales/fr-FR/desktop-onboarding.json +1 -0
- package/locales/fr-FR/metadata.json +2 -2
- package/locales/fr-FR/models.json +33 -5
- package/locales/fr-FR/providers.json +0 -1
- package/locales/fr-FR/setting.json +19 -0
- package/locales/it-IT/auth.json +1 -1
- package/locales/it-IT/desktop-onboarding.json +1 -0
- package/locales/it-IT/metadata.json +2 -2
- package/locales/it-IT/models.json +3 -8
- package/locales/it-IT/providers.json +0 -1
- package/locales/it-IT/setting.json +19 -0
- package/locales/ja-JP/auth.json +1 -1
- package/locales/ja-JP/desktop-onboarding.json +1 -0
- package/locales/ja-JP/metadata.json +2 -2
- package/locales/ja-JP/models.json +32 -5
- package/locales/ja-JP/providers.json +0 -1
- package/locales/ja-JP/setting.json +19 -0
- package/locales/ko-KR/auth.json +1 -1
- package/locales/ko-KR/desktop-onboarding.json +1 -0
- package/locales/ko-KR/metadata.json +2 -2
- package/locales/ko-KR/models.json +3 -8
- package/locales/ko-KR/providers.json +0 -1
- package/locales/ko-KR/setting.json +19 -0
- package/locales/nl-NL/auth.json +1 -1
- package/locales/nl-NL/desktop-onboarding.json +1 -0
- package/locales/nl-NL/metadata.json +2 -2
- package/locales/nl-NL/models.json +45 -4
- package/locales/nl-NL/providers.json +0 -1
- package/locales/nl-NL/setting.json +19 -0
- package/locales/pl-PL/auth.json +1 -1
- package/locales/pl-PL/desktop-onboarding.json +1 -0
- package/locales/pl-PL/metadata.json +2 -2
- package/locales/pl-PL/models.json +37 -5
- package/locales/pl-PL/providers.json +0 -1
- package/locales/pl-PL/setting.json +19 -0
- package/locales/pt-BR/auth.json +1 -1
- package/locales/pt-BR/desktop-onboarding.json +1 -0
- package/locales/pt-BR/metadata.json +2 -2
- package/locales/pt-BR/models.json +28 -4
- package/locales/pt-BR/providers.json +0 -1
- package/locales/pt-BR/setting.json +19 -0
- package/locales/ru-RU/auth.json +1 -1
- package/locales/ru-RU/desktop-onboarding.json +1 -0
- package/locales/ru-RU/metadata.json +2 -2
- package/locales/ru-RU/models.json +3 -8
- package/locales/ru-RU/providers.json +0 -1
- package/locales/ru-RU/setting.json +19 -0
- package/locales/tr-TR/auth.json +1 -1
- package/locales/tr-TR/desktop-onboarding.json +1 -0
- package/locales/tr-TR/metadata.json +2 -2
- package/locales/tr-TR/models.json +26 -7
- package/locales/tr-TR/providers.json +0 -1
- package/locales/tr-TR/setting.json +19 -0
- package/locales/vi-VN/auth.json +1 -1
- package/locales/vi-VN/desktop-onboarding.json +1 -0
- package/locales/vi-VN/metadata.json +2 -2
- package/locales/vi-VN/models.json +3 -5
- package/locales/vi-VN/providers.json +0 -1
- package/locales/vi-VN/setting.json +19 -0
- package/locales/zh-CN/auth.json +3 -3
- package/locales/zh-CN/metadata.json +2 -2
- package/locales/zh-CN/models.json +46 -6
- package/locales/zh-CN/providers.json +0 -1
- package/locales/zh-TW/auth.json +1 -1
- package/locales/zh-TW/desktop-onboarding.json +1 -0
- package/locales/zh-TW/metadata.json +2 -2
- package/locales/zh-TW/models.json +39 -6
- package/locales/zh-TW/providers.json +0 -1
- package/locales/zh-TW/setting.json +19 -0
- package/package.json +1 -1
- package/packages/const/src/url.ts +1 -1
- package/public/og/agent-og.webp +0 -0
- package/public/og/mcp-og.webp +0 -0
- package/public/og/og.webp +0 -0
- package/scripts/clerk-to-betterauth/__tests__/parseCsvLine.test.ts +21 -0
- package/scripts/clerk-to-betterauth/_internal/config.ts +55 -0
- package/scripts/clerk-to-betterauth/_internal/db.ts +32 -0
- package/scripts/clerk-to-betterauth/_internal/env.ts +6 -0
- package/scripts/clerk-to-betterauth/_internal/load-data-from-files.ts +74 -0
- package/scripts/clerk-to-betterauth/_internal/types.ts +45 -0
- package/scripts/clerk-to-betterauth/_internal/utils.ts +36 -0
- package/scripts/clerk-to-betterauth/export-clerk-users-with-api.ts +211 -0
- package/scripts/clerk-to-betterauth/index.ts +314 -0
- package/scripts/clerk-to-betterauth/prod/put_clerk_exported_users_csv_here.txt +0 -0
- package/scripts/clerk-to-betterauth/test/put_clerk_exported_users_csv_here.txt +0 -0
- package/scripts/clerk-to-betterauth/verify.ts +275 -0
- package/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx +30 -2
- package/src/app/[variants]/(auth)/signin/SignInPasswordStep.tsx +1 -1
- package/src/app/[variants]/(auth)/signin/page.tsx +3 -0
- package/src/app/[variants]/(auth)/signin/useSignIn.ts +6 -2
- package/src/app/[variants]/(main)/home/features/RecentResource/Item.tsx +2 -2
- package/src/app/[variants]/(main)/home/features/index.tsx +1 -2
- package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx +0 -2
- package/src/app/[variants]/(main)/settings/skill/features/Actions.tsx +8 -7
- package/src/app/[variants]/(main)/settings/skill/features/McpSkillItem.tsx +9 -11
- package/src/app/manifest.ts +4 -4
- package/src/features/AuthCard/index.tsx +1 -1
- package/src/features/SkillStore/CommunityList/Item.tsx +3 -2
- package/src/features/SkillStore/Search/index.tsx +0 -1
- package/src/locales/default/auth.ts +3 -2
- package/src/locales/default/metadata.ts +2 -2
- package/src/server/ld.ts +4 -3
- package/src/styles/global.ts +0 -6
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/apple.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/apple.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/auth0.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/auth0.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/authelia.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/authelia.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/authentik.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/authentik.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/casdoor.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/casdoor.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/cloudflare-zero-trust.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/cloudflare-zero-trust.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/cognito.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/cognito.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/feishu.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/feishu.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/generic-oidc.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/generic-oidc.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/github.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/github.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/google.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/google.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/keycloak.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/keycloak.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/logto.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/logto.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/microsoft.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/microsoft.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/okta.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/okta.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/wechat.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/wechat.zh-CN.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/zitadel.mdx +0 -0
- /package/docs/self-hosting/advanced/auth/{better-auth → providers}/zitadel.zh-CN.mdx +0 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/* eslint-disable unicorn/prefer-top-level-await, unicorn/no-process-exit */
|
|
2
|
+
import { type User, createClerkClient } from '@clerk/backend';
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
import { getClerkSecret, getMigrationMode, resolveDataPaths } from './_internal/config';
|
|
6
|
+
import './_internal/env';
|
|
7
|
+
import { ClerkUser } from './_internal/types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch all Clerk users via REST API and persist them into a local JSON file.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* tsx scripts/clerk-to-betterauth/export-clerk-users.ts [outputPath]
|
|
14
|
+
*
|
|
15
|
+
* Env vars required (set by CLERK_TO_BETTERAUTH_MODE=test|prod):
|
|
16
|
+
* - TEST_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY (test)
|
|
17
|
+
* - PROD_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY (prod)
|
|
18
|
+
*/
|
|
19
|
+
const PAGE_SIZE = 500;
|
|
20
|
+
const CONCURRENCY = Number(process.env.CLERK_EXPORT_CONCURRENCY ?? 10);
|
|
21
|
+
const MAX_RETRIES = Number(process.env.CLERK_EXPORT_RETRIES ?? 10);
|
|
22
|
+
const RETRY_DELAY_MS = 1000;
|
|
23
|
+
const ORDER_BY = '+created_at';
|
|
24
|
+
const DEFAULT_OUTPUT_PATH = resolveDataPaths().clerkUsersPath;
|
|
25
|
+
const formatDuration = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
|
|
26
|
+
|
|
27
|
+
const sleep = (ms: number) =>
|
|
28
|
+
new Promise<void>((resolve) => {
|
|
29
|
+
setTimeout(resolve, ms);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function getClerkClient(secretKey: string) {
|
|
33
|
+
return createClerkClient({
|
|
34
|
+
secretKey,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mapClerkUser(user: User): ClerkUser {
|
|
39
|
+
const raw = user.raw!;
|
|
40
|
+
|
|
41
|
+
const primaryEmail = raw.email_addresses?.find(
|
|
42
|
+
(email) => email.id === raw.primary_email_address_id,
|
|
43
|
+
)?.email_address;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
banned: raw.banned,
|
|
47
|
+
created_at: raw.created_at,
|
|
48
|
+
external_accounts: (raw.external_accounts ?? []).map((acc) => ({
|
|
49
|
+
approved_scopes: acc.approved_scopes,
|
|
50
|
+
created_at: (acc as any).created_at,
|
|
51
|
+
id: acc.id,
|
|
52
|
+
provider: acc.provider,
|
|
53
|
+
provider_user_id: acc.provider_user_id,
|
|
54
|
+
updated_at: (acc as any).updated_at,
|
|
55
|
+
verificationStatus: acc.verification?.status === 'verified',
|
|
56
|
+
})),
|
|
57
|
+
id: raw.id,
|
|
58
|
+
image_url: raw.image_url,
|
|
59
|
+
lockout_expires_in_seconds: raw.lockout_expires_in_seconds,
|
|
60
|
+
password_enabled: raw.password_enabled,
|
|
61
|
+
password_last_updated_at: raw.password_last_updated_at,
|
|
62
|
+
primaryEmail,
|
|
63
|
+
two_factor_enabled: raw.two_factor_enabled,
|
|
64
|
+
updated_at: raw.updated_at,
|
|
65
|
+
} satisfies ClerkUser;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fetchClerkUserPage(
|
|
69
|
+
offset: number,
|
|
70
|
+
clerkClient: ReturnType<typeof getClerkClient>,
|
|
71
|
+
pageIndex: number,
|
|
72
|
+
): Promise<ClerkUser[]> {
|
|
73
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) {
|
|
74
|
+
try {
|
|
75
|
+
console.log(
|
|
76
|
+
`🚚 [clerk-export] Fetching page #${pageIndex + 1} offset=${offset} limit=${PAGE_SIZE} (attempt ${attempt}/${MAX_RETRIES})`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const { data } = await clerkClient.users.getUserList({
|
|
80
|
+
limit: PAGE_SIZE,
|
|
81
|
+
offset,
|
|
82
|
+
orderBy: ORDER_BY,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
console.log(
|
|
86
|
+
`📥 [clerk-export] Received page #${pageIndex + 1} (${data.length} users) offset=${offset}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return data.map(mapClerkUser);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const isLastAttempt = attempt === MAX_RETRIES;
|
|
92
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
93
|
+
console.warn(
|
|
94
|
+
`⚠️ [clerk-export] Page #${pageIndex + 1} offset=${offset} failed (attempt ${attempt}/${MAX_RETRIES}): ${message}`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (isLastAttempt) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await sleep(RETRY_DELAY_MS);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Unreachable, but satisfies TypeScript return.
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runWithConcurrency<T>(
|
|
110
|
+
items: T[],
|
|
111
|
+
concurrency: number,
|
|
112
|
+
worker: (item: T, index: number) => Promise<void>,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const queue = [...items];
|
|
115
|
+
const inFlight: Promise<void>[] = [];
|
|
116
|
+
let index = 0;
|
|
117
|
+
|
|
118
|
+
const launchNext = () => {
|
|
119
|
+
if (!queue.length) return;
|
|
120
|
+
const currentItem = queue.shift() as T;
|
|
121
|
+
const currentIndex = index;
|
|
122
|
+
index += 1;
|
|
123
|
+
const task = worker(currentItem, currentIndex).finally(() => {
|
|
124
|
+
const pos = inFlight.indexOf(task);
|
|
125
|
+
if (pos !== -1) inFlight.splice(pos, 1);
|
|
126
|
+
});
|
|
127
|
+
inFlight.push(task);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < concurrency && queue.length; i += 1) {
|
|
131
|
+
launchNext();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
while (inFlight.length) {
|
|
135
|
+
await Promise.race(inFlight);
|
|
136
|
+
launchNext();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fetchAllClerkUsers(secretKey: string): Promise<ClerkUser[]> {
|
|
141
|
+
const clerkClient = getClerkClient(secretKey);
|
|
142
|
+
const userMap = new Map<string, ClerkUser>();
|
|
143
|
+
|
|
144
|
+
const firstPageResponse = await clerkClient.users.getUserList({
|
|
145
|
+
limit: PAGE_SIZE,
|
|
146
|
+
offset: 0,
|
|
147
|
+
orderBy: ORDER_BY,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const totalCount = firstPageResponse.totalCount ?? firstPageResponse.data.length;
|
|
151
|
+
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
|
152
|
+
const offsets = Array.from({ length: totalPages }, (_, pageIndex) => pageIndex * PAGE_SIZE);
|
|
153
|
+
|
|
154
|
+
console.log(
|
|
155
|
+
`📊 [clerk-export] Total users: ${totalCount}. Pages: ${totalPages}. Concurrency: ${CONCURRENCY}.`,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
await runWithConcurrency(offsets, CONCURRENCY, async (offset, index) => {
|
|
159
|
+
const page = await fetchClerkUserPage(offset, clerkClient, index);
|
|
160
|
+
|
|
161
|
+
for (const user of page) {
|
|
162
|
+
userMap.set(user.id, user);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if ((index + 1) % CONCURRENCY === 0 || index === offsets.length - 1) {
|
|
166
|
+
console.log(
|
|
167
|
+
`⏳ [clerk-export] Progress: ${userMap.size}/${totalCount} unique users collected.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const uniqueCount = userMap.size;
|
|
173
|
+
const extraUsers = Math.max(0, uniqueCount - totalCount);
|
|
174
|
+
|
|
175
|
+
console.log(
|
|
176
|
+
`🆕 [clerk-export] Snapshot total=${totalCount}, final unique=${uniqueCount}, new during export=${extraUsers}`,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return Array.from(userMap.values());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function main() {
|
|
183
|
+
const startedAt = Date.now();
|
|
184
|
+
const mode = getMigrationMode();
|
|
185
|
+
const secretKey = getClerkSecret();
|
|
186
|
+
const outputPath = process.argv[2] ?? DEFAULT_OUTPUT_PATH;
|
|
187
|
+
|
|
188
|
+
console.log('');
|
|
189
|
+
console.log('╔════════════════════════════════════════════════════════════╗');
|
|
190
|
+
console.log('║ Clerk Users Export Script (via API) ║');
|
|
191
|
+
console.log('╠════════════════════════════════════════════════════════════╣');
|
|
192
|
+
console.log(`║ Mode: ${mode.padEnd(48)}║`);
|
|
193
|
+
console.log(`║ Output: ${outputPath.padEnd(48)}║`);
|
|
194
|
+
console.log('╚════════════════════════════════════════════════════════════╝');
|
|
195
|
+
console.log('');
|
|
196
|
+
|
|
197
|
+
const clerkUsers = await fetchAllClerkUsers(secretKey);
|
|
198
|
+
|
|
199
|
+
await writeFile(outputPath, JSON.stringify(clerkUsers, null, 2), 'utf8');
|
|
200
|
+
|
|
201
|
+
console.log('');
|
|
202
|
+
console.log(
|
|
203
|
+
`✅ Export success! Saved ${clerkUsers.length} users to ${outputPath} (${formatDuration(Date.now() - startedAt)})`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
void main().catch((error) => {
|
|
208
|
+
console.log('');
|
|
209
|
+
console.error('❌ Export failed:', error);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/* eslint-disable unicorn/prefer-top-level-await */
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
|
|
4
|
+
import { getMigrationMode } from './_internal/config';
|
|
5
|
+
import { db, pool, schema } from './_internal/db';
|
|
6
|
+
import { loadCSVData, loadClerkUsersFromFile } from './_internal/load-data-from-files';
|
|
7
|
+
import { ClerkExternalAccount } from './_internal/types';
|
|
8
|
+
import { generateBackupCodes, safeDateConversion } from './_internal/utils';
|
|
9
|
+
|
|
10
|
+
const BATCH_SIZE = Number(process.env.CLERK_TO_BETTERAUTH_BATCH_SIZE) || 300;
|
|
11
|
+
const PROGRESS_TABLE = sql.identifier('clerk_migration_progress');
|
|
12
|
+
const IS_DRY_RUN =
|
|
13
|
+
process.argv.includes('--dry-run') || process.env.CLERK_TO_BETTERAUTH_DRY_RUN === '1';
|
|
14
|
+
const formatDuration = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
|
|
15
|
+
|
|
16
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
17
|
+
if (!Number.isFinite(size) || size <= 0) return [items];
|
|
18
|
+
const result: T[][] = [];
|
|
19
|
+
for (let i = 0; i < items.length; i += size) {
|
|
20
|
+
result.push(items.slice(i, i + size));
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function computeBanExpires(lockoutSeconds?: number | null): Date | undefined {
|
|
26
|
+
if (typeof lockoutSeconds !== 'number') return undefined;
|
|
27
|
+
return new Date(Date.now() + lockoutSeconds * 1000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function migrateFromClerk() {
|
|
31
|
+
const mode = getMigrationMode();
|
|
32
|
+
const csvUsers = await loadCSVData();
|
|
33
|
+
const clerkUsers = await loadClerkUsersFromFile();
|
|
34
|
+
const clerkUserMap = new Map(clerkUsers.map((u) => [u.id, u]));
|
|
35
|
+
|
|
36
|
+
if (!IS_DRY_RUN) {
|
|
37
|
+
await db.execute(sql`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS ${PROGRESS_TABLE} (
|
|
39
|
+
user_id TEXT PRIMARY KEY,
|
|
40
|
+
processed_at TIMESTAMPTZ DEFAULT NOW()
|
|
41
|
+
);
|
|
42
|
+
`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const processedUsers = new Set<string>();
|
|
46
|
+
|
|
47
|
+
if (!IS_DRY_RUN) {
|
|
48
|
+
try {
|
|
49
|
+
const processedResult = await db.execute<{ user_id: string }>(
|
|
50
|
+
sql`SELECT user_id FROM ${PROGRESS_TABLE};`,
|
|
51
|
+
);
|
|
52
|
+
const rows = (processedResult as { rows?: { user_id: string }[] }).rows ?? [];
|
|
53
|
+
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
const userId = row?.user_id;
|
|
56
|
+
if (typeof userId === 'string') {
|
|
57
|
+
processedUsers.add(userId);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn('[clerk-to-betterauth] failed to read progress table, treating as empty', error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`[clerk-to-betterauth] mode: ${mode} (dryRun=${IS_DRY_RUN})`);
|
|
66
|
+
console.log(`[clerk-to-betterauth] csv users: ${csvUsers.length}`);
|
|
67
|
+
console.log(`[clerk-to-betterauth] clerk api users: ${clerkUsers.length}`);
|
|
68
|
+
console.log(`[clerk-to-betterauth] already processed: ${processedUsers.size}`);
|
|
69
|
+
|
|
70
|
+
const unprocessedUsers = csvUsers.filter((user) => !processedUsers.has(user.id));
|
|
71
|
+
const batches = chunk(unprocessedUsers, BATCH_SIZE);
|
|
72
|
+
console.log(
|
|
73
|
+
`[clerk-to-betterauth] batches: ${batches.length} (batchSize=${BATCH_SIZE}, toProcess=${unprocessedUsers.length})`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
let processed = 0;
|
|
77
|
+
let accountAttempts = 0;
|
|
78
|
+
let twoFactorAttempts = 0;
|
|
79
|
+
let skipped = csvUsers.length - unprocessedUsers.length;
|
|
80
|
+
const startedAt = Date.now();
|
|
81
|
+
const accountCounts: Record<string, number> = {};
|
|
82
|
+
let missingScopeNonCredential = 0;
|
|
83
|
+
let passwordEnabledButNoDigest = 0;
|
|
84
|
+
const sampleMissingScope: string[] = [];
|
|
85
|
+
const sampleMissingDigest: string[] = [];
|
|
86
|
+
|
|
87
|
+
const bumpAccountCount = (providerId: string) => {
|
|
88
|
+
accountCounts[providerId] = (accountCounts[providerId] ?? 0) + 1;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) {
|
|
92
|
+
const batch = batches[batchIndex];
|
|
93
|
+
const userRows: (typeof schema.users.$inferInsert)[] = [];
|
|
94
|
+
const accountRows: (typeof schema.account.$inferInsert)[] = [];
|
|
95
|
+
const twoFactorRows: (typeof schema.twoFactor.$inferInsert)[] = [];
|
|
96
|
+
|
|
97
|
+
for (const user of batch) {
|
|
98
|
+
const clerkUser = clerkUserMap.get(user.id);
|
|
99
|
+
const lockoutSeconds = clerkUser?.lockout_expires_in_seconds;
|
|
100
|
+
const externalAccounts = clerkUser?.external_accounts as ClerkExternalAccount[] | undefined;
|
|
101
|
+
|
|
102
|
+
const userRow: typeof schema.users.$inferInsert = {
|
|
103
|
+
avatar: clerkUser?.image_url,
|
|
104
|
+
banExpires: computeBanExpires(lockoutSeconds) ?? undefined,
|
|
105
|
+
banned: Boolean(clerkUser?.banned),
|
|
106
|
+
clerkCreatedAt: safeDateConversion(clerkUser?.created_at),
|
|
107
|
+
email: user.primary_email_address,
|
|
108
|
+
emailVerified: Boolean(user.verified_email_addresses?.length),
|
|
109
|
+
firstName: user.first_name || undefined,
|
|
110
|
+
id: user.id,
|
|
111
|
+
lastName: user.last_name || undefined,
|
|
112
|
+
phone: user.primary_phone_number || undefined,
|
|
113
|
+
phoneNumberVerified: Boolean(user.verified_phone_numbers?.length),
|
|
114
|
+
role: 'user',
|
|
115
|
+
twoFactorEnabled: Boolean(clerkUser?.two_factor_enabled),
|
|
116
|
+
username: user.username || undefined,
|
|
117
|
+
};
|
|
118
|
+
userRows.push(userRow);
|
|
119
|
+
|
|
120
|
+
if (externalAccounts) {
|
|
121
|
+
for (const externalAccount of externalAccounts) {
|
|
122
|
+
const provider = externalAccount.provider;
|
|
123
|
+
const providerUserId = externalAccount.provider_user_id;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Clerk external accounts never contain credential providers and always include provider_user_id.
|
|
127
|
+
* Enforce this assumption to avoid inserting invalid account rows.
|
|
128
|
+
*/
|
|
129
|
+
if (provider === 'credential') {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[clerk-to-betterauth] unexpected credential external account: userId=${user.id}, externalAccountId=${externalAccount.id}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (!providerUserId) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`[clerk-to-betterauth] missing provider_user_id: userId=${user.id}, externalAccountId=${externalAccount.id}, provider=${provider}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const providerId = provider.replace('oauth_', '');
|
|
141
|
+
|
|
142
|
+
if (!externalAccount.approved_scopes) {
|
|
143
|
+
missingScopeNonCredential += 1;
|
|
144
|
+
if (sampleMissingScope.length < 5) sampleMissingScope.push(user.id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
accountRows.push({
|
|
148
|
+
accountId: providerUserId,
|
|
149
|
+
createdAt: safeDateConversion(externalAccount.created_at),
|
|
150
|
+
id: externalAccount.id,
|
|
151
|
+
providerId,
|
|
152
|
+
scope: externalAccount.approved_scopes?.replace(/\s+/g, ',') || undefined,
|
|
153
|
+
updatedAt: safeDateConversion(externalAccount.updated_at),
|
|
154
|
+
userId: user.id,
|
|
155
|
+
});
|
|
156
|
+
accountAttempts += 1;
|
|
157
|
+
bumpAccountCount(providerId);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Clerk API 不返回 credential external_account;若用户开启密码并且 CSV 提供散列,则补齐本地密码账号
|
|
162
|
+
const passwordEnabled = Boolean(clerkUser?.password_enabled);
|
|
163
|
+
if (passwordEnabled && user.password_digest) {
|
|
164
|
+
const passwordUpdatedAt = clerkUser?.password_last_updated_at;
|
|
165
|
+
|
|
166
|
+
accountRows.push({
|
|
167
|
+
accountId: user.id,
|
|
168
|
+
createdAt: safeDateConversion(clerkUser?.created_at),
|
|
169
|
+
id: 'cred_' + user.id,
|
|
170
|
+
password: user.password_digest,
|
|
171
|
+
providerId: 'credential',
|
|
172
|
+
updatedAt: safeDateConversion(
|
|
173
|
+
passwordUpdatedAt ?? clerkUser?.updated_at ?? clerkUser?.created_at,
|
|
174
|
+
),
|
|
175
|
+
userId: user.id,
|
|
176
|
+
});
|
|
177
|
+
accountAttempts += 1;
|
|
178
|
+
bumpAccountCount('credential');
|
|
179
|
+
} else if (passwordEnabled && !user.password_digest) {
|
|
180
|
+
passwordEnabledButNoDigest += 1;
|
|
181
|
+
if (sampleMissingDigest.length < 5) sampleMissingDigest.push(user.id);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (user.totp_secret) {
|
|
185
|
+
twoFactorRows.push({
|
|
186
|
+
backupCodes: await generateBackupCodes(user.totp_secret),
|
|
187
|
+
id: `tf_${user.id}`,
|
|
188
|
+
secret: user.totp_secret,
|
|
189
|
+
userId: user.id,
|
|
190
|
+
});
|
|
191
|
+
twoFactorAttempts += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!IS_DRY_RUN) {
|
|
196
|
+
await db.transaction(async (tx) => {
|
|
197
|
+
await tx
|
|
198
|
+
.insert(schema.users)
|
|
199
|
+
.values(userRows)
|
|
200
|
+
.onConflictDoUpdate({
|
|
201
|
+
set: {
|
|
202
|
+
avatar: sql`excluded.avatar`,
|
|
203
|
+
banExpires: sql`excluded.ban_expires`,
|
|
204
|
+
banned: sql`excluded.banned`,
|
|
205
|
+
clerkCreatedAt: sql`excluded.clerk_created_at`,
|
|
206
|
+
email: sql`excluded.email`,
|
|
207
|
+
emailVerified: sql`excluded.email_verified`,
|
|
208
|
+
firstName: sql`excluded.first_name`,
|
|
209
|
+
lastName: sql`excluded.last_name`,
|
|
210
|
+
phone: sql`excluded.phone`,
|
|
211
|
+
phoneNumberVerified: sql`excluded.phone_number_verified`,
|
|
212
|
+
role: sql`excluded.role`,
|
|
213
|
+
twoFactorEnabled: sql`excluded.two_factor_enabled`,
|
|
214
|
+
username: sql`excluded.username`,
|
|
215
|
+
},
|
|
216
|
+
target: schema.users.id,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (accountRows.length > 0) {
|
|
220
|
+
await tx.insert(schema.account).values(accountRows).onConflictDoNothing();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (twoFactorRows.length > 0) {
|
|
224
|
+
await tx.insert(schema.twoFactor).values(twoFactorRows).onConflictDoNothing();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const userIdValues = userRows.map((row) => sql`(${row.id})`);
|
|
228
|
+
if (userIdValues.length > 0) {
|
|
229
|
+
await tx.execute(sql`
|
|
230
|
+
INSERT INTO ${PROGRESS_TABLE} (user_id)
|
|
231
|
+
VALUES ${sql.join(userIdValues, sql`, `)}
|
|
232
|
+
ON CONFLICT (user_id) DO NOTHING;
|
|
233
|
+
`);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
processed += batch.length;
|
|
238
|
+
console.log(
|
|
239
|
+
`[clerk-to-betterauth] batch ${batchIndex + 1}/${batches.length} done, users ${processed}/${unprocessedUsers.length}, accounts+=${accountRows.length}, 2fa+=${twoFactorRows.length}, dryRun=${IS_DRY_RUN}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
console.log(
|
|
244
|
+
`[clerk-to-betterauth] completed users=${processed}, skipped=${skipped}, accounts attempted=${accountAttempts}, 2fa attempted=${twoFactorAttempts}, dryRun=${IS_DRY_RUN}, elapsed=${formatDuration(Date.now() - startedAt)}`,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const accountCountsText = Object.entries(accountCounts)
|
|
248
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
249
|
+
.map(([providerId, count]) => `${providerId}=${count}`)
|
|
250
|
+
.join(', ');
|
|
251
|
+
|
|
252
|
+
console.log(
|
|
253
|
+
`[clerk-to-betterauth] account provider counts: ${accountCountsText || 'none recorded'}`,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
console.log(
|
|
257
|
+
[
|
|
258
|
+
'[clerk-to-betterauth] anomalies:',
|
|
259
|
+
` - missing scope (non-credential): ${missingScopeNonCredential} sample=${sampleMissingScope.join(';') || 'n/a'}`,
|
|
260
|
+
` - passwordEnabled without digest: ${passwordEnabledButNoDigest} sample=${sampleMissingDigest.join(';') || 'n/a'}`,
|
|
261
|
+
].join('\n'),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
async function main() {
|
|
265
|
+
const startedAt = Date.now();
|
|
266
|
+
const mode = getMigrationMode();
|
|
267
|
+
|
|
268
|
+
console.log('');
|
|
269
|
+
console.log('╔════════════════════════════════════════════════════════════╗');
|
|
270
|
+
console.log('║ Clerk to Better Auth Migration Script ║');
|
|
271
|
+
console.log('╠════════════════════════════════════════════════════════════╣');
|
|
272
|
+
console.log(`║ Mode: ${mode.padEnd(48)}║`);
|
|
273
|
+
console.log(`║ Dry Run: ${(IS_DRY_RUN ? 'YES (no changes will be made)' : 'NO').padEnd(48)}║`);
|
|
274
|
+
console.log(`║ Batch: ${String(BATCH_SIZE).padEnd(48)}║`);
|
|
275
|
+
console.log('╚════════════════════════════════════════════════════════════╝');
|
|
276
|
+
console.log('');
|
|
277
|
+
|
|
278
|
+
if (mode === 'prod' && !IS_DRY_RUN) {
|
|
279
|
+
console.log('⚠️ WARNING: Running in PRODUCTION mode. Data will be modified!');
|
|
280
|
+
console.log(' Type "yes" to continue or press Ctrl+C to abort.');
|
|
281
|
+
console.log('');
|
|
282
|
+
|
|
283
|
+
const readline = await import('node:readline');
|
|
284
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
285
|
+
const answer = await new Promise<string>((resolve) => {
|
|
286
|
+
rl.question(' Confirm (yes/no): ', (ans) => {
|
|
287
|
+
resolve(ans);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
rl.close();
|
|
291
|
+
|
|
292
|
+
if (answer.toLowerCase() !== 'yes') {
|
|
293
|
+
console.log('❌ Aborted by user.');
|
|
294
|
+
process.exitCode = 0;
|
|
295
|
+
await pool.end();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
console.log('');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
await migrateFromClerk();
|
|
303
|
+
console.log('');
|
|
304
|
+
console.log(`✅ Migration success! (${formatDuration(Date.now() - startedAt)})`);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.log('');
|
|
307
|
+
console.error(`❌ Migration failed (${formatDuration(Date.now() - startedAt)}):`, error);
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
} finally {
|
|
310
|
+
await pool.end();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
void main();
|
|
File without changes
|
|
File without changes
|