@lark-codem/codem-cli 0.1.235-dev.100102679703.0.g4115fa5 → 0.1.235
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/package.json +11 -8
- package/profile/config-lock.js +453 -0
- package/profile/credential-errors.js +27 -0
- package/profile/credential-metadata.js +29 -0
- package/profile/debug.js +104 -0
- package/profile/device-identity.js +95 -0
- package/profile/diagnostic-id.js +47 -0
- package/profile/index.d.ts +61 -0
- package/profile/index.js +17 -0
- package/profile/profile-io.js +70 -0
- package/profile/profile-migration.js +1022 -0
- package/profile/profile-snapshot.js +120 -0
- package/profile/profile-writers.js +296 -0
- package/profile/runtime-logs.js +374 -0
- package/profile/slardar-pc-transport.js +38 -0
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, unlink } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { writeFileAtomically, writeJsonFileAtomically } from './profile-io.js';
|
|
6
|
+
import { probeProcessLiveness } from './profile-io.js';
|
|
7
|
+
import { resolveProfileUser as resolveCodeMAuthenticationUser } from './profile-snapshot.js';
|
|
8
|
+
import { samePathForPlatform } from './profile-io.js';
|
|
9
|
+
import { withCodeMConfigLock } from './profile-io.js';
|
|
10
|
+
import { CODEM_PROFILE_SESSION_TREES, CodeMProfileWriterBusyError, CodeMProfileWriterInspectionError, requireIdleProfileSessions } from './profile-writers.js';
|
|
11
|
+
const LEGACY_HISTORY_OWNER_FILE = 'app-session-owner.json';
|
|
12
|
+
const DEFERRED_IDENTITY_SELECTION_FILE = 'app-profile-recovery-pending.json';
|
|
13
|
+
const IDENTITY_FILES = [
|
|
14
|
+
'config.json.journal',
|
|
15
|
+
'.credentials.json',
|
|
16
|
+
'device-rebind.journal',
|
|
17
|
+
LEGACY_HISTORY_OWNER_FILE,
|
|
18
|
+
DEFERRED_IDENTITY_SELECTION_FILE,
|
|
19
|
+
'credential-storage.json',
|
|
20
|
+
'config.json'
|
|
21
|
+
];
|
|
22
|
+
const HashSchema = z.string().regex(/^[a-f0-9]{64}$/u).nullable();
|
|
23
|
+
const TransactionSchema = z.strictObject({
|
|
24
|
+
version: z.literal(1),
|
|
25
|
+
phase: z.enum([
|
|
26
|
+
'prepared',
|
|
27
|
+
'identity-applied'
|
|
28
|
+
]),
|
|
29
|
+
id: z.string().uuid(),
|
|
30
|
+
targetHome: z.string(),
|
|
31
|
+
homes: z.array(z.string()),
|
|
32
|
+
files: z.array(z.strictObject({
|
|
33
|
+
path: z.string(),
|
|
34
|
+
before: HashSchema,
|
|
35
|
+
after: HashSchema
|
|
36
|
+
})),
|
|
37
|
+
archivedSessions: z.array(z.string())
|
|
38
|
+
});
|
|
39
|
+
export class CodeMProfileMigrationError extends Error {
|
|
40
|
+
}
|
|
41
|
+
export class CodeMProfileMigrationBlockedError extends CodeMProfileMigrationError {
|
|
42
|
+
code = 'CODEM_PROFILE_MIGRATION_BLOCKED';
|
|
43
|
+
reason;
|
|
44
|
+
blockers;
|
|
45
|
+
constructor(detail, reason, blockers, options){
|
|
46
|
+
super(detail, options);
|
|
47
|
+
this.name = 'CodeMProfileMigrationBlockedError';
|
|
48
|
+
this.reason = reason;
|
|
49
|
+
this.blockers = blockers;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function migrateCodeMProfile(options) {
|
|
53
|
+
const target = await canonicalCodeMProfileHome(options.targetHome);
|
|
54
|
+
const homes = [];
|
|
55
|
+
for (const home of (await Promise.all([
|
|
56
|
+
target,
|
|
57
|
+
...options.legacyHomes
|
|
58
|
+
].map(canonicalCodeMProfileHome)))){
|
|
59
|
+
if (!homes.includes(home)) homes.push(home);
|
|
60
|
+
}
|
|
61
|
+
const { backupHome, pending } = await findPendingMigration(options.backupHome, target);
|
|
62
|
+
const profiles = [];
|
|
63
|
+
for (const home of homes){
|
|
64
|
+
let bundle = new Map();
|
|
65
|
+
let unreadable = false;
|
|
66
|
+
let readIssue = null;
|
|
67
|
+
try {
|
|
68
|
+
bundle = await readBundle(home);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
unreadable = true;
|
|
71
|
+
readIssue = message(error);
|
|
72
|
+
}
|
|
73
|
+
let identity = null;
|
|
74
|
+
let issue = readIssue;
|
|
75
|
+
let foreign = false;
|
|
76
|
+
try {
|
|
77
|
+
if (!unreadable) identity = inspectProfile(home, bundle, options.serverOrigin);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
issue = message(error);
|
|
80
|
+
foreign = issue.includes('其他服务环境');
|
|
81
|
+
}
|
|
82
|
+
let hasHistory = false;
|
|
83
|
+
try {
|
|
84
|
+
hasHistory = (await Promise.all(CODEM_PROFILE_SESSION_TREES.map(async (name)=>{
|
|
85
|
+
const info = await optionalStat(join(home, name));
|
|
86
|
+
return Boolean(info && (!info.isDirectory() || (await readdir(join(home, name))).length));
|
|
87
|
+
}))).some(Boolean);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
unreadable = true;
|
|
90
|
+
issue = message(error);
|
|
91
|
+
}
|
|
92
|
+
if (!issue && bundle.get('device-rebind.journal')) issue = '上次设备连接未完成,可以备份并恢复;结果不明确时需要重新登录。';
|
|
93
|
+
if (!issue && !identity && bundle.get('config.json.journal')) issue = '登录资料存在未完成的更新,需要恢复。';
|
|
94
|
+
if (!issue && bundle.get('.credentials.json')) {
|
|
95
|
+
try {
|
|
96
|
+
parseObject(bundle.get('.credentials.json'), home);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
issue = message(error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let blocker = null;
|
|
102
|
+
try {
|
|
103
|
+
await requireDaemonStopped(home);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
blocker = profileMigrationBlocker(error, home, 'writer-inspection-failed');
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await requireIdleProfileSessions(home);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
blocker ??= profileMigrationBlocker(error, home, 'writer-inspection-failed');
|
|
111
|
+
}
|
|
112
|
+
profiles.push({
|
|
113
|
+
home,
|
|
114
|
+
bundle,
|
|
115
|
+
identity,
|
|
116
|
+
issue,
|
|
117
|
+
foreign,
|
|
118
|
+
hasHistory,
|
|
119
|
+
blocker,
|
|
120
|
+
unreadable
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const sources = profiles.filter((profile)=>!profile.foreign || profile.home === target);
|
|
124
|
+
const canonical = sources.find((profile)=>profile.home === target);
|
|
125
|
+
const legacy = sources.filter((profile)=>profile.home !== target && hasData(profile));
|
|
126
|
+
const hasIdentity = (profile)=>Boolean(profile.identity && !profile.unreadable && !profile.foreign);
|
|
127
|
+
const hasDeferredIdentitySelection = (profile)=>Boolean(profile.bundle.get(DEFERRED_IDENTITY_SELECTION_FILE));
|
|
128
|
+
if (pending) {
|
|
129
|
+
const transaction = await parseTransaction(pending, homes, target);
|
|
130
|
+
let credentialStorage;
|
|
131
|
+
try {
|
|
132
|
+
credentialStorage = sources.some((profile)=>hasCredentialStorage(profile.bundle)) || await hasTransactionCredentials(transaction, backupHome);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (options.mode !== 'automatic' || !hasIdentity(canonical) || canonical.issue) throw error;
|
|
135
|
+
reportDeferredLegacyConsolidation(error);
|
|
136
|
+
return {
|
|
137
|
+
kind: 'ready',
|
|
138
|
+
backupHome: join(backupHome, transaction.id),
|
|
139
|
+
legacyCleanupPending: true
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (credentialStorage) {
|
|
143
|
+
if (options.mode === 'automatic' && hasIdentity(canonical)) {
|
|
144
|
+
return {
|
|
145
|
+
kind: 'ready',
|
|
146
|
+
backupHome: join(backupHome, transaction.id),
|
|
147
|
+
legacyCleanupPending: true
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
throw new CodeMProfileMigrationError('系统凭据资料需要由 CodeM CLI 迁移,当前已保留原资料和备份,请稍后重试。');
|
|
151
|
+
}
|
|
152
|
+
if (hasIdentity(canonical)) {
|
|
153
|
+
const transactionProfiles = profiles.filter((profile)=>transaction.homes.includes(profile.home));
|
|
154
|
+
if (transactionProfiles.some((profile)=>profile.blocker !== null)) {
|
|
155
|
+
return {
|
|
156
|
+
kind: 'ready',
|
|
157
|
+
backupHome: join(backupHome, transaction.id),
|
|
158
|
+
legacyCleanupPending: true
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
let completed = false;
|
|
162
|
+
try {
|
|
163
|
+
await withProfileLocks(transaction.homes, async ()=>{
|
|
164
|
+
if (!sameBundle(canonical.bundle, await readBundle(target))) {
|
|
165
|
+
throw new CodeMProfileMigrationError('登录资料已更新,请重新打开 CodeM。');
|
|
166
|
+
}
|
|
167
|
+
await requireTransactionSessionsIdle(transaction);
|
|
168
|
+
await options.migrateSessions(transactionSessionRoots(transaction));
|
|
169
|
+
await rm(join(backupHome, 'pending.json'));
|
|
170
|
+
});
|
|
171
|
+
completed = true;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
reportDeferredLegacyConsolidation(error);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
kind: 'ready',
|
|
177
|
+
backupHome: join(backupHome, transaction.id),
|
|
178
|
+
legacyCleanupPending: !completed || legacy.length > 0
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (await retireSupersededPreparedTransaction(transaction, backupHome, pending, [
|
|
182
|
+
...new Set([
|
|
183
|
+
...transaction.homes,
|
|
184
|
+
...sources.map((profile)=>profile.home)
|
|
185
|
+
])
|
|
186
|
+
], options.serverOrigin)) {
|
|
187
|
+
return migrateCodeMProfile(options);
|
|
188
|
+
}
|
|
189
|
+
await withProfileLocks(transaction.homes, ()=>completeTransaction(transaction, backupHome, options.migrateSessions));
|
|
190
|
+
return {
|
|
191
|
+
kind: 'ready',
|
|
192
|
+
backupHome: join(backupHome, transaction.id),
|
|
193
|
+
legacyCleanupPending: legacy.some((profile)=>!transaction.homes.includes(profile.home))
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
let selected;
|
|
197
|
+
let fresh = false;
|
|
198
|
+
let affectedProfiles = [];
|
|
199
|
+
let preserveSelectedIdentity = false;
|
|
200
|
+
let preserveLegacyIdentities = false;
|
|
201
|
+
const maintenance = options.mode === 'maintenance';
|
|
202
|
+
const candidates = legacy.filter(hasIdentity);
|
|
203
|
+
const primaryLegacyHome = options.legacyHomes[0] ? await canonicalCodeMProfileHome(options.legacyHomes[0]) : null;
|
|
204
|
+
const primary = candidates.find((profile)=>profile.home === primaryLegacyHome);
|
|
205
|
+
const first = candidates[0];
|
|
206
|
+
const equivalent = first !== undefined && candidates.every((profile)=>profile.identity?.deviceId === first.identity?.deviceId && profile.identity?.accountKey === first.identity?.accountKey);
|
|
207
|
+
if (maintenance) {
|
|
208
|
+
const recommended = hasIdentity(canonical) ? canonical : primary ?? first ?? sources.find((profile)=>hasData(profile) && !profile.foreign) ?? canonical;
|
|
209
|
+
const identities = [
|
|
210
|
+
canonical,
|
|
211
|
+
...candidates
|
|
212
|
+
].flatMap((profile)=>profile.identity ? [
|
|
213
|
+
profile.identity
|
|
214
|
+
] : []);
|
|
215
|
+
const sameAccount = Boolean(identities[0]?.accountKey && identities.every((identity)=>identity.accountKey === identities[0]?.accountKey));
|
|
216
|
+
const snapshot = {
|
|
217
|
+
stage: 'choice',
|
|
218
|
+
situation: sources.some((profile)=>hasData(profile) && (profile.issue || profile.unreadable || profile.blocker !== null)) || identities.length < 2 ? 'recovery' : sameAccount ? 'devices' : 'accounts',
|
|
219
|
+
targetHome: target,
|
|
220
|
+
environment: options.serverOrigin,
|
|
221
|
+
backupHome,
|
|
222
|
+
profiles: sources.filter(hasData).map(profileSetupProfile),
|
|
223
|
+
recommendedHome: recommended.home,
|
|
224
|
+
busy: sources.some((profile)=>hasData(profile) && profile.blocker !== null),
|
|
225
|
+
blockers: [],
|
|
226
|
+
notice: null,
|
|
227
|
+
diagnosticId: null
|
|
228
|
+
};
|
|
229
|
+
const choice = await options.choose(snapshot);
|
|
230
|
+
if (choice.kind === 'defer') return {
|
|
231
|
+
kind: 'defer'
|
|
232
|
+
};
|
|
233
|
+
if (choice.kind === 'new') {
|
|
234
|
+
selected = canonical;
|
|
235
|
+
fresh = true;
|
|
236
|
+
} else if (choice.kind === 'migrate') {
|
|
237
|
+
const selectedHome = await canonicalCodeMProfileHome(choice.home);
|
|
238
|
+
const chosen = sources.find((profile)=>profile.home === selectedHome);
|
|
239
|
+
if (!chosen || chosen.foreign) {
|
|
240
|
+
throw new CodeMProfileMigrationError('所选资料不属于当前环境,请重新选择。');
|
|
241
|
+
}
|
|
242
|
+
selected = chosen;
|
|
243
|
+
} else {
|
|
244
|
+
throw new CodeMProfileMigrationError('请选择需要使用的资料。');
|
|
245
|
+
}
|
|
246
|
+
} else if (hasIdentity(canonical) || hasDeferredIdentitySelection(canonical)) {
|
|
247
|
+
selected = canonical;
|
|
248
|
+
preserveSelectedIdentity = true;
|
|
249
|
+
preserveLegacyIdentities = !hasIdentity(canonical);
|
|
250
|
+
if (canonical.blocker !== null) {
|
|
251
|
+
return {
|
|
252
|
+
kind: 'ready',
|
|
253
|
+
backupHome: null,
|
|
254
|
+
legacyCleanupPending: legacy.length > 0 || Boolean(canonical.bundle.get(LEGACY_HISTORY_OWNER_FILE))
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
affectedProfiles = [
|
|
258
|
+
canonical,
|
|
259
|
+
...legacy.filter((profile)=>!profile.unreadable && profile.blocker === null)
|
|
260
|
+
];
|
|
261
|
+
const hasLegacyWork = preserveLegacyIdentities ? affectedProfiles.some((profile)=>profile.home !== target && profile.hasHistory) : affectedProfiles.length > 1;
|
|
262
|
+
const hasLegacyOwnerMarker = Boolean(canonical.bundle.get(LEGACY_HISTORY_OWNER_FILE));
|
|
263
|
+
const canonicalSessionsAreLinked = Boolean((await optionalStat(join(target, 'sessions')))?.isSymbolicLink());
|
|
264
|
+
const hasObsoleteDeferredSelection = hasIdentity(canonical) && hasDeferredIdentitySelection(canonical);
|
|
265
|
+
if (!hasLegacyWork && !hasLegacyOwnerMarker && !canonicalSessionsAreLinked && !hasObsoleteDeferredSelection) {
|
|
266
|
+
return {
|
|
267
|
+
kind: 'ready',
|
|
268
|
+
backupHome: null,
|
|
269
|
+
legacyCleanupPending: legacy.length > 0
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
const ambiguous = !primary && candidates.length > 1 && !equivalent;
|
|
274
|
+
selected = ambiguous ? canonical : primary ?? first ?? canonical;
|
|
275
|
+
fresh = !selected.identity;
|
|
276
|
+
preserveSelectedIdentity = Boolean(selected.identity);
|
|
277
|
+
preserveLegacyIdentities = ambiguous;
|
|
278
|
+
}
|
|
279
|
+
if (!(!maintenance && selected === canonical && hasIdentity(canonical))) {
|
|
280
|
+
if (selected.unreadable) {
|
|
281
|
+
throw new CodeMProfileMigrationError(`无法备份该资料目录,请检查权限后重试:${selected.home}`);
|
|
282
|
+
}
|
|
283
|
+
const selectedSourceIsBusy = selected.home !== target && selected.blocker !== null;
|
|
284
|
+
if (maintenance && selected.blocker !== null) {
|
|
285
|
+
throw selected.blocker;
|
|
286
|
+
}
|
|
287
|
+
affectedProfiles = [
|
|
288
|
+
canonical,
|
|
289
|
+
...legacy.filter((profile)=>!profile.unreadable && profile.blocker === null && !selectedSourceIsBusy)
|
|
290
|
+
];
|
|
291
|
+
if (!selectedSourceIsBusy && !affectedProfiles.includes(selected)) {
|
|
292
|
+
affectedProfiles.push(selected);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const deferredLegacyProfiles = legacy.filter((profile)=>!affectedProfiles.includes(profile));
|
|
296
|
+
for (const profile of affectedProfiles){
|
|
297
|
+
if (profile.blocker !== null) throw profile.blocker;
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
if (hasCredentialStorage(selected.bundle) || affectedProfiles.some((profile)=>hasCredentialStorage(profile.bundle))) {
|
|
301
|
+
if (!maintenance && hasIdentity(canonical)) {
|
|
302
|
+
return {
|
|
303
|
+
kind: 'ready',
|
|
304
|
+
backupHome: null,
|
|
305
|
+
legacyCleanupPending: legacy.length > 0
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
kind: 'defer'
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (options.mode !== 'automatic' || !hasIdentity(canonical) || canonical.issue) throw error;
|
|
314
|
+
reportDeferredLegacyConsolidation(error);
|
|
315
|
+
return {
|
|
316
|
+
kind: 'ready',
|
|
317
|
+
backupHome: null,
|
|
318
|
+
legacyCleanupPending: true
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const affected = affectedProfiles.map((profile)=>profile.home);
|
|
322
|
+
const lockedHomes = [
|
|
323
|
+
...new Set([
|
|
324
|
+
...affected,
|
|
325
|
+
selected.home
|
|
326
|
+
])
|
|
327
|
+
];
|
|
328
|
+
const pendingPath = join(backupHome, 'pending.json');
|
|
329
|
+
try {
|
|
330
|
+
return await withProfileLocks(lockedHomes, async ()=>{
|
|
331
|
+
for (const profile of affectedProfiles){
|
|
332
|
+
if (profile.hasHistory) await requireIdleProfile(profile.home);
|
|
333
|
+
if (!sameBundle(profile.bundle, await readBundle(profile.home))) {
|
|
334
|
+
throw new CodeMProfileMigrationError(`确认期间登录资料发生变化,请重试:${profile.home}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (!affectedProfiles.includes(selected) && !sameBundle(selected.bundle, await readBundle(selected.home))) {
|
|
338
|
+
throw new CodeMProfileMigrationError(`自动迁移期间登录资料发生变化,请重新打开 CodeM:${selected.home}`);
|
|
339
|
+
}
|
|
340
|
+
const id = randomUUID();
|
|
341
|
+
const directory = join(backupHome, id);
|
|
342
|
+
await mkdir(directory, {
|
|
343
|
+
recursive: true,
|
|
344
|
+
mode: 0o700
|
|
345
|
+
});
|
|
346
|
+
const files = [];
|
|
347
|
+
const next = prepareSelectedBundle(selected, options.serverOrigin, fresh, target, preserveSelectedIdentity);
|
|
348
|
+
next.set(DEFERRED_IDENTITY_SELECTION_FILE, preserveLegacyIdentities && !selected.identity ? Buffer.from('{"version":1}\n') : null);
|
|
349
|
+
if (pending) await writeFileAtomically(join(directory, 'previous-pending.json'), pending, 'previous migration recovery');
|
|
350
|
+
for (const home of [
|
|
351
|
+
target,
|
|
352
|
+
...affected.filter((candidate)=>candidate !== target)
|
|
353
|
+
]){
|
|
354
|
+
const bundle = affectedProfiles.find((profile)=>profile.home === home).bundle;
|
|
355
|
+
for (const name of IDENTITY_FILES){
|
|
356
|
+
const before = bundle.get(name) ?? null;
|
|
357
|
+
const after = home === target ? next.get(name) ?? null : preserveLegacyIdentities ? before : null;
|
|
358
|
+
if (digest(before) === digest(after)) continue;
|
|
359
|
+
const index = files.length;
|
|
360
|
+
if (before) await writeFileAtomically(join(directory, `${index}.before`), before, 'profile backup');
|
|
361
|
+
if (after) await writeFileAtomically(join(directory, `${index}.after`), after, 'profile migration target');
|
|
362
|
+
files.push({
|
|
363
|
+
path: join(home, name),
|
|
364
|
+
before: digest(before),
|
|
365
|
+
after: digest(after)
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
await requireIdentityWritersStopped(files);
|
|
370
|
+
const transaction = {
|
|
371
|
+
version: 1,
|
|
372
|
+
phase: 'prepared',
|
|
373
|
+
id,
|
|
374
|
+
targetHome: target,
|
|
375
|
+
homes: affected,
|
|
376
|
+
files,
|
|
377
|
+
archivedSessions: []
|
|
378
|
+
};
|
|
379
|
+
await writeJsonFileAtomically(join(directory, 'transaction.json'), transaction, 'profile migration receipt');
|
|
380
|
+
await writeJsonFileAtomically(pendingPath, transaction, 'pending profile migration');
|
|
381
|
+
await completeTransaction(transaction, backupHome, options.migrateSessions);
|
|
382
|
+
return {
|
|
383
|
+
kind: 'ready',
|
|
384
|
+
backupHome: directory,
|
|
385
|
+
legacyCleanupPending: preserveLegacyIdentities || deferredLegacyProfiles.length > 0
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (!hasIdentity(canonical) || !await hasCanonicalIdentity(target, options.serverOrigin)) throw error;
|
|
390
|
+
reportDeferredLegacyConsolidation(error);
|
|
391
|
+
return {
|
|
392
|
+
kind: 'ready',
|
|
393
|
+
backupHome: null,
|
|
394
|
+
legacyCleanupPending: legacy.length > 0
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function hasCredentialStorage(bundle) {
|
|
399
|
+
if (bundle.get('credential-storage.json')) return true;
|
|
400
|
+
for (const name of [
|
|
401
|
+
'config.json',
|
|
402
|
+
'.credentials.json'
|
|
403
|
+
]){
|
|
404
|
+
const bytes = bundle.get(name);
|
|
405
|
+
if (!bytes) continue;
|
|
406
|
+
// Unreadable JSON cannot prove the absence of a secure storage reference.
|
|
407
|
+
if (parseObject(bytes, name).credentialStorageRef != null) return true;
|
|
408
|
+
}
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
async function hasTransactionCredentials(transaction, backupHome) {
|
|
412
|
+
for (const [index, file] of transaction.files.entries()){
|
|
413
|
+
const name = basename(file.path);
|
|
414
|
+
if (name === 'credential-storage.json') return true;
|
|
415
|
+
if (name !== 'config.json' && name !== '.credentials.json') continue;
|
|
416
|
+
for (const side of [
|
|
417
|
+
'before',
|
|
418
|
+
'after'
|
|
419
|
+
]){
|
|
420
|
+
if (!file[side]) continue;
|
|
421
|
+
const bytes = await readOptional(join(backupHome, transaction.id, `${index}.${side}`));
|
|
422
|
+
if (hasCredentialStorage(new Map([
|
|
423
|
+
[
|
|
424
|
+
name,
|
|
425
|
+
bytes
|
|
426
|
+
]
|
|
427
|
+
]))) return true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
function hasData(profile) {
|
|
433
|
+
return profile.unreadable || profile.hasHistory || [
|
|
434
|
+
...profile.bundle.values()
|
|
435
|
+
].some(Boolean);
|
|
436
|
+
}
|
|
437
|
+
function profileSetupProfile(profile) {
|
|
438
|
+
return {
|
|
439
|
+
home: profile.home,
|
|
440
|
+
accountKey: profile.identity?.accountKey ?? null,
|
|
441
|
+
accountLabel: profile.identity?.accountLabel ?? '待恢复的资料',
|
|
442
|
+
deviceId: profile.identity?.deviceId ?? null,
|
|
443
|
+
status: profile.foreign ? 'other-environment' : profile.issue || profile.unreadable ? 'recovery' : profile.identity?.signedIn ? 'available' : 'signed-out',
|
|
444
|
+
issue: profile.issue
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
function prepareSelectedBundle(profile, origin, fresh, targetHome, preserveIdentity) {
|
|
448
|
+
const next = new Map(profile.bundle);
|
|
449
|
+
next.set(LEGACY_HISTORY_OWNER_FILE, null);
|
|
450
|
+
if (preserveIdentity) {
|
|
451
|
+
if (profile.home !== targetHome) removeDerivedCredentials(next, profile.home);
|
|
452
|
+
return next;
|
|
453
|
+
}
|
|
454
|
+
if (!fresh && profile.home === targetHome && !profile.issue) return next;
|
|
455
|
+
let config;
|
|
456
|
+
try {
|
|
457
|
+
config = parseObject(next.get('config.json') ?? Buffer.from('{}'), profile.home);
|
|
458
|
+
} catch {
|
|
459
|
+
config = {};
|
|
460
|
+
}
|
|
461
|
+
const original = profile.identity;
|
|
462
|
+
const journal = next.get('device-rebind.journal');
|
|
463
|
+
let requireLogin = fresh || !profile.identity;
|
|
464
|
+
if (journal && !fresh) {
|
|
465
|
+
requireLogin = true;
|
|
466
|
+
let entry = null;
|
|
467
|
+
try {
|
|
468
|
+
entry = parseObject(journal, profile.home);
|
|
469
|
+
} catch {}
|
|
470
|
+
const bound = object(entry?.boundDevice);
|
|
471
|
+
const boundId = string(bound?.deviceId);
|
|
472
|
+
const previous = string(entry?.previousDeviceId);
|
|
473
|
+
const daemonRecord = entry?.source === undefined || entry.source === 'daemon';
|
|
474
|
+
if (entry?.version === 1 && daemonRecord && original?.signedIn && boundId && string(bound?.deviceSecret) && previous === original.deviceId) {
|
|
475
|
+
config.device = {
|
|
476
|
+
...object(config.device),
|
|
477
|
+
deviceId: boundId,
|
|
478
|
+
deviceSecret: bound?.deviceSecret,
|
|
479
|
+
...typeof bound?.defaultWorkDirectory === 'string' ? {
|
|
480
|
+
defaultWorkDirectory: bound.defaultWorkDirectory
|
|
481
|
+
} : {},
|
|
482
|
+
...typeof bound?.cliVersion === 'string' ? {
|
|
483
|
+
cliVersion: bound.cliVersion
|
|
484
|
+
} : {}
|
|
485
|
+
};
|
|
486
|
+
requireLogin = false;
|
|
487
|
+
} else if (entry?.version === 1 && original && previous && previous !== original.deviceId && boundId !== original.deviceId && daemonRecord) {
|
|
488
|
+
requireLogin = false;
|
|
489
|
+
} else if (entry?.version === 1 && boundId && string(bound?.deviceSecret)) {
|
|
490
|
+
config.device = {
|
|
491
|
+
deviceId: boundId
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (requireLogin) {
|
|
496
|
+
let verifiedEnvironment = false;
|
|
497
|
+
try {
|
|
498
|
+
const url = new URL(String(config.serverUrl));
|
|
499
|
+
verifiedEnvironment = url.origin === origin && !url.username && !url.password && !url.search && !url.hash;
|
|
500
|
+
} catch {}
|
|
501
|
+
const anchor = verifiedEnvironment ? string(object(config.device)?.deviceId ?? object(config.device)?.device_id) : null;
|
|
502
|
+
config = {
|
|
503
|
+
serverUrl: origin,
|
|
504
|
+
device: anchor && anchor.length <= 128 ? {
|
|
505
|
+
deviceId: anchor
|
|
506
|
+
} : null,
|
|
507
|
+
auth: {
|
|
508
|
+
oauth: null
|
|
509
|
+
},
|
|
510
|
+
user: null
|
|
511
|
+
};
|
|
512
|
+
next.set('config.json.journal', null);
|
|
513
|
+
}
|
|
514
|
+
next.set('device-rebind.journal', null);
|
|
515
|
+
next.set('config.json', Buffer.from(`${JSON.stringify(config)}\n`));
|
|
516
|
+
removeDerivedCredentials(next, profile.home);
|
|
517
|
+
return next;
|
|
518
|
+
}
|
|
519
|
+
function removeDerivedCredentials(bundle, home) {
|
|
520
|
+
let credentials = null;
|
|
521
|
+
try {
|
|
522
|
+
if (bundle.get('.credentials.json')) {
|
|
523
|
+
credentials = parseObject(bundle.get('.credentials.json'), home);
|
|
524
|
+
}
|
|
525
|
+
} catch {}
|
|
526
|
+
if (credentials) delete credentials.codemAiOauth;
|
|
527
|
+
bundle.set('.credentials.json', credentials ? Buffer.from(`${JSON.stringify(credentials)}\n`) : null);
|
|
528
|
+
}
|
|
529
|
+
function message(error) {
|
|
530
|
+
return error instanceof CodeMProfileMigrationError ? error.message : '无法读取资料,请检查目录权限后重试。';
|
|
531
|
+
}
|
|
532
|
+
function reportDeferredLegacyConsolidation(error) {
|
|
533
|
+
console.warn('Deferred legacy CodeM profile consolidation; the canonical profile remains active', error instanceof Error ? error.message : 'unknown error');
|
|
534
|
+
}
|
|
535
|
+
async function retireSupersededPreparedTransaction(transaction, backupHome, pending, recoveryHomes, serverOrigin) {
|
|
536
|
+
if (transaction.phase !== 'prepared') return false;
|
|
537
|
+
return withProfileLocks(recoveryHomes, async ()=>{
|
|
538
|
+
const bundles = await readProfileBundles(recoveryHomes);
|
|
539
|
+
const decision = preparedTransactionDecision(transaction, bundles, serverOrigin);
|
|
540
|
+
if (decision === 'conflict') {
|
|
541
|
+
throw new CodeMProfileMigrationError('未完成迁移中的登录资料再次发生冲突,已保留备份,请联系支持。');
|
|
542
|
+
}
|
|
543
|
+
if (decision === 'resume' || await hasTransactionSessionArchive(transaction)) return false;
|
|
544
|
+
await requireProfileHomesIdle(recoveryHomes);
|
|
545
|
+
const confirmed = await readProfileBundles(recoveryHomes);
|
|
546
|
+
if (preparedTransactionDecision(transaction, confirmed, serverOrigin) !== 'restart' || await hasTransactionSessionArchive(transaction)) {
|
|
547
|
+
throw new CodeMProfileMigrationError('登录资料正在更新,请重新打开 CodeM。');
|
|
548
|
+
}
|
|
549
|
+
const directory = join(backupHome, transaction.id);
|
|
550
|
+
await writeFileAtomically(join(directory, 'superseded-pending.json'), pending, 'superseded profile migration');
|
|
551
|
+
await rm(join(backupHome, 'pending.json'));
|
|
552
|
+
console.warn('Restarting prepared CodeM profile migration from a newer legacy identity');
|
|
553
|
+
return true;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async function readProfileBundles(homes) {
|
|
557
|
+
return new Map(await Promise.all(homes.map(async (home)=>[
|
|
558
|
+
home,
|
|
559
|
+
await readBundle(home)
|
|
560
|
+
])));
|
|
561
|
+
}
|
|
562
|
+
function preparedTransactionDecision(transaction, bundles, serverOrigin) {
|
|
563
|
+
const targetBundle = bundles.get(transaction.targetHome);
|
|
564
|
+
if (!targetBundle) return 'conflict';
|
|
565
|
+
try {
|
|
566
|
+
if (inspectProfile(transaction.targetHome, targetBundle, serverOrigin)) {
|
|
567
|
+
return 'resume';
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
return 'conflict';
|
|
571
|
+
}
|
|
572
|
+
const recordedPaths = new Set(transaction.files.map((file)=>file.path));
|
|
573
|
+
let legacyChanged = false;
|
|
574
|
+
let transactionApplied = false;
|
|
575
|
+
for (const file of transaction.files){
|
|
576
|
+
const current = digest(bundles.get(dirname(file.path))?.get(basename(file.path)) ?? null);
|
|
577
|
+
if (dirname(file.path) === transaction.targetHome) {
|
|
578
|
+
if (current === file.after) transactionApplied = true;
|
|
579
|
+
else if (current !== file.before) return 'conflict';
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (current === file.after) transactionApplied = true;
|
|
583
|
+
else if (current !== file.before) legacyChanged = true;
|
|
584
|
+
}
|
|
585
|
+
for (const home of bundles.keys()){
|
|
586
|
+
if (home === transaction.targetHome) continue;
|
|
587
|
+
const bundle = bundles.get(home);
|
|
588
|
+
if (!bundle) return 'conflict';
|
|
589
|
+
if (IDENTITY_FILES.some((name)=>bundle.get(name) && !recordedPaths.has(join(home, name)))) {
|
|
590
|
+
legacyChanged = true;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (!legacyChanged) return 'resume';
|
|
594
|
+
if (transactionApplied) return 'conflict';
|
|
595
|
+
const identities = [];
|
|
596
|
+
for (const home of bundles.keys()){
|
|
597
|
+
if (home === transaction.targetHome) continue;
|
|
598
|
+
const bundle = bundles.get(home);
|
|
599
|
+
if (!bundle) return 'conflict';
|
|
600
|
+
try {
|
|
601
|
+
const identity = inspectProfile(home, bundle, serverOrigin);
|
|
602
|
+
if (identity) identities.push(identity);
|
|
603
|
+
} catch {
|
|
604
|
+
return 'conflict';
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const first = identities[0];
|
|
608
|
+
return first && identities.every((identity)=>identity.deviceId === first.deviceId && identity.accountKey === first.accountKey) ? 'restart' : 'conflict';
|
|
609
|
+
}
|
|
610
|
+
async function requireProfileHomesIdle(homes) {
|
|
611
|
+
for (const home of homes){
|
|
612
|
+
await requireDaemonStopped(home);
|
|
613
|
+
const entries = await Promise.all(CODEM_PROFILE_SESSION_TREES.map((name)=>optionalStat(join(home, name))));
|
|
614
|
+
if (entries.some(Boolean)) await requireIdleProfile(home);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function hasTransactionSessionArchive(transaction) {
|
|
618
|
+
for (const home of transaction.archivedSessions){
|
|
619
|
+
for (const name of CODEM_PROFILE_SESSION_TREES){
|
|
620
|
+
const root = join(home, '.app-profile-archive', transaction.id);
|
|
621
|
+
if (await optionalStat(join(root, name)) || await optionalStat(join(root, 'conflicts', name))) return true;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
async function hasCanonicalIdentity(home, origin) {
|
|
627
|
+
try {
|
|
628
|
+
const bundle = await readBundle(home);
|
|
629
|
+
return Boolean(inspectProfile(home, bundle, origin));
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function completeTransaction(transaction, backupHome, migrateSessions) {
|
|
635
|
+
const pendingPath = join(backupHome, 'pending.json');
|
|
636
|
+
await requireTransactionReady(transaction);
|
|
637
|
+
if (transaction.phase === 'prepared') {
|
|
638
|
+
await applyTransaction(transaction, backupHome);
|
|
639
|
+
await writeJsonFileAtomically(pendingPath, {
|
|
640
|
+
...transaction,
|
|
641
|
+
phase: 'identity-applied'
|
|
642
|
+
}, 'pending profile migration');
|
|
643
|
+
}
|
|
644
|
+
for (const file of transaction.files){
|
|
645
|
+
if (digest(await readOptional(file.path)) !== file.after) {
|
|
646
|
+
throw new CodeMProfileMigrationError(`迁移后资料被其他进程修改,已停止恢复;请保留备份并联系支持:${join(backupHome, transaction.id)}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
await migrateSessions(transactionSessionRoots(transaction));
|
|
650
|
+
await rm(pendingPath);
|
|
651
|
+
}
|
|
652
|
+
async function applyTransaction(transaction, backupHome) {
|
|
653
|
+
const directory = join(backupHome, transaction.id);
|
|
654
|
+
await requireTransactionReady(transaction);
|
|
655
|
+
for (const [index, file] of transaction.files.entries()){
|
|
656
|
+
const current = digest(await readOptional(file.path));
|
|
657
|
+
if (current !== file.before && current !== file.after) {
|
|
658
|
+
throw new CodeMProfileMigrationError(`迁移后资料被其他进程修改,已停止恢复;请保留备份并联系支持:${directory}`);
|
|
659
|
+
}
|
|
660
|
+
for (const side of [
|
|
661
|
+
'before',
|
|
662
|
+
'after'
|
|
663
|
+
]){
|
|
664
|
+
if (file[side] !== digest(await readOptional(join(directory, `${index}.${side}`)))) {
|
|
665
|
+
throw new CodeMProfileMigrationError(`资料迁移备份校验失败:${directory}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
for (const home of transaction.homes){
|
|
670
|
+
if (home === transaction.targetHome) continue;
|
|
671
|
+
const sessions = join(home, 'sessions');
|
|
672
|
+
if (!(await optionalStat(sessions))?.isSymbolicLink()) continue;
|
|
673
|
+
const target = resolve(dirname(sessions), await readlink(sessions));
|
|
674
|
+
const actualTarget = await optionalRealpath(sessions);
|
|
675
|
+
if (!await isKnownTransactionSessionRoot(transaction, home, target, actualTarget)) {
|
|
676
|
+
throw new CodeMProfileMigrationError(`旧会话目录是未识别的链接,已停止迁移:${sessions}`);
|
|
677
|
+
}
|
|
678
|
+
await unlink(sessions);
|
|
679
|
+
}
|
|
680
|
+
const canonicalSessions = join(transaction.targetHome, 'sessions');
|
|
681
|
+
if (transaction.archivedSessions.includes(transaction.targetHome) && (await optionalStat(canonicalSessions))?.isSymbolicLink()) {
|
|
682
|
+
const source = await realpath(canonicalSessions);
|
|
683
|
+
if (!transaction.archivedSessions.some((home)=>join(home, 'sessions') === source)) {
|
|
684
|
+
throw new CodeMProfileMigrationError('待归档共享会话链接的目标发生变化,已停止迁移。');
|
|
685
|
+
}
|
|
686
|
+
await unlink(canonicalSessions);
|
|
687
|
+
}
|
|
688
|
+
for (const home of transaction.archivedSessions){
|
|
689
|
+
for (const name of CODEM_PROFILE_SESSION_TREES){
|
|
690
|
+
const source = join(home, name);
|
|
691
|
+
const archive = join(home, '.app-profile-archive', transaction.id, name);
|
|
692
|
+
const sourceStat = await optionalStat(source);
|
|
693
|
+
const archivedStat = await optionalStat(archive);
|
|
694
|
+
if (!sourceStat) continue;
|
|
695
|
+
if (sourceStat.isSymbolicLink()) {
|
|
696
|
+
if (await realpath(source) === await realpath(join(transaction.targetHome, 'sessions'))) continue;
|
|
697
|
+
throw new CodeMProfileMigrationError(`旧会话目录是未识别的链接,已停止迁移:${source}`);
|
|
698
|
+
}
|
|
699
|
+
if (!sourceStat.isDirectory() || archivedStat) {
|
|
700
|
+
throw new CodeMProfileMigrationError(`会话归档状态发生冲突,已停止迁移:${source}`);
|
|
701
|
+
}
|
|
702
|
+
await mkdir(join(home, '.app-profile-archive', transaction.id), {
|
|
703
|
+
recursive: true,
|
|
704
|
+
mode: 0o700
|
|
705
|
+
});
|
|
706
|
+
await rename(source, archive);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
for (const [index, file] of transaction.files.entries()){
|
|
710
|
+
const current = digest(await readOptional(file.path));
|
|
711
|
+
if (current === file.after) continue;
|
|
712
|
+
if (current !== file.before) throw new CodeMProfileMigrationError(`迁移时资料发生变化:${file.path}`);
|
|
713
|
+
if (file.after === null) await rm(file.path);
|
|
714
|
+
else await writeFileAtomically(file.path, await readOptional(join(directory, `${index}.after`)), 'CodeM profile');
|
|
715
|
+
}
|
|
716
|
+
for (const file of transaction.files){
|
|
717
|
+
if (digest(await readOptional(file.path)) !== file.after) {
|
|
718
|
+
throw new CodeMProfileMigrationError(`迁移结果校验失败:${file.path}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function isKnownTransactionSessionRoot(transaction, sourceHome, linkTarget, actualTarget) {
|
|
723
|
+
const canonicalSessions = await canonicalCodeMProfileHome(join(transaction.targetHome, 'sessions'));
|
|
724
|
+
if (samePathForPlatform(linkTarget, canonicalSessions, process.platform)) {
|
|
725
|
+
return actualTarget === null || samePathForPlatform(actualTarget, canonicalSessions, process.platform);
|
|
726
|
+
}
|
|
727
|
+
if (actualTarget === null) return false;
|
|
728
|
+
if (!(await optionalStat(linkTarget))?.isDirectory()) return false;
|
|
729
|
+
for (const home of transaction.homes){
|
|
730
|
+
if (home === sourceHome || home === transaction.targetHome) continue;
|
|
731
|
+
const candidate = join(home, 'sessions');
|
|
732
|
+
if (!samePathForPlatform(linkTarget, candidate, process.platform)) continue;
|
|
733
|
+
if (!(await optionalStat(candidate))?.isDirectory()) continue;
|
|
734
|
+
if (samePathForPlatform(actualTarget, await canonicalCodeMProfileHome(candidate), process.platform)) {
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
function transactionSessionRoots(transaction) {
|
|
741
|
+
const roots = transaction.homes.flatMap((home)=>CODEM_PROFILE_SESSION_TREES.map((name)=>join(home, name)));
|
|
742
|
+
for (const home of transaction.archivedSessions){
|
|
743
|
+
for (const name of CODEM_PROFILE_SESSION_TREES){
|
|
744
|
+
roots.push(join(home, '.app-profile-archive', transaction.id, name));
|
|
745
|
+
roots.push(join(home, '.app-profile-archive', transaction.id, 'conflicts', name));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return [
|
|
749
|
+
...new Set(roots)
|
|
750
|
+
];
|
|
751
|
+
}
|
|
752
|
+
async function requireTransactionReady(transaction) {
|
|
753
|
+
const pendingFiles = [];
|
|
754
|
+
for (const file of transaction.files){
|
|
755
|
+
if (digest(await readOptional(file.path)) !== file.after) pendingFiles.push(file);
|
|
756
|
+
}
|
|
757
|
+
await requireIdentityWritersStopped(pendingFiles);
|
|
758
|
+
await requireTransactionSessionsIdle(transaction);
|
|
759
|
+
}
|
|
760
|
+
async function requireTransactionSessionsIdle(transaction) {
|
|
761
|
+
for (const home of transaction.homes){
|
|
762
|
+
const entries = await Promise.all(CODEM_PROFILE_SESSION_TREES.map((name)=>optionalStat(join(home, name))));
|
|
763
|
+
if (entries.some(Boolean)) await requireIdleProfile(home);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function requireIdentityWritersStopped(files) {
|
|
767
|
+
const homes = new Set(files.filter((file)=>basename(file.path) !== LEGACY_HISTORY_OWNER_FILE).map((file)=>dirname(file.path)));
|
|
768
|
+
for (const home of homes){
|
|
769
|
+
await requireDaemonStopped(home);
|
|
770
|
+
await requireIdleProfile(home);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async function findPendingMigration(root, target) {
|
|
774
|
+
const canonicalBackup = join(root, hash(Buffer.from(target)));
|
|
775
|
+
const canonicalPending = await readPending(canonicalBackup);
|
|
776
|
+
const candidates = canonicalPending ? [
|
|
777
|
+
{
|
|
778
|
+
backupHome: canonicalBackup,
|
|
779
|
+
pending: canonicalPending
|
|
780
|
+
}
|
|
781
|
+
] : [];
|
|
782
|
+
const entries = await readdir(root, {
|
|
783
|
+
withFileTypes: true
|
|
784
|
+
}).catch((error)=>{
|
|
785
|
+
if (isMissing(error) || error instanceof Error && 'code' in error && error.code === 'ENOTDIR') return [];
|
|
786
|
+
throw error;
|
|
787
|
+
});
|
|
788
|
+
for (const entry of entries){
|
|
789
|
+
const backupHome = join(root, entry.name);
|
|
790
|
+
if (!entry.isDirectory() || !/^[a-f0-9]{64}$/u.test(entry.name) || backupHome === canonicalBackup) continue;
|
|
791
|
+
try {
|
|
792
|
+
const pending = await readPending(backupHome);
|
|
793
|
+
if (!pending) continue;
|
|
794
|
+
const parsed = TransactionSchema.safeParse(JSON.parse(pending.toString('utf8')));
|
|
795
|
+
if (!parsed.success || !isAbsolute(parsed.data.targetHome)) continue;
|
|
796
|
+
if (await canonicalCodeMProfileHome(parsed.data.targetHome) === target) candidates.push({
|
|
797
|
+
backupHome,
|
|
798
|
+
pending
|
|
799
|
+
});
|
|
800
|
+
} catch {}
|
|
801
|
+
}
|
|
802
|
+
if (candidates.length > 1) throw new CodeMProfileMigrationError('该资料存在多份未完成的迁移记录。备份已保留,请选择其他资料目录或暂不处理,并联系支持恢复。');
|
|
803
|
+
return candidates[0] ?? {
|
|
804
|
+
backupHome: canonicalBackup,
|
|
805
|
+
pending: null
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
async function readPending(backupHome) {
|
|
809
|
+
const path = join(backupHome, 'pending.json');
|
|
810
|
+
const info = await lstat(path).catch((error)=>{
|
|
811
|
+
if (isMissing(error) || error instanceof Error && 'code' in error && error.code === 'ENOTDIR') return null;
|
|
812
|
+
throw error;
|
|
813
|
+
});
|
|
814
|
+
return info ? readOptional(path) : null;
|
|
815
|
+
}
|
|
816
|
+
async function parseTransaction(bytes, homes, target) {
|
|
817
|
+
const parsed = TransactionSchema.safeParse(parseObject(bytes, '迁移记录'));
|
|
818
|
+
if (!parsed.success) throw new CodeMProfileMigrationError('资料迁移记录损坏,请保留现场并联系支持。');
|
|
819
|
+
const transaction = {
|
|
820
|
+
...parsed.data,
|
|
821
|
+
targetHome: await canonicalCodeMProfileHome(parsed.data.targetHome),
|
|
822
|
+
homes: await Promise.all(parsed.data.homes.map(canonicalCodeMProfileHome)),
|
|
823
|
+
archivedSessions: await Promise.all(parsed.data.archivedSessions.map(canonicalCodeMProfileHome)),
|
|
824
|
+
files: await Promise.all(parsed.data.files.map(async (file)=>({
|
|
825
|
+
...file,
|
|
826
|
+
path: join(await canonicalCodeMProfileHome(dirname(file.path)), basename(file.path))
|
|
827
|
+
})))
|
|
828
|
+
};
|
|
829
|
+
const permitted = new Set(homes.flatMap((home)=>IDENTITY_FILES.map((name)=>join(home, name))));
|
|
830
|
+
if (transaction.targetHome !== target || !transaction.homes.includes(target) || new Set(transaction.homes).size !== transaction.homes.length || new Set(transaction.files.map((file)=>file.path)).size !== transaction.files.length || transaction.homes.some((home)=>!homes.includes(home)) || transaction.archivedSessions.some((home)=>!homes.includes(home)) || transaction.files.some((file)=>!permitted.has(file.path))) {
|
|
831
|
+
throw new CodeMProfileMigrationError('资料迁移目录发生变化,请使用原目录完成恢复。');
|
|
832
|
+
}
|
|
833
|
+
return transaction;
|
|
834
|
+
}
|
|
835
|
+
function inspectProfile(home, bundle, serverOrigin) {
|
|
836
|
+
const bytes = bundle.get('config.json');
|
|
837
|
+
if (!bytes) {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
const config = parseObject(bytes, home);
|
|
841
|
+
const device = object(config.device);
|
|
842
|
+
const oauth = object(object(config.auth)?.oauth);
|
|
843
|
+
const last = object(config.lastLoginUser ?? config.last_login_user);
|
|
844
|
+
const deviceId = string(device?.deviceId ?? device?.device_id);
|
|
845
|
+
const hasHistory = Boolean(oauth || last || device || config.user);
|
|
846
|
+
if (!deviceId) {
|
|
847
|
+
if (hasHistory) throw new CodeMProfileMigrationError(`已有登录资料缺少设备标识,已停止迁移以避免新建设备:${home}`);
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
if (deviceId.length > 128) throw new CodeMProfileMigrationError(`已有设备标识格式异常:${home}`);
|
|
851
|
+
let origin;
|
|
852
|
+
try {
|
|
853
|
+
const url = new URL(String(config.serverUrl));
|
|
854
|
+
if (url.username || url.password || url.search || url.hash) throw new Error('invalid server URL');
|
|
855
|
+
origin = url.origin;
|
|
856
|
+
} catch {
|
|
857
|
+
throw new CodeMProfileMigrationError(`已有设备资料缺少有效服务地址:${home}`);
|
|
858
|
+
}
|
|
859
|
+
if (origin !== serverOrigin) throw new CodeMProfileMigrationError(`资料属于其他服务环境,不能混用:${home}`);
|
|
860
|
+
const user = resolveCodeMAuthenticationUser({
|
|
861
|
+
loggedIn: true,
|
|
862
|
+
authMethod: null,
|
|
863
|
+
routerCredential: null,
|
|
864
|
+
serverUrl: origin,
|
|
865
|
+
tenantId: null,
|
|
866
|
+
userId: null,
|
|
867
|
+
displayName: null
|
|
868
|
+
}, config);
|
|
869
|
+
const lastTenantId = string(last?.tenantId ?? last?.tenant_id);
|
|
870
|
+
const lastUserId = string(last?.userId ?? last?.user_id);
|
|
871
|
+
const tenantId = user.tenantId ?? lastTenantId;
|
|
872
|
+
const userId = user.userId ?? lastUserId;
|
|
873
|
+
if (lastUserId !== null && userId !== lastUserId || lastTenantId !== null && tenantId !== lastTenantId) {
|
|
874
|
+
throw new CodeMProfileMigrationError(`登录资料中的账号信息不一致:${home}`);
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
home,
|
|
878
|
+
deviceId,
|
|
879
|
+
accountKey: tenantId && userId ? JSON.stringify([
|
|
880
|
+
origin,
|
|
881
|
+
tenantId,
|
|
882
|
+
userId
|
|
883
|
+
]) : null,
|
|
884
|
+
accountLabel: user.displayName ?? string(last?.displayName ?? last?.display_name) ?? userId ?? '已退出的账号',
|
|
885
|
+
signedIn: Boolean(oauth && string(device?.deviceSecret ?? device?.device_secret))
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
async function readBundle(home) {
|
|
889
|
+
return new Map(await Promise.all(IDENTITY_FILES.map(async (name)=>[
|
|
890
|
+
name,
|
|
891
|
+
await readOptional(join(home, name))
|
|
892
|
+
])));
|
|
893
|
+
}
|
|
894
|
+
async function requireDaemonStopped(home) {
|
|
895
|
+
const bytes = await readOptional(join(home, 'daemon.pid'));
|
|
896
|
+
if (bytes) {
|
|
897
|
+
const line = bytes.toString('utf8').split(/\r?\n/u)[0] ?? '';
|
|
898
|
+
const pid = /^\d+$/u.test(line) ? Number(line) : NaN;
|
|
899
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
900
|
+
throw new CodeMProfileMigrationBlockedError(`无法确认该目录的后台服务状态,请检查资料后重试:${home}`, 'writer-inspection-failed', []);
|
|
901
|
+
}
|
|
902
|
+
const liveness = probeProcessLiveness(pid);
|
|
903
|
+
if (liveness.kind === 'missing') return;
|
|
904
|
+
throw new CodeMProfileMigrationBlockedError(`该目录的后台服务仍在运行或无法确认已退出。请先结束任务并暂停后台服务,然后在此页面重试:${home}`, liveness.kind === 'alive' ? 'daemon-active' : 'writer-inspection-failed', [
|
|
905
|
+
{
|
|
906
|
+
kind: 'cli-process',
|
|
907
|
+
processId: pid,
|
|
908
|
+
processName: 'codem-daemon'
|
|
909
|
+
}
|
|
910
|
+
], liveness.kind === 'unavailable' ? {
|
|
911
|
+
cause: liveness.cause
|
|
912
|
+
} : undefined);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
async function requireIdleProfile(home) {
|
|
916
|
+
try {
|
|
917
|
+
await requireIdleProfileSessions(home);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
throw profileMigrationBlocker(error, home, 'writer-inspection-failed');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
function profileMigrationBlocker(error, home, fallbackReason) {
|
|
923
|
+
if (error instanceof CodeMProfileMigrationBlockedError) return error;
|
|
924
|
+
if (error instanceof CodeMProfileWriterBusyError) {
|
|
925
|
+
return new CodeMProfileMigrationBlockedError(error.message, 'session-writer-active', error.blockers, {
|
|
926
|
+
cause: error
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
if (error instanceof CodeMProfileWriterInspectionError) {
|
|
930
|
+
return new CodeMProfileMigrationBlockedError(error.message, 'writer-inspection-failed', [], {
|
|
931
|
+
cause: error
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
return new CodeMProfileMigrationBlockedError(error instanceof Error ? error.message : `无法确认资料是否仍在使用:${home}`, fallbackReason, [], {
|
|
935
|
+
cause: error
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
async function withProfileLocks(homes, operation) {
|
|
939
|
+
const paths = [
|
|
940
|
+
...homes
|
|
941
|
+
].sort((left, right)=>left.localeCompare(right)).flatMap((home)=>[
|
|
942
|
+
join(home, '.credentials.json.shared'),
|
|
943
|
+
join(home, 'config.json'),
|
|
944
|
+
join(home, 'credential-storage.json')
|
|
945
|
+
]);
|
|
946
|
+
const next = async (index)=>{
|
|
947
|
+
if (index < paths.length) return withCodeMConfigLock(paths[index], ()=>next(index + 1));
|
|
948
|
+
for (const home of homes){
|
|
949
|
+
if (hasCredentialStorage(await readBundle(home))) {
|
|
950
|
+
throw new CodeMProfileMigrationError('系统凭据资料需要由 CodeM CLI 迁移,当前已保留原资料和备份,请稍后重试。');
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return operation();
|
|
954
|
+
};
|
|
955
|
+
return next(0);
|
|
956
|
+
}
|
|
957
|
+
export async function canonicalCodeMProfileHome(home) {
|
|
958
|
+
if (!isAbsolute(home)) throw new CodeMProfileMigrationError('CodeM 资料目录必须是绝对路径。');
|
|
959
|
+
let ancestor = resolve(home);
|
|
960
|
+
const tail = [];
|
|
961
|
+
for(;;){
|
|
962
|
+
try {
|
|
963
|
+
return join(await realpath(ancestor), ...tail);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
const parent = dirname(ancestor);
|
|
966
|
+
if (!isMissing(error) || parent === ancestor) throw new CodeMProfileMigrationError(`无法访问资料目录:${home}`);
|
|
967
|
+
tail.unshift(basename(ancestor));
|
|
968
|
+
ancestor = parent;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
async function readOptional(path) {
|
|
973
|
+
const info = await optionalStat(path);
|
|
974
|
+
if (!info) return null;
|
|
975
|
+
if (!info.isFile() || info.size > 16 * 1024 * 1024) throw new CodeMProfileMigrationError(`资料文件类型或大小异常:${path}`);
|
|
976
|
+
try {
|
|
977
|
+
return await readFile(path);
|
|
978
|
+
} catch {
|
|
979
|
+
throw new CodeMProfileMigrationError(`无法读取资料文件:${path}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async function optionalStat(path) {
|
|
983
|
+
try {
|
|
984
|
+
return await lstat(path);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
if (isMissing(error)) return null;
|
|
987
|
+
throw new CodeMProfileMigrationError(`无法访问资料路径:${path}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async function optionalRealpath(path) {
|
|
991
|
+
try {
|
|
992
|
+
return await realpath(path);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
if (isMissing(error)) return null;
|
|
995
|
+
throw new CodeMProfileMigrationError(`无法访问资料路径:${path}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
function parseObject(bytes, path) {
|
|
999
|
+
try {
|
|
1000
|
+
const value = object(JSON.parse(bytes.toString('utf8')));
|
|
1001
|
+
if (value) return value;
|
|
1002
|
+
} catch {}
|
|
1003
|
+
throw new CodeMProfileMigrationError(`资料文件损坏:${path}`);
|
|
1004
|
+
}
|
|
1005
|
+
function object(value) {
|
|
1006
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
1007
|
+
}
|
|
1008
|
+
function string(value) {
|
|
1009
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
1010
|
+
}
|
|
1011
|
+
function isMissing(error) {
|
|
1012
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
1013
|
+
}
|
|
1014
|
+
function hash(bytes) {
|
|
1015
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
1016
|
+
}
|
|
1017
|
+
function digest(bytes) {
|
|
1018
|
+
return bytes === null ? null : hash(bytes);
|
|
1019
|
+
}
|
|
1020
|
+
function sameBundle(left, right) {
|
|
1021
|
+
return IDENTITY_FILES.every((name)=>digest(left.get(name) ?? null) === digest(right.get(name) ?? null));
|
|
1022
|
+
}
|