+1
Dockerfile
+1
Dockerfile
···
17
17
cp target/release/tranquil-pds /tmp/tranquil-pds
18
18
19
19
FROM alpine:3.23
20
+
RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail
20
21
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
21
22
COPY --from=builder /app/migrations /app/migrations
22
23
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
+310
-9
frontend/src/lib/migration/flow.svelte.ts
+310
-9
frontend/src/lib/migration/flow.svelte.ts
···
84
84
let sourceClient: AtprotoClient | null = null;
85
85
let localClient: AtprotoClient | null = null;
86
86
let localServerInfo: ServerDescription | null = null;
87
-
let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> =
88
-
null;
87
+
let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = null;
89
88
90
89
function setStep(step: InboundStep) {
91
90
state.step = step;
···
461
460
async function migrateBlobs(): Promise<void> {
462
461
if (!sourceClient || !localClient) return;
463
462
464
-
const result = await migrateBlobsUtil(
465
-
localClient,
466
-
sourceClient,
467
-
state.sourceDid,
468
-
setProgress,
469
-
);
463
+
let cursor: string | undefined;
464
+
let migrated = 0;
470
465
471
-
state.progress.blobsFailed = result.failed;
466
+
do {
467
+
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
468
+
cursor,
469
+
100,
470
+
);
471
+
472
+
for (const blob of blobs) {
473
+
try {
474
+
setProgress({
475
+
currentOperation: `Migrating blob ${migrated + 1
476
+
}/${state.progress.blobsTotal}...`,
477
+
});
478
+
479
+
const blobData = await sourceClient.getBlob(
480
+
state.sourceDid,
481
+
blob.cid,
482
+
);
483
+
await localClient.uploadBlob(blobData, "application/octet-stream");
484
+
migrated++;
485
+
setProgress({ blobsMigrated: migrated });
486
+
} catch {
487
+
state.progress.blobsFailed.push(blob.cid);
488
+
}
489
+
}
490
+
491
+
cursor = nextCursor;
492
+
} while (cursor);
472
493
}
473
494
474
495
async function migratePreferences(): Promise<void> {
···
955
976
updateField<K extends keyof InboundMigrationState>(
956
977
field: K,
957
978
value: InboundMigrationState[K],
979
+
) {
980
+
state[field] = value;
981
+
},
982
+
};
983
+
}
984
+
985
+
export function createOutboundMigrationFlow() {
986
+
let state = $state<OutboundMigrationState>({
987
+
direction: "outbound",
988
+
step: "welcome",
989
+
localDid: "",
990
+
localHandle: "",
991
+
targetPdsUrl: "",
992
+
targetPdsDid: "",
993
+
targetHandle: "",
994
+
targetEmail: "",
995
+
targetPassword: "",
996
+
inviteCode: "",
997
+
targetAccessToken: null,
998
+
targetRefreshToken: null,
999
+
serviceAuthToken: null,
1000
+
plcToken: "",
1001
+
progress: createInitialProgress(),
1002
+
error: null,
1003
+
targetServerInfo: null,
1004
+
});
1005
+
1006
+
let localClient: AtprotoClient | null = null;
1007
+
let targetClient: AtprotoClient | null = null;
1008
+
1009
+
function setStep(step: OutboundStep) {
1010
+
state.step = step;
1011
+
state.error = null;
1012
+
saveMigrationState(state);
1013
+
updateStep(step);
1014
+
}
1015
+
1016
+
function setError(error: string) {
1017
+
state.error = error;
1018
+
saveMigrationState(state);
1019
+
}
1020
+
1021
+
function setProgress(updates: Partial<MigrationProgress>) {
1022
+
state.progress = { ...state.progress, ...updates };
1023
+
updateProgress(updates);
1024
+
}
1025
+
1026
+
async function validateTargetPds(url: string): Promise<ServerDescription> {
1027
+
const normalizedUrl = url.replace(/\/$/, "");
1028
+
targetClient = new AtprotoClient(normalizedUrl);
1029
+
1030
+
try {
1031
+
const serverInfo = await targetClient.describeServer();
1032
+
state.targetPdsUrl = normalizedUrl;
1033
+
state.targetPdsDid = serverInfo.did;
1034
+
state.targetServerInfo = serverInfo;
1035
+
return serverInfo;
1036
+
} catch (e) {
1037
+
throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
1038
+
}
1039
+
}
1040
+
1041
+
function initLocalClient(
1042
+
accessToken: string,
1043
+
did?: string,
1044
+
handle?: string,
1045
+
): void {
1046
+
localClient = createLocalClient();
1047
+
localClient.setAccessToken(accessToken);
1048
+
if (did) {
1049
+
state.localDid = did;
1050
+
}
1051
+
if (handle) {
1052
+
state.localHandle = handle;
1053
+
}
1054
+
}
1055
+
1056
+
async function startMigration(currentDid: string): Promise<void> {
1057
+
if (!localClient || !targetClient) {
1058
+
throw new Error("Not connected to PDSes");
1059
+
}
1060
+
1061
+
setStep("migrating");
1062
+
setProgress({ currentOperation: "Getting service auth token..." });
1063
+
1064
+
try {
1065
+
const { token } = await localClient.getServiceAuth(
1066
+
state.targetPdsDid,
1067
+
"com.atproto.server.createAccount",
1068
+
);
1069
+
state.serviceAuthToken = token;
1070
+
1071
+
setProgress({ currentOperation: "Creating account on new PDS..." });
1072
+
1073
+
const accountParams = {
1074
+
did: currentDid,
1075
+
handle: state.targetHandle,
1076
+
email: state.targetEmail,
1077
+
password: state.targetPassword,
1078
+
inviteCode: state.inviteCode || undefined,
1079
+
};
1080
+
1081
+
const session = await targetClient.createAccount(accountParams, token);
1082
+
state.targetAccessToken = session.accessJwt;
1083
+
state.targetRefreshToken = session.refreshJwt;
1084
+
targetClient.setAccessToken(session.accessJwt);
1085
+
1086
+
setProgress({ currentOperation: "Exporting repository..." });
1087
+
1088
+
const car = await localClient.getRepo(currentDid);
1089
+
setProgress({
1090
+
repoExported: true,
1091
+
currentOperation: "Importing repository...",
1092
+
});
1093
+
1094
+
await targetClient.importRepo(car);
1095
+
setProgress({
1096
+
repoImported: true,
1097
+
currentOperation: "Counting blobs...",
1098
+
});
1099
+
1100
+
const accountStatus = await targetClient.checkAccountStatus();
1101
+
setProgress({
1102
+
blobsTotal: accountStatus.expectedBlobs,
1103
+
currentOperation: "Migrating blobs...",
1104
+
});
1105
+
1106
+
await migrateBlobs(currentDid);
1107
+
1108
+
setProgress({ currentOperation: "Migrating preferences..." });
1109
+
await migratePreferences();
1110
+
1111
+
setProgress({ currentOperation: "Requesting PLC operation token..." });
1112
+
await localClient.requestPlcOperationSignature();
1113
+
1114
+
setStep("plc-token");
1115
+
} catch (e) {
1116
+
const err = e as Error & { error?: string; status?: number };
1117
+
const message = err.message || err.error ||
1118
+
`Unknown error (status ${err.status || "unknown"})`;
1119
+
setError(message);
1120
+
setStep("error");
1121
+
}
1122
+
}
1123
+
1124
+
async function migrateBlobs(did: string): Promise<void> {
1125
+
if (!localClient || !targetClient) return;
1126
+
1127
+
let cursor: string | undefined;
1128
+
let migrated = 0;
1129
+
1130
+
do {
1131
+
const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
1132
+
cursor,
1133
+
100,
1134
+
);
1135
+
1136
+
for (const blob of blobs) {
1137
+
try {
1138
+
setProgress({
1139
+
currentOperation: `Migrating blob ${migrated + 1
1140
+
}/${state.progress.blobsTotal}...`,
1141
+
});
1142
+
1143
+
const blobData = await localClient.getBlob(did, blob.cid);
1144
+
await targetClient.uploadBlob(blobData, "application/octet-stream");
1145
+
migrated++;
1146
+
setProgress({ blobsMigrated: migrated });
1147
+
} catch {
1148
+
state.progress.blobsFailed.push(blob.cid);
1149
+
}
1150
+
}
1151
+
1152
+
cursor = nextCursor;
1153
+
} while (cursor);
1154
+
}
1155
+
1156
+
async function migratePreferences(): Promise<void> {
1157
+
if (!localClient || !targetClient) return;
1158
+
1159
+
try {
1160
+
const prefs = await localClient.getPreferences();
1161
+
await targetClient.putPreferences(prefs);
1162
+
setProgress({ prefsMigrated: true });
1163
+
} catch { /* optional, best-effort */ }
1164
+
}
1165
+
1166
+
async function submitPlcToken(token: string): Promise<void> {
1167
+
if (!localClient || !targetClient) {
1168
+
throw new Error("Not connected to PDSes");
1169
+
}
1170
+
1171
+
state.plcToken = token;
1172
+
setStep("finalizing");
1173
+
setProgress({ currentOperation: "Signing PLC operation..." });
1174
+
1175
+
try {
1176
+
const credentials = await targetClient.getRecommendedDidCredentials();
1177
+
1178
+
const { operation } = await localClient.signPlcOperation({
1179
+
token,
1180
+
...credentials,
1181
+
});
1182
+
1183
+
setProgress({
1184
+
plcSigned: true,
1185
+
currentOperation: "Submitting PLC operation...",
1186
+
});
1187
+
1188
+
await targetClient.submitPlcOperation(operation);
1189
+
1190
+
setProgress({ currentOperation: "Activating account on new PDS..." });
1191
+
await targetClient.activateAccount();
1192
+
setProgress({ activated: true });
1193
+
1194
+
setProgress({ currentOperation: "Deactivating old account..." });
1195
+
try {
1196
+
await localClient.deactivateAccount(state.targetPdsUrl);
1197
+
setProgress({ deactivated: true });
1198
+
} catch { /* optional, best-effort */ }
1199
+
1200
+
setStep("success");
1201
+
clearMigrationState();
1202
+
} catch (e) {
1203
+
const err = e as Error & { error?: string; status?: number };
1204
+
const message = err.message || err.error ||
1205
+
`Unknown error (status ${err.status || "unknown"})`;
1206
+
setError(message);
1207
+
setStep("plc-token");
1208
+
}
1209
+
}
1210
+
1211
+
async function resendPlcToken(): Promise<void> {
1212
+
if (!localClient) {
1213
+
throw new Error("Not connected to local PDS");
1214
+
}
1215
+
await localClient.requestPlcOperationSignature();
1216
+
}
1217
+
1218
+
function reset(): void {
1219
+
state = {
1220
+
direction: "outbound",
1221
+
step: "welcome",
1222
+
localDid: "",
1223
+
localHandle: "",
1224
+
targetPdsUrl: "",
1225
+
targetPdsDid: "",
1226
+
targetHandle: "",
1227
+
targetEmail: "",
1228
+
targetPassword: "",
1229
+
inviteCode: "",
1230
+
targetAccessToken: null,
1231
+
targetRefreshToken: null,
1232
+
serviceAuthToken: null,
1233
+
plcToken: "",
1234
+
progress: createInitialProgress(),
1235
+
error: null,
1236
+
targetServerInfo: null,
1237
+
};
1238
+
localClient = null;
1239
+
targetClient = null;
1240
+
clearMigrationState();
1241
+
}
1242
+
1243
+
return {
1244
+
get state() {
1245
+
return state;
1246
+
},
1247
+
setStep,
1248
+
setError,
1249
+
validateTargetPds,
1250
+
initLocalClient,
1251
+
startMigration,
1252
+
submitPlcToken,
1253
+
resendPlcToken,
1254
+
reset,
1255
+
1256
+
updateField<K extends keyof OutboundMigrationState>(
1257
+
field: K,
1258
+
value: OutboundMigrationState[K],
958
1259
) {
959
1260
state[field] = value;
960
1261
},