i18-fe-automator-beta 2.1.8 → 2.1.10
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/dist/commonjs/index.js +1504 -126
- package/dist/esm/index.mjs +1504 -126
- package/package.json +2 -1
package/dist/commonjs/index.js
CHANGED
|
@@ -17,6 +17,8 @@ var xlsx = require('node-xlsx');
|
|
|
17
17
|
var crypto = require('crypto');
|
|
18
18
|
var querystring = require('querystring');
|
|
19
19
|
var uuid = require('uuid');
|
|
20
|
+
var os = require('os');
|
|
21
|
+
var AdmZip = require('adm-zip');
|
|
20
22
|
var eslint = require('eslint');
|
|
21
23
|
var espree = require('espree');
|
|
22
24
|
var node_module = require('node:module');
|
|
@@ -26,7 +28,6 @@ require('shelljs');
|
|
|
26
28
|
var process$1 = require('child_process');
|
|
27
29
|
var _ = require('lodash');
|
|
28
30
|
var request$1 = require('request-promise');
|
|
29
|
-
require('os');
|
|
30
31
|
require('console');
|
|
31
32
|
|
|
32
33
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
@@ -107,6 +108,68 @@ class CheckboxAllPrompt extends CheckboxPrompt {
|
|
|
107
108
|
super.onInverseKey();
|
|
108
109
|
this.syncAllChoice();
|
|
109
110
|
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 组联动式checkbox prompt:
|
|
115
|
+
* - 组开关选项标记 groupToggle: '<组名>',组成员选项标记 group: '<组名>'
|
|
116
|
+
* - 勾选/取消组开关:该组全部成员同步勾选/取消(可视化联动,而非静默取集合)
|
|
117
|
+
* - 切换组成员:组内全勾时开关自动点亮,任一取消则开关熄灭
|
|
118
|
+
* - 未标记 group 的选项为普通选项,不参与任何联动
|
|
119
|
+
*/
|
|
120
|
+
class CheckboxGroupPrompt extends CheckboxPrompt {
|
|
121
|
+
getGroupToggles() {
|
|
122
|
+
return this.opt.choices.filter(
|
|
123
|
+
(choice) =>
|
|
124
|
+
choice.type !== "separator" && choice.groupToggle && !choice.disabled
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getGroupMembers(groupId) {
|
|
129
|
+
return this.opt.choices.filter(
|
|
130
|
+
(choice) =>
|
|
131
|
+
choice.type !== "separator" &&
|
|
132
|
+
choice.group === groupId &&
|
|
133
|
+
!choice.disabled
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 根据各组勾选情况同步开关状态(组内全勾才亮)
|
|
138
|
+
syncGroupToggles() {
|
|
139
|
+
this.getGroupToggles().forEach((toggle) => {
|
|
140
|
+
const members = this.getGroupMembers(toggle.groupToggle);
|
|
141
|
+
if (members.length) {
|
|
142
|
+
toggle.checked = members.every((item) => item.checked);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 覆写toggleChoice: 空格键和数字键选择都会经过这里,统一处理联动
|
|
148
|
+
toggleChoice(index) {
|
|
149
|
+
const choice = this.opt.choices.getChoice(index);
|
|
150
|
+
if (choice && choice.groupToggle) {
|
|
151
|
+
// 切换组开关:该组成员全部跟随
|
|
152
|
+
const checked = !choice.checked;
|
|
153
|
+
choice.checked = checked;
|
|
154
|
+
this.getGroupMembers(choice.groupToggle).forEach((item) => {
|
|
155
|
+
item.checked = checked;
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
super.toggleChoice(index);
|
|
159
|
+
this.syncGroupToggles();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 按a全选/按i反选后,同步各组开关状态
|
|
164
|
+
onAllKey() {
|
|
165
|
+
super.onAllKey();
|
|
166
|
+
this.syncGroupToggles();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
onInverseKey() {
|
|
170
|
+
super.onInverseKey();
|
|
171
|
+
this.syncGroupToggles();
|
|
172
|
+
}
|
|
110
173
|
}
|
|
111
174
|
|
|
112
175
|
function Loading() {
|
|
@@ -1803,7 +1866,7 @@ async function addSassPermission(cliOpts = {}) {
|
|
|
1803
1866
|
|
|
1804
1867
|
// 禅道基础地址/缓存键(联调不符只改这里)
|
|
1805
1868
|
const ZENTAO_BASE = "https://zentao.hongxinshop.com/zentao";
|
|
1806
|
-
const CACHE_KEY = "zentao"; // .cache/zentao: { account, password, zentaosid, products: [{id, name}] }
|
|
1869
|
+
const CACHE_KEY$1 = "zentao"; // .cache/zentao: { account, password, zentaosid, products: [{id, name}] }
|
|
1807
1870
|
|
|
1808
1871
|
// 需求列表展示与pull并发的上限
|
|
1809
1872
|
const PLAN_STORY_TABLE_LIMIT = 50;
|
|
@@ -1832,12 +1895,12 @@ function ensureCacheDir() {
|
|
|
1832
1895
|
// 读取禅道凭据/会话缓存(不存在/损坏返回 {})
|
|
1833
1896
|
function readZentaoCache() {
|
|
1834
1897
|
const cacheFilePath =
|
|
1835
|
-
getRunCliPath({ root: true }) + "/.cache/" + CACHE_KEY;
|
|
1898
|
+
getRunCliPath({ root: true }) + "/.cache/" + CACHE_KEY$1;
|
|
1836
1899
|
if (!isExistPath(cacheFilePath)) {
|
|
1837
1900
|
return {};
|
|
1838
1901
|
}
|
|
1839
1902
|
try {
|
|
1840
|
-
return JSON.parse(readCache(CACHE_KEY).toString("utf8")) || {};
|
|
1903
|
+
return JSON.parse(readCache(CACHE_KEY$1).toString("utf8")) || {};
|
|
1841
1904
|
} catch (error) {
|
|
1842
1905
|
return {};
|
|
1843
1906
|
}
|
|
@@ -1847,7 +1910,7 @@ function readZentaoCache() {
|
|
|
1847
1910
|
function writeZentaoCache(obj) {
|
|
1848
1911
|
ensureCacheDir();
|
|
1849
1912
|
writeCache(
|
|
1850
|
-
CACHE_KEY,
|
|
1913
|
+
CACHE_KEY$1,
|
|
1851
1914
|
JSON.stringify({ ...readZentaoCache(), ...obj })
|
|
1852
1915
|
);
|
|
1853
1916
|
}
|
|
@@ -2116,7 +2179,7 @@ function askZentaoCredentials(cliOpts) {
|
|
|
2116
2179
|
password: cliOpts.password,
|
|
2117
2180
|
},
|
|
2118
2181
|
useCache: Boolean(cliOpts.cache),
|
|
2119
|
-
cacheKey: CACHE_KEY,
|
|
2182
|
+
cacheKey: CACHE_KEY$1,
|
|
2120
2183
|
}).then((secret) => {
|
|
2121
2184
|
// resolveSecretQuestions 会整文件覆盖回写缓存(只剩凭据字段, 发生在登录之前),
|
|
2122
2185
|
// 合并写补回 zentaosid 等字段, 防止登录失败时已缓存的有效会话被抹掉
|
|
@@ -2289,15 +2352,46 @@ function fetchProjectStories(zentaosid, projectID) {
|
|
|
2289
2352
|
// 拉项目任务列表(全部状态, 不含已删除; 供勾选时统计已建任务数 + 创建前后 diff 拿新任务ID)
|
|
2290
2353
|
// 注1: 不用禅道 storyWebTasks/storyDevTasks 计数——软删除(回收站)的任务也计入, 数据不准(实测)
|
|
2291
2354
|
// 注2: pager.recTotal 同样含回收站任务(返回数恒小于 recTotal 属正常), 翻页判定用 pageTotal
|
|
2292
|
-
// 注3: 子任务不出现在任务列表(挂在父任务 children 下),
|
|
2293
|
-
|
|
2294
|
-
|
|
2355
|
+
// 注3: 子任务不出现在任务列表(挂在父任务 children 下), 且列表的 children 字段残缺(只带1个子任务, 实测),
|
|
2356
|
+
// 子任务统计须按 parent=-1 圈真父任务逐个拉 task-view
|
|
2357
|
+
// 注4: 任务超单页时按 pageTotal 循环翻页拉全, 某页失败降级黄字提示不阻塞(该页任务缺计数),
|
|
2358
|
+
// 翻页拉全后不再有"计数不全"提醒
|
|
2359
|
+
// 注5: 翻页路由格式(实测从任务列表页 pager 的 data-link-creator 提取):
|
|
2360
|
+
// project-task-<ID>-all-0--<recTotal>-<recPerPage>-<页码>;
|
|
2361
|
+
// 错误示范: project-task-<ID>-all-<页码> 禅道不认, 静默返回第1页 → 任务重复计数(踩坑实录)
|
|
2362
|
+
async function fetchProjectTasks(zentaosid, projectID) {
|
|
2363
|
+
const first = await zentaoRequest({
|
|
2295
2364
|
route: `project-task-${projectID}-all`,
|
|
2296
2365
|
zentaosid,
|
|
2297
|
-
})
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2366
|
+
});
|
|
2367
|
+
const tasks = Object.values(first.tasks || {});
|
|
2368
|
+
const pager = first.pager || {};
|
|
2369
|
+
const pageTotal = Number(pager.pageTotal || 1);
|
|
2370
|
+
for (let page = 2; page <= pageTotal; page += 1) {
|
|
2371
|
+
// ora 的 text 是属性而非方法
|
|
2372
|
+
Loading$1.text = `拉取项目任务中(第${page}/${pageTotal}页)...`;
|
|
2373
|
+
try {
|
|
2374
|
+
const next = await zentaoRequest({
|
|
2375
|
+
route: `project-task-${projectID}-all-0--${pager.recTotal}-${pager.recPerPage}-${page}`,
|
|
2376
|
+
zentaosid,
|
|
2377
|
+
});
|
|
2378
|
+
tasks.push(...Object.values(next.tasks || {}));
|
|
2379
|
+
} catch (e) {
|
|
2380
|
+
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试
|
|
2381
|
+
if (e?.isAuthError) throw e;
|
|
2382
|
+
console.log(
|
|
2383
|
+
chalk.yellow(`第${page}页任务拉取失败, 该页任务缺计数: ${e.message}`)
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
// 按任务ID去重兜底: 翻页路由若再被禅道静默忽略会返回重复数据, 重复计数直接污染前端/后端父任务数
|
|
2388
|
+
const seen = new Set();
|
|
2389
|
+
const unique = tasks.filter((t) => {
|
|
2390
|
+
if (seen.has(t.id)) return false;
|
|
2391
|
+
seen.add(t.id);
|
|
2392
|
+
return true;
|
|
2393
|
+
});
|
|
2394
|
+
return { tasks: unique, pageTotal };
|
|
2301
2395
|
}
|
|
2302
2396
|
|
|
2303
2397
|
// 拉单个任务详情(含children子任务映射, subtask命令与子任务统计用)
|
|
@@ -3212,7 +3306,8 @@ async function storyMain(action, cliOpts) {
|
|
|
3212
3306
|
validateChoice(cliOpts.type, ["front", "back"], "--type");
|
|
3213
3307
|
}
|
|
3214
3308
|
const session = await ensureZentaoSession(cliOpts);
|
|
3215
|
-
|
|
3309
|
+
// 具名回调: 交互模式一轮结束后可递归重入, 回到需求勾选列表(计数刷新); 一键模式单轮结束
|
|
3310
|
+
await session.request(async function taskRound(zentaosid) {
|
|
3216
3311
|
// 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
|
|
3217
3312
|
Loading$1.start("校验项目中...");
|
|
3218
3313
|
const projectMap = await fetchProjectMap(zentaosid);
|
|
@@ -3236,22 +3331,14 @@ async function storyMain(action, cliOpts) {
|
|
|
3236
3331
|
}
|
|
3237
3332
|
// 每条需求的前后端已建任务计数(勾选时提示防重复建)
|
|
3238
3333
|
// 自行从项目任务列表统计(禅道 storyWebTasks/storyDevTasks 会把回收站任务也计入, 实测不准)
|
|
3239
|
-
Loading$1.start("
|
|
3334
|
+
Loading$1.start("拉取项目任务中(第1页)...");
|
|
3240
3335
|
const { tasks: projectTasks, pageTotal } = await fetchProjectTasks(
|
|
3241
3336
|
zentaosid,
|
|
3242
3337
|
projectID
|
|
3243
3338
|
);
|
|
3244
3339
|
Loading$1.succeed(
|
|
3245
|
-
`拉取项目任务成功(${projectTasks.length}
|
|
3340
|
+
`拉取项目任务成功(${projectTasks.length}条/${pageTotal}页, 需求计数用)`
|
|
3246
3341
|
);
|
|
3247
|
-
// 任务超单页时计数可能不全(禅道翻页路由不稳定, 只提示不翻页)
|
|
3248
|
-
if (pageTotal > 1) {
|
|
3249
|
-
console.log(
|
|
3250
|
-
chalk.yellow(
|
|
3251
|
-
`项目任务超过单页(${pageTotal}页), 已建计数可能不全`
|
|
3252
|
-
)
|
|
3253
|
-
);
|
|
3254
|
-
}
|
|
3255
3342
|
// 前端任务判定: type=frontend 或 名称【前端】前缀(修复前的旧数据 type 误传 devel)
|
|
3256
3343
|
const isFrontTask = (t) =>
|
|
3257
3344
|
t.type === "frontend" || String(t.name || "").startsWith("【前端】");
|
|
@@ -3260,15 +3347,15 @@ async function storyMain(action, cliOpts) {
|
|
|
3260
3347
|
const webCount = (s) => tasksOf(s).filter(isFrontTask).length;
|
|
3261
3348
|
const devCount = (s) =>
|
|
3262
3349
|
tasksOf(s).filter((t) => t.type === "devel" && !isFrontTask(t)).length;
|
|
3263
|
-
|
|
3264
|
-
//
|
|
3265
|
-
//
|
|
3266
|
-
//
|
|
3267
|
-
//
|
|
3350
|
+
// 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情, 并发限流控制请求量
|
|
3351
|
+
// 注1: parent 字段三态(实测): 0=独立任务(无子任务) | -1=自己是父任务(有子任务) | >0=挂在别人下的子任务
|
|
3352
|
+
// 注2: 候选只拉 parent=-1 的真父任务即可——列表里 parent=0 的独立任务不会有 children(实测抽查),
|
|
3353
|
+
// 且列表 children 字段本身残缺(只带1个, 详情才全), 逐父任务拉 task-view 是唯一准确途径;
|
|
3354
|
+
// 此前按 estimate>8 圈候选会漏掉 ≤8h 却手动拆过子任务的父任务, 且请求量大(89个→15个, 6倍提速)
|
|
3268
3355
|
const subCountMap = new Map(); // 任务ID -> 子任务数
|
|
3269
3356
|
{
|
|
3270
3357
|
const candidates = projectTasks.filter(
|
|
3271
|
-
(t) => Number(t.
|
|
3358
|
+
(t) => Number(t.parent) === -1
|
|
3272
3359
|
);
|
|
3273
3360
|
if (candidates.length) {
|
|
3274
3361
|
Loading$1.start(`统计子任务 (0/${candidates.length})...`);
|
|
@@ -3301,6 +3388,8 @@ async function storyMain(action, cliOpts) {
|
|
|
3301
3388
|
}
|
|
3302
3389
|
// 先勾选需求: 一键(--type直通)自动取候选; 交互则列出项目全部需求
|
|
3303
3390
|
let picked;
|
|
3391
|
+
// 交互勾了「一键前端」的标志(供后续跳过任务类型询问, 前端已隐含)
|
|
3392
|
+
let frontTodoPicked = false;
|
|
3304
3393
|
if (cliOpts.type !== undefined) {
|
|
3305
3394
|
const presetLabel = cliOpts.type === "front" ? "前端" : "后端";
|
|
3306
3395
|
const hourOfPreset = (s) =>
|
|
@@ -3345,85 +3434,129 @@ async function storyMain(action, cliOpts) {
|
|
|
3345
3434
|
? Math.max(...frontTasks.map((t) => Number(t.estimate) || 0))
|
|
3346
3435
|
: Number(s.webestimate);
|
|
3347
3436
|
};
|
|
3437
|
+
// 后端子任务计数/基准工时: 与前端同构(父任务=devel类任务, 无父任务用需求estimate)
|
|
3438
|
+
const isBackTask = (t) => t.type === "devel" && !isFrontTask(t);
|
|
3439
|
+
const backSubsOf = (s) =>
|
|
3440
|
+
tasksOf(s)
|
|
3441
|
+
.filter(isBackTask)
|
|
3442
|
+
.reduce(
|
|
3443
|
+
(sum, t) => sum + (subCountMap.get(String(t.id)) || 0),
|
|
3444
|
+
0
|
|
3445
|
+
);
|
|
3446
|
+
const backBaseHours = (s) => {
|
|
3447
|
+
const backTasks = tasksOf(s).filter(isBackTask);
|
|
3448
|
+
return backTasks.length
|
|
3449
|
+
? Math.max(...backTasks.map((t) => Number(t.estimate) || 0))
|
|
3450
|
+
: Number(s.estimate);
|
|
3451
|
+
};
|
|
3452
|
+
// 一键前端: 批量选中"前端待办"(有前端工时且未建父任务, 或有父任务但子任务未满)
|
|
3453
|
+
const frontTodo = pj.stories.filter((s) => {
|
|
3454
|
+
if (!(Number(s.webestimate) > 0)) return false;
|
|
3455
|
+
if (webCount(s) === 0) return true;
|
|
3456
|
+
return (
|
|
3457
|
+
frontBaseHours(s) > 8 &&
|
|
3458
|
+
frontSubsOf(s) < Math.ceil(frontBaseHours(s) / 8)
|
|
3459
|
+
);
|
|
3460
|
+
});
|
|
3461
|
+
const FRONT_TODO = "__FRONT_TODO__";
|
|
3462
|
+
// 组联动式checkbox: 勾「一键前端」下方待办需求同步点亮/熄灭(可视化联动), 取消个别则开关熄灭
|
|
3463
|
+
inquirer.registerPrompt("checkbox-group", CheckboxGroupPrompt);
|
|
3348
3464
|
({ picked } = await inquirer.prompt([
|
|
3349
3465
|
{
|
|
3350
|
-
message: "请勾选要建任务的需求(空格选择,
|
|
3466
|
+
message: "请勾选要建任务的需求(空格选择, 回车确认; 「一键前端」联动勾选前端待办)",
|
|
3351
3467
|
name: "picked",
|
|
3352
|
-
type: "checkbox",
|
|
3468
|
+
type: "checkbox-group",
|
|
3353
3469
|
// 到列表边界停止, 不循环回第一条
|
|
3354
3470
|
loop: false,
|
|
3355
|
-
choices: [
|
|
3356
|
-
|
|
3357
|
-
//
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
rank
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3471
|
+
choices: [
|
|
3472
|
+
{
|
|
3473
|
+
// 无待办时置灰; groupToggle组开关, 联动所有group:front成员
|
|
3474
|
+
name: `一键前端(${frontTodo.length}条: 未建父任务/子任务未满)`,
|
|
3475
|
+
value: FRONT_TODO,
|
|
3476
|
+
short: "一键前端",
|
|
3477
|
+
groupToggle: "front",
|
|
3478
|
+
disabled: frontTodo.length ? false : "无前端待办",
|
|
3479
|
+
},
|
|
3480
|
+
...[...pj.stories]
|
|
3481
|
+
.sort((a, b) => {
|
|
3482
|
+
// 待办优先级: 0=前端有工时且未建任务(子任务也必无, 全新建)
|
|
3483
|
+
// 1=前端有任务但子任务未满(待补拆, 基准=父任务实际工时) 2=其余有前端工时 3=无前端工时
|
|
3484
|
+
// 组内按需求ID从小到大
|
|
3485
|
+
const rank = (s) => {
|
|
3486
|
+
if (!(Number(s.webestimate) > 0)) return 3;
|
|
3487
|
+
const base = frontBaseHours(s);
|
|
3488
|
+
if (webCount(s) === 0) return 0;
|
|
3489
|
+
return base > 8 && frontSubsOf(s) < Math.ceil(base / 8)
|
|
3490
|
+
? 1
|
|
3491
|
+
: 2;
|
|
3492
|
+
};
|
|
3493
|
+
return (
|
|
3494
|
+
rank(a) - rank(b) ||
|
|
3495
|
+
Number(a.id) - Number(b.id)
|
|
3496
|
+
);
|
|
3497
|
+
})
|
|
3498
|
+
.map((s, i) => {
|
|
3499
|
+
// 任务计数(前后端一排, " / "分隔): x个父任务 + 子任务进度(已建/应建)
|
|
3500
|
+
// 子任务进度仅基准工时>8h展示: 应建 = ceil(基准工时/8), 基准=该类父任务实际工时(无父任务用需求该类工时)
|
|
3501
|
+
// 未满应建数红色, 满则绿色; 工时与计数合并为一行(标题行内联), 无该类工时不输出该段
|
|
3502
|
+
const taskLine = (label, hours, count, baseHours, subs) => {
|
|
3503
|
+
let text = `${label}${hours}h,${count}个父任务`;
|
|
3504
|
+
if (baseHours > 8) {
|
|
3505
|
+
const total = Math.ceil(baseHours / 8);
|
|
3506
|
+
const progress =
|
|
3507
|
+
subs >= total
|
|
3508
|
+
? `${subs}/${chalk.green(total)}`
|
|
3509
|
+
: `${subs}/${chalk.red(total)}`;
|
|
3510
|
+
text += `,${progress}子任务`;
|
|
3511
|
+
}
|
|
3512
|
+
return text;
|
|
3382
3513
|
};
|
|
3383
|
-
const
|
|
3384
|
-
const base = frontBaseHours(s);
|
|
3385
|
-
// 前端: 任务数(子任务进度); 后端: 任务数(暂不展示子任务)
|
|
3386
|
-
const built = [
|
|
3514
|
+
const countParts = [
|
|
3387
3515
|
Number(s.webestimate) > 0
|
|
3388
|
-
?
|
|
3389
|
-
base > 8 ? subProgress(base, frontSubs) : ""
|
|
3390
|
-
}`
|
|
3516
|
+
? taskLine("前端", s.webestimate, webCount(s), frontBaseHours(s), frontSubsOf(s))
|
|
3391
3517
|
: "",
|
|
3392
|
-
Number(s.estimate) > 0
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
// 父任务工时不足提示: 有前端父任务但工时 < 需求前端工时(黄字, 手建父任务漏填/填少)
|
|
3397
|
-
const
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3518
|
+
Number(s.estimate) > 0
|
|
3519
|
+
? taskLine("后端", s.estimate, devCount(s), backBaseHours(s), backSubsOf(s))
|
|
3520
|
+
: "",
|
|
3521
|
+
].filter(Boolean);
|
|
3522
|
+
// 父任务工时不足提示: 有前端父任务但工时 < 需求前端工时(黄字, 手建父任务漏填/填少), 追加同排末尾
|
|
3523
|
+
const frontBase = frontBaseHours(s);
|
|
3524
|
+
if (webCount(s) > 0 && frontBase < Number(s.webestimate)) {
|
|
3525
|
+
countParts.push(
|
|
3526
|
+
chalk.yellow(
|
|
3527
|
+
`父任务${frontBase}h<需求${s.webestimate}h, 不足!`
|
|
3528
|
+
)
|
|
3529
|
+
);
|
|
3530
|
+
}
|
|
3405
3531
|
return {
|
|
3406
|
-
name: `${i + 1}. #${s.id} ${s.title} (
|
|
3407
|
-
Number(s.webestimate) > 0 ? `${s.webestimate}h` : "-"
|
|
3408
|
-
} / 后端${
|
|
3409
|
-
Number(s.estimate) > 0 ? `${s.estimate}h` : "-"
|
|
3410
|
-
})${detail ? `\n ${detail}` : ""}`,
|
|
3532
|
+
name: `${i + 1}. #${s.id} ${s.title} (${countParts.join(" / ")})`,
|
|
3411
3533
|
value: s,
|
|
3412
|
-
//
|
|
3413
|
-
|
|
3534
|
+
// 前端待办成员: 受「一键前端」组开关联动勾选/取消
|
|
3535
|
+
...(frontTodo.includes(s) ? { group: "front" } : {}),
|
|
3414
3536
|
};
|
|
3415
3537
|
}),
|
|
3538
|
+
],
|
|
3416
3539
|
},
|
|
3417
3540
|
]));
|
|
3541
|
+
// 勾了「一键前端」: 提交结果按实际勾选的需求算(联动已把待办项点亮, 哨兵值剔除), 类型固定前端
|
|
3542
|
+
if (picked.includes(FRONT_TODO)) {
|
|
3543
|
+
frontTodoPicked = true;
|
|
3544
|
+
picked = picked.filter((v) => v !== FRONT_TODO);
|
|
3545
|
+
console.log(
|
|
3546
|
+
chalk.blue(`一键前端: 已选中 ${picked.length} 条前端待办需求`)
|
|
3547
|
+
);
|
|
3548
|
+
}
|
|
3418
3549
|
}
|
|
3419
3550
|
if (!picked.length) {
|
|
3420
3551
|
console.log(chalk.yellow("未勾选需求, 结束"));
|
|
3421
3552
|
return;
|
|
3422
3553
|
}
|
|
3423
|
-
// 再选任务类型: --type 直通(一键场景) > 选完需求后交互询问
|
|
3554
|
+
// 再选任务类型: --type 直通(一键场景) > 一键前端固定前端 > 选完需求后交互询问
|
|
3424
3555
|
let taskType;
|
|
3425
3556
|
if (cliOpts.type !== undefined) {
|
|
3426
3557
|
taskType = cliOpts.type;
|
|
3558
|
+
} else if (frontTodoPicked) {
|
|
3559
|
+
taskType = "front"; // 一键前端已隐含类型
|
|
3427
3560
|
} else {
|
|
3428
3561
|
({ taskType } = await inquirer.prompt([
|
|
3429
3562
|
{
|
|
@@ -3466,7 +3599,7 @@ async function storyMain(action, cliOpts) {
|
|
|
3466
3599
|
: t.type === "devel" && !isFrontTask(t)
|
|
3467
3600
|
);
|
|
3468
3601
|
// 处理模式(选择即确认): --split-only > --split > 交互列表 > 一键默认
|
|
3469
|
-
// parent=仅创建父任务 | parent-split
|
|
3602
|
+
// parent=仅创建父任务 | parent-split=拆分子任务(无父任务自动先建, ≤8h仅建父任务不拆) | split=仅拆分(--split-only专用, >8h才建父任务)
|
|
3470
3603
|
let taskMode;
|
|
3471
3604
|
if (cliOpts.splitOnly) {
|
|
3472
3605
|
taskMode = "split";
|
|
@@ -3487,6 +3620,7 @@ async function storyMain(action, cliOpts) {
|
|
|
3487
3620
|
},
|
|
3488
3621
|
]));
|
|
3489
3622
|
} else {
|
|
3623
|
+
// 交互合并原"创建父任务+拆分子任务"与"仅拆分"为一项: 无父任务自动先建(≤8h仅建父任务), >8h拆分, 已有父任务复用
|
|
3490
3624
|
({ taskMode } = await inquirer.prompt([
|
|
3491
3625
|
{
|
|
3492
3626
|
message: `将处理 ${picked.length} 条【${typeLabel}】任务(截止 ${pj.project.end}), 请选择模式`,
|
|
@@ -3494,9 +3628,10 @@ async function storyMain(action, cliOpts) {
|
|
|
3494
3628
|
type: "list",
|
|
3495
3629
|
choices: [
|
|
3496
3630
|
{ name: "1. 创建父任务", value: "parent" },
|
|
3497
|
-
{ name: "2.
|
|
3498
|
-
{ name: "3. 拆分子任务(无父任务自动先创建)", value: "split" },
|
|
3631
|
+
{ name: "2. 创建子任务(无父任务自动先创建, ≤8h不拆)", value: "parent-split" },
|
|
3499
3632
|
],
|
|
3633
|
+
// 默认选中2(创建子任务)
|
|
3634
|
+
default: "parent-split",
|
|
3500
3635
|
},
|
|
3501
3636
|
]));
|
|
3502
3637
|
}
|
|
@@ -3653,7 +3788,22 @@ async function storyMain(action, cliOpts) {
|
|
|
3653
3788
|
splitLogs.forEach((line) => console.log(line));
|
|
3654
3789
|
}
|
|
3655
3790
|
}
|
|
3656
|
-
//
|
|
3791
|
+
// 交互模式: 一轮结束询问是否继续(默认继续), 继续则回到需求勾选列表(任务计数重新拉取)
|
|
3792
|
+
if (cliOpts.type === undefined) {
|
|
3793
|
+
const { goNext } = await inquirer.prompt([
|
|
3794
|
+
{
|
|
3795
|
+
message: "是否继续处理其他需求? (继续回到需求勾选列表)",
|
|
3796
|
+
name: "goNext",
|
|
3797
|
+
type: "confirm",
|
|
3798
|
+
default: true,
|
|
3799
|
+
},
|
|
3800
|
+
]);
|
|
3801
|
+
if (goNext) {
|
|
3802
|
+
// 递归进入新一轮(会话失效仍由 session.request 兜底重登)
|
|
3803
|
+
return session.request(taskRound);
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
// 部分失败以非零退出码收尾(一键模式, 或交互模式选择结束时), 供自动化流程感知重试
|
|
3657
3807
|
if (failedStories.length) {
|
|
3658
3808
|
console.log(
|
|
3659
3809
|
chalk.red(
|
|
@@ -3808,7 +3958,8 @@ async function storyMain(action, cliOpts) {
|
|
|
3808
3958
|
}
|
|
3809
3959
|
const projectID = match[1];
|
|
3810
3960
|
const session = await ensureZentaoSession(cliOpts);
|
|
3811
|
-
|
|
3961
|
+
// 具名回调: 一轮关闭结束后可递归重入, 回到任务勾选列表(任务状态重新拉取)
|
|
3962
|
+
await session.request(async function closeRound(zentaosid) {
|
|
3812
3963
|
// 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
|
|
3813
3964
|
Loading$1.start("校验项目中...");
|
|
3814
3965
|
const projectMap = await fetchProjectMap(zentaosid);
|
|
@@ -3879,14 +4030,7 @@ async function storyMain(action, cliOpts) {
|
|
|
3879
4030
|
)
|
|
3880
4031
|
);
|
|
3881
4032
|
}
|
|
3882
|
-
//
|
|
3883
|
-
if (projectAll.pageTotal > 1) {
|
|
3884
|
-
console.log(
|
|
3885
|
-
chalk.yellow(
|
|
3886
|
-
`项目任务超过单页(${projectAll.pageTotal}页), 由我完成待关闭的任务可能漏列`
|
|
3887
|
-
)
|
|
3888
|
-
);
|
|
3889
|
-
}
|
|
4033
|
+
// fetchProjectTasks 已翻页拉全, "由我完成"兜底不再漏列(单页失败时其黄字已提示)
|
|
3890
4034
|
if (!tasks.length) {
|
|
3891
4035
|
console.log(
|
|
3892
4036
|
chalk.yellow("无指派给我的任务, 也无由我完成待关闭的任务, 结束")
|
|
@@ -3925,39 +4069,51 @@ async function storyMain(action, cliOpts) {
|
|
|
3925
4069
|
Loading$1.succeed("拉取任务详情完成");
|
|
3926
4070
|
}
|
|
3927
4071
|
// 勾选要完成的任务(完成后自动关闭; 到列表边界停止, 不循环回第一条)
|
|
4072
|
+
// 顶部「一键关闭」与下方任务联动: 勾上=全选可选任务, 取消任一下方任务则自动熄灭(已关闭项不参与)
|
|
4073
|
+
inquirer.registerPrompt("checkbox-all", CheckboxAllPrompt);
|
|
4074
|
+
const closableCount = tasks.filter((t) => t.status !== "closed").length;
|
|
3928
4075
|
const { picked } = await inquirer.prompt([
|
|
3929
4076
|
{
|
|
3930
|
-
message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭,
|
|
4077
|
+
message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭, 已关闭的仅展示不可选, 关闭全部子任务后父任务自动关闭)",
|
|
3931
4078
|
name: "picked",
|
|
3932
|
-
type: "checkbox",
|
|
4079
|
+
type: "checkbox-all",
|
|
3933
4080
|
loop: false,
|
|
3934
|
-
choices:
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
4081
|
+
choices: [
|
|
4082
|
+
{
|
|
4083
|
+
name: `一键关闭(共${closableCount}个可选任务)`,
|
|
4084
|
+
value: SELECT_ALL,
|
|
4085
|
+
short: "一键关闭",
|
|
4086
|
+
},
|
|
4087
|
+
...tasks.map((t) => {
|
|
4088
|
+
const detail = details.get(String(t.id));
|
|
4089
|
+
const children = Object.values(detail?.children || {});
|
|
4090
|
+
const closedCount = children.filter(
|
|
4091
|
+
(c) => c.status === "closed"
|
|
4092
|
+
).length;
|
|
4093
|
+
// 子任务进度并入工时括号内: 已关数/总数, 无子任务仅工时
|
|
4094
|
+
const childInfo = children.length
|
|
4095
|
+
? `${t.estimate || 0}h,子任务${closedCount}/${children.length}`
|
|
4096
|
+
: `${t.estimate || 0}h`;
|
|
4097
|
+
return {
|
|
4098
|
+
name: `#${t.id} [${TASK_STATUS_LABELS[t.status] || t.status}] ${t.name} (${childInfo})`,
|
|
4099
|
+
value: t,
|
|
4100
|
+
// 已关闭的仅展示, 不可勾选
|
|
4101
|
+
disabled: t.status === "closed" ? "已关闭" : false,
|
|
4102
|
+
};
|
|
4103
|
+
}),
|
|
4104
|
+
],
|
|
3951
4105
|
},
|
|
3952
4106
|
]);
|
|
3953
|
-
|
|
4107
|
+
// 「一键关闭」是哨兵值, 剔除后剩下的即勾选任务(联动勾上时下方已全选, 数组完整)
|
|
4108
|
+
const targets = picked.filter((v) => v !== SELECT_ALL);
|
|
4109
|
+
if (!targets.length) {
|
|
3954
4110
|
console.log(chalk.yellow("未勾选任务, 结束"));
|
|
3955
4111
|
return;
|
|
3956
4112
|
}
|
|
3957
4113
|
// 写操作确认(关闭属敏感操作, 二次确认防误关)
|
|
3958
4114
|
const { ok } = await inquirer.prompt([
|
|
3959
4115
|
{
|
|
3960
|
-
message: `确认处理勾选的 ${
|
|
4116
|
+
message: `确认处理勾选的 ${targets.length} 个任务? (未完成的先完成后关闭; 有子任务的逐个关闭子任务, 全关后父任务自动关闭)`,
|
|
3961
4117
|
name: "ok",
|
|
3962
4118
|
type: "confirm",
|
|
3963
4119
|
default: true,
|
|
@@ -3989,8 +4145,8 @@ async function storyMain(action, cliOpts) {
|
|
|
3989
4145
|
let failedCount = 0;
|
|
3990
4146
|
let done = 0;
|
|
3991
4147
|
const logs = [];
|
|
3992
|
-
Loading$1.start(`处理任务 (0/${
|
|
3993
|
-
for (const task of
|
|
4148
|
+
Loading$1.start(`处理任务 (0/${targets.length})...`);
|
|
4149
|
+
for (const task of targets) {
|
|
3994
4150
|
try {
|
|
3995
4151
|
// 详情缺失(预拉阶段失败的)现拉, 防父任务被误判成叶子任务直接 finish
|
|
3996
4152
|
const detail =
|
|
@@ -4056,12 +4212,25 @@ async function storyMain(action, cliOpts) {
|
|
|
4056
4212
|
}
|
|
4057
4213
|
done += 1;
|
|
4058
4214
|
// ora 的 text 是属性而非方法
|
|
4059
|
-
Loading$1.text = `处理任务 (${done}/${
|
|
4215
|
+
Loading$1.text = `处理任务 (${done}/${targets.length})...`;
|
|
4060
4216
|
}
|
|
4061
4217
|
Loading$1.succeed(
|
|
4062
|
-
`处理完成: 成功 ${
|
|
4218
|
+
`处理完成: 成功 ${targets.length - failedCount} 条 / 失败 ${failedCount} 条`
|
|
4063
4219
|
);
|
|
4064
4220
|
logs.forEach((line) => console.log(line));
|
|
4221
|
+
// 一轮结束询问是否继续(默认继续), 继续则回到任务勾选列表(任务状态重新拉取)
|
|
4222
|
+
const { goNext } = await inquirer.prompt([
|
|
4223
|
+
{
|
|
4224
|
+
message: "是否继续处理其他任务? (继续回到任务勾选列表)",
|
|
4225
|
+
name: "goNext",
|
|
4226
|
+
type: "confirm",
|
|
4227
|
+
default: true,
|
|
4228
|
+
},
|
|
4229
|
+
]);
|
|
4230
|
+
if (goNext) {
|
|
4231
|
+
// 递归进入新一轮(会话失效仍由 session.request 兜底重登)
|
|
4232
|
+
return session.request(closeRound);
|
|
4233
|
+
}
|
|
4065
4234
|
// 部分失败以非零退出码收尾, 供自动化流程感知重试
|
|
4066
4235
|
if (failedCount) {
|
|
4067
4236
|
console.log(chalk.red(`失败${failedCount}个, 详情见上方黄字提示`));
|
|
@@ -4181,6 +4350,1209 @@ function registerStoryCommand(program, { markHandled, addHelpExample }) {
|
|
|
4181
4350
|
);
|
|
4182
4351
|
}
|
|
4183
4352
|
|
|
4353
|
+
// 坚果云 WebDAV 公共模块(其他功能可直接复用)
|
|
4354
|
+
// 应用密码需网页端 账户信息->安全选项->添加应用密码 生成, 非登录密码
|
|
4355
|
+
const JIANGUOYUN_DAV = "https://dav.jianguoyun.com/dav";
|
|
4356
|
+
const CACHE_KEY = "jianguoyun"; // .cache/jianguoyun: { account, appPassword, remoteDir }
|
|
4357
|
+
|
|
4358
|
+
// 凭据交互问题(默认值会被缓存覆盖, 输入后回写缓存)
|
|
4359
|
+
const JGY_QUESTIONS = [
|
|
4360
|
+
{
|
|
4361
|
+
message: "请输入坚果云账号(邮箱)",
|
|
4362
|
+
name: "account",
|
|
4363
|
+
validate: (v) => (v && v.trim() ? true : "账号不能为空"),
|
|
4364
|
+
},
|
|
4365
|
+
{
|
|
4366
|
+
message: "请输入坚果云应用密码(网页端: 账户信息->安全选项->添加应用密码)",
|
|
4367
|
+
name: "appPassword",
|
|
4368
|
+
type: "password",
|
|
4369
|
+
validate: (v) => (v && v.trim() ? true : "应用密码不能为空"),
|
|
4370
|
+
},
|
|
4371
|
+
{
|
|
4372
|
+
message: "请输入远程目录(坚果云里的文件夹名)",
|
|
4373
|
+
name: "remoteDir",
|
|
4374
|
+
default: "claude-migrate",
|
|
4375
|
+
},
|
|
4376
|
+
];
|
|
4377
|
+
|
|
4378
|
+
/**
|
|
4379
|
+
* 解析坚果云凭据: CLI参数 > --cache缓存 > 交互(展示缓存默认值), 解析后回写缓存
|
|
4380
|
+
* @param cliValues CLI 参数值 { account, appPassword, remoteDir }
|
|
4381
|
+
* @param useCache 是否静默走缓存(缓存缺失的字段回退交互)
|
|
4382
|
+
*/
|
|
4383
|
+
function resolveJianguoyunSecret(cliValues = {}, useCache = false) {
|
|
4384
|
+
return resolveSecretQuestions({
|
|
4385
|
+
questions: JGY_QUESTIONS,
|
|
4386
|
+
cliValues,
|
|
4387
|
+
useCache,
|
|
4388
|
+
cacheKey: CACHE_KEY,
|
|
4389
|
+
});
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
function basicAuth(account, appPassword) {
|
|
4393
|
+
return "Basic " + Buffer.from(`${account}:${appPassword}`).toString("base64");
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
// 规范化远程目录: 去首尾空白与斜杠
|
|
4397
|
+
function normDir(remoteDir) {
|
|
4398
|
+
return String(remoteDir || "").trim().replace(/^\/+|\/+$/g, "");
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
function remoteUrl(secret, fileName = "") {
|
|
4402
|
+
const parts = [JIANGUOYUN_DAV, normDir(secret.remoteDir)];
|
|
4403
|
+
if (fileName) parts.push(...String(fileName).split("/").map(encodeURIComponent));
|
|
4404
|
+
return parts.filter(Boolean).join("/");
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
// 从 WebDAV 错误响应体提取可读信息(坚果云错误是 XML, 含 <s:message>)
|
|
4408
|
+
async function extractErrorMessage(res) {
|
|
4409
|
+
if (res.status === 401) return "账号或应用密码错误(应用密码需网页端生成, 非登录密码)";
|
|
4410
|
+
let text = "";
|
|
4411
|
+
try {
|
|
4412
|
+
text = await res.text();
|
|
4413
|
+
} catch {
|
|
4414
|
+
/* 忽略 */
|
|
4415
|
+
}
|
|
4416
|
+
const msg = text.match(/<(?:[\w-]+:)?message>([\s\S]*?)<\/(?:[\w-]+:)?message>/i)?.[1]?.trim();
|
|
4417
|
+
return msg || `HTTP ${res.status}`;
|
|
4418
|
+
}
|
|
4419
|
+
|
|
4420
|
+
// 确保远程目录存在(逐级 MKCOL, 已存在返回 405 忽略)
|
|
4421
|
+
async function ensureRemoteDir(secret) {
|
|
4422
|
+
const dir = normDir(secret.remoteDir);
|
|
4423
|
+
if (!dir) return;
|
|
4424
|
+
const auth = basicAuth(secret.account, secret.appPassword);
|
|
4425
|
+
let current = JIANGUOYUN_DAV;
|
|
4426
|
+
for (const seg of dir.split("/")) {
|
|
4427
|
+
current += "/" + encodeURIComponent(seg);
|
|
4428
|
+
const res = await fetch(current, { method: "MKCOL", headers: { Authorization: auth } });
|
|
4429
|
+
// 201=创建成功 405=已存在; 其他视为失败
|
|
4430
|
+
if (res.status !== 201 && res.status !== 405) {
|
|
4431
|
+
throw new Error(`创建远程目录失败: ${await extractErrorMessage(res)}`);
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
/**
|
|
4437
|
+
* 上传文件到坚果云
|
|
4438
|
+
* @param secret resolveJianguoyunSecret 的结果
|
|
4439
|
+
* @param localFile 本地文件路径
|
|
4440
|
+
* @param fileName 远程文件名(默认取 localFile 的 basename)
|
|
4441
|
+
*/
|
|
4442
|
+
async function jianguoyunUpload(secret, localFile, fileName) {
|
|
4443
|
+
await ensureRemoteDir(secret);
|
|
4444
|
+
const name = fileName || localFile.split(/[\\/]/).pop();
|
|
4445
|
+
const res = await fetch(remoteUrl(secret, name), {
|
|
4446
|
+
method: "PUT",
|
|
4447
|
+
headers: { Authorization: basicAuth(secret.account, secret.appPassword) },
|
|
4448
|
+
body: fs.readFileSync(localFile),
|
|
4449
|
+
});
|
|
4450
|
+
if (!res.ok) {
|
|
4451
|
+
throw new Error(`上传坚果云失败: ${await extractErrorMessage(res)}`);
|
|
4452
|
+
}
|
|
4453
|
+
return `${normDir(secret.remoteDir)}/${name}`;
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
// 解析 PROPFIND 响应, 返回目录下文件列表(跳过目录本身)
|
|
4457
|
+
function parsePropfind(xml) {
|
|
4458
|
+
const files = [];
|
|
4459
|
+
const respRe = /<(?:[\w-]+:)?response>([\s\S]*?)<\/(?:[\w-]+:)?response>/g;
|
|
4460
|
+
let m;
|
|
4461
|
+
while ((m = respRe.exec(xml))) {
|
|
4462
|
+
const block = m[1];
|
|
4463
|
+
const href =
|
|
4464
|
+
block.match(/<(?:[\w-]+:)?href>([\s\S]*?)<\/(?:[\w-]+:)?href>/)?.[1] || "";
|
|
4465
|
+
if (!href || href.endsWith("/")) continue; // 目录本身
|
|
4466
|
+
const name = decodeURIComponent(href.split("/").filter(Boolean).pop());
|
|
4467
|
+
const size = Number(
|
|
4468
|
+
block.match(/<(?:[\w-]+:)?getcontentlength>([\s\S]*?)</)?.[1] || 0
|
|
4469
|
+
);
|
|
4470
|
+
const mtime =
|
|
4471
|
+
block.match(/<(?:[\w-]+:)?getlastmodified>([\s\S]*?)</)?.[1] || "";
|
|
4472
|
+
files.push({ name, size, mtime });
|
|
4473
|
+
}
|
|
4474
|
+
return files;
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
/**
|
|
4478
|
+
* 列出坚果云远程目录下的文件(目录不存在返回 [])
|
|
4479
|
+
* @returns [{ name, size, mtime }]
|
|
4480
|
+
*/
|
|
4481
|
+
async function jianguoyunList(secret) {
|
|
4482
|
+
const dir = normDir(secret.remoteDir);
|
|
4483
|
+
if (!dir) return [];
|
|
4484
|
+
const res = await fetch(remoteUrl(secret), {
|
|
4485
|
+
method: "PROPFIND",
|
|
4486
|
+
headers: {
|
|
4487
|
+
Authorization: basicAuth(secret.account, secret.appPassword),
|
|
4488
|
+
Depth: "1",
|
|
4489
|
+
},
|
|
4490
|
+
});
|
|
4491
|
+
if (res.status === 404) return []; // 目录不存在
|
|
4492
|
+
if (!res.ok) {
|
|
4493
|
+
throw new Error(`读取坚果云目录失败: ${await extractErrorMessage(res)}`);
|
|
4494
|
+
}
|
|
4495
|
+
return parsePropfind(await res.text());
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
/**
|
|
4499
|
+
* 从坚果云下载文件
|
|
4500
|
+
* @param localPath 下载保存的本地路径
|
|
4501
|
+
*/
|
|
4502
|
+
async function jianguoyunDownload(secret, fileName, localPath) {
|
|
4503
|
+
const res = await fetch(remoteUrl(secret, fileName), {
|
|
4504
|
+
headers: { Authorization: basicAuth(secret.account, secret.appPassword) },
|
|
4505
|
+
});
|
|
4506
|
+
if (!res.ok) {
|
|
4507
|
+
throw new Error(`下载坚果云文件失败: ${await extractErrorMessage(res)}`);
|
|
4508
|
+
}
|
|
4509
|
+
fs.writeFileSync(localPath, Buffer.from(await res.arrayBuffer()));
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
// claude 子命令 — Claude Code 数据跨平台迁移(list/export/import)
|
|
4513
|
+
// 参照 claude-migrate.sh 重写为 Node 版: zip 归档 + 跨系统路径重映射(POSIX <-> Windows)
|
|
4514
|
+
const MIGRATE_VERSION = "1.0.0";
|
|
4515
|
+
const ALL_CATEGORIES =
|
|
4516
|
+
"settings,history,plans,projects,plugins,skills,memory";
|
|
4517
|
+
const CATEGORY_LIST = ALL_CATEGORIES.split(",");
|
|
4518
|
+
|
|
4519
|
+
// 类别 -> 顶层文件(相对 ~/.claude)
|
|
4520
|
+
const CATEGORY_FILES = {
|
|
4521
|
+
settings: ["settings.json", "settings.local.json", "keybindings.json"],
|
|
4522
|
+
history: ["history.jsonl"],
|
|
4523
|
+
};
|
|
4524
|
+
// 类别 -> 整目录拷贝(相对 ~/.claude)
|
|
4525
|
+
const CATEGORY_DIRS = {
|
|
4526
|
+
plans: "plans",
|
|
4527
|
+
projects: "projects",
|
|
4528
|
+
skills: "skills",
|
|
4529
|
+
};
|
|
4530
|
+
// export 交互勾选时默认不勾选的类别(history/projects 体积大且含大量本机路径, 按需勾选)
|
|
4531
|
+
const UNCHECKED_BY_DEFAULT = ["history", "projects"];
|
|
4532
|
+
// plugins 只迁 2 个清单(插件本体可重装, 不迁)
|
|
4533
|
+
const PLUGIN_FILES = ["installed_plugins.json", "known_marketplaces.json"];
|
|
4534
|
+
// memory = memory/ 目录 + 全局记忆文件
|
|
4535
|
+
const MEMORY_DIR = "memory";
|
|
4536
|
+
const MEMORY_FILES = ["CLAUDE.md", "AGENTS.md"];
|
|
4537
|
+
|
|
4538
|
+
// ─── 工具函数 ─────────────────────────────────────────────────────────
|
|
4539
|
+
|
|
4540
|
+
function log(msg) {
|
|
4541
|
+
console.log(`${chalk.green("=>")} ${msg}`);
|
|
4542
|
+
}
|
|
4543
|
+
function warn(msg) {
|
|
4544
|
+
console.log(chalk.yellow(`警告: ${msg}`));
|
|
4545
|
+
}
|
|
4546
|
+
function verbose(msg, enabled) {
|
|
4547
|
+
if (enabled) console.log(` ${chalk.blue("+")} ${msg}`);
|
|
4548
|
+
}
|
|
4549
|
+
function die(msg) {
|
|
4550
|
+
console.error(chalk.red(`错误: ${msg}`));
|
|
4551
|
+
process.exit(1);
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
4555
|
+
|
|
4556
|
+
// 时间戳 YYYY-MM-DD-HHmmss(年月日时分秒)
|
|
4557
|
+
function getTimestamp() {
|
|
4558
|
+
const d = new Date();
|
|
4559
|
+
return (
|
|
4560
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
4561
|
+
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
|
4562
|
+
);
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4565
|
+
// 递归收集目录下所有文件(绝对路径)
|
|
4566
|
+
function walkFiles(dir) {
|
|
4567
|
+
const out = [];
|
|
4568
|
+
(function rec(d) {
|
|
4569
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
4570
|
+
if (e.isDirectory()) rec(path.join(d, e.name));
|
|
4571
|
+
else out.push(path.join(d, e.name));
|
|
4572
|
+
}
|
|
4573
|
+
})(dir);
|
|
4574
|
+
return out;
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
function countFiles(dir) {
|
|
4578
|
+
if (!fs.existsSync(dir)) return 0;
|
|
4579
|
+
return walkFiles(dir).length;
|
|
4580
|
+
}
|
|
4581
|
+
|
|
4582
|
+
// ─── 路径编码与重映射 ──────────────────────────────────────────────────
|
|
4583
|
+
|
|
4584
|
+
/**
|
|
4585
|
+
* 跨平台路径编码(Claude Code projects 目录名规则):
|
|
4586
|
+
* / \ : 均替换为 -(统一按正斜杠处理, POSIX 首斜杠替换后自然带前导横杠)
|
|
4587
|
+
* /Users/harry -> -Users-harry
|
|
4588
|
+
* D:\Users\harry -> D--Users-harry
|
|
4589
|
+
*/
|
|
4590
|
+
function encodePath(p) {
|
|
4591
|
+
const norm = String(p).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
4592
|
+
return norm.replace(/[:/]/g, "-");
|
|
4593
|
+
}
|
|
4594
|
+
|
|
4595
|
+
// projects 子目录名: 把编码后的源 home 前缀替换为目标 home 前缀
|
|
4596
|
+
function remapDirname(name, srcHome, dstHome) {
|
|
4597
|
+
const src = encodePath(srcHome);
|
|
4598
|
+
const dst = encodePath(dstHome);
|
|
4599
|
+
if (src === dst || !name.startsWith(src)) return name;
|
|
4600
|
+
return dst + name.slice(src.length);
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
/**
|
|
4604
|
+
* 文件内容路径重映射, 覆盖 3 种变体(长变体优先, 避免单分隔符替换破坏双转义):
|
|
4605
|
+
* 1. JSON 双反斜杠转义: D:\\Users\\harry
|
|
4606
|
+
* 2. 反斜杠分隔: D:\Users\harry 或 \Users\harry
|
|
4607
|
+
* 3. 正斜杠分隔: /Users/harry 或 D:/Users/harry
|
|
4608
|
+
*/
|
|
4609
|
+
function remapContent(content, srcHome, dstHome) {
|
|
4610
|
+
if (!srcHome || !dstHome || srcHome === dstHome) return content;
|
|
4611
|
+
const srcPosix = srcHome.replace(/\\/g, "/");
|
|
4612
|
+
const dstPosix = dstHome.replace(/\\/g, "/");
|
|
4613
|
+
const srcWin = srcPosix.replace(/\//g, "\\");
|
|
4614
|
+
const dstWin = dstPosix.replace(/\//g, "\\");
|
|
4615
|
+
const pairs = [
|
|
4616
|
+
[srcWin.replace(/\\/g, "\\\\"), dstWin.replace(/\\/g, "\\\\")],
|
|
4617
|
+
[srcWin, dstWin],
|
|
4618
|
+
[srcPosix, dstPosix],
|
|
4619
|
+
];
|
|
4620
|
+
for (const [from, to] of pairs) {
|
|
4621
|
+
if (from && from !== to) content = content.split(from).join(to);
|
|
4622
|
+
}
|
|
4623
|
+
return content;
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4626
|
+
function remapFileContents(file, srcHome, dstHome) {
|
|
4627
|
+
if (srcHome === dstHome) return;
|
|
4628
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
4629
|
+
const newContent = remapContent(content, srcHome, dstHome);
|
|
4630
|
+
if (newContent !== content) {
|
|
4631
|
+
fs.writeFileSync(file, newContent, "utf-8");
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
|
|
4635
|
+
// ─── claude 目录解析 ───────────────────────────────────────────────────
|
|
4636
|
+
|
|
4637
|
+
function detectClaudeDir() {
|
|
4638
|
+
return (
|
|
4639
|
+
process.env.CLAUDE_DIR || path.join(os.homedir(), ".claude")
|
|
4640
|
+
);
|
|
4641
|
+
}
|
|
4642
|
+
|
|
4643
|
+
// 读操作(list/export)用: --claude-dir > 环境变量 > 默认检测; 不存在则交互输入
|
|
4644
|
+
async function resolveClaudeDirForRead(options) {
|
|
4645
|
+
if (options.claudeDir) {
|
|
4646
|
+
const p = path.resolve(options.claudeDir);
|
|
4647
|
+
if (!fs.existsSync(p)) die(`claude 目录不存在: ${p}`);
|
|
4648
|
+
return p;
|
|
4649
|
+
}
|
|
4650
|
+
const detected = detectClaudeDir();
|
|
4651
|
+
if (fs.existsSync(detected)) return detected;
|
|
4652
|
+
warn(`未检测到 claude 目录: ${detected}`);
|
|
4653
|
+
const { custom } = await inquirer.prompt([
|
|
4654
|
+
{
|
|
4655
|
+
type: "input",
|
|
4656
|
+
name: "custom",
|
|
4657
|
+
message: "请输入 claude 数据目录路径",
|
|
4658
|
+
validate: (v) => {
|
|
4659
|
+
if (!v.trim()) return "路径不能为空";
|
|
4660
|
+
if (!fs.existsSync(v.trim())) return "目录不存在, 请重新输入";
|
|
4661
|
+
return true;
|
|
4662
|
+
},
|
|
4663
|
+
},
|
|
4664
|
+
]);
|
|
4665
|
+
return path.resolve(custom.trim());
|
|
4666
|
+
}
|
|
4667
|
+
|
|
4668
|
+
// import 用: --claude-dir 传了直接用; 否则展示检测路径让用户确认或自定义
|
|
4669
|
+
async function resolveClaudeDirForImport(options) {
|
|
4670
|
+
if (options.claudeDir) return path.resolve(options.claudeDir);
|
|
4671
|
+
const detected = detectClaudeDir();
|
|
4672
|
+
const exists = fs.existsSync(detected);
|
|
4673
|
+
const { choice } = await inquirer.prompt([
|
|
4674
|
+
{
|
|
4675
|
+
type: "list",
|
|
4676
|
+
name: "choice",
|
|
4677
|
+
message: "请确认目标 claude 数据目录",
|
|
4678
|
+
choices: [
|
|
4679
|
+
{
|
|
4680
|
+
name: `使用检测到的路径: ${detected}${exists ? "" : chalk.yellow(" (目录不存在, 导入时会创建)")}`,
|
|
4681
|
+
value: detected,
|
|
4682
|
+
},
|
|
4683
|
+
{ name: "手动输入自定义路径", value: null },
|
|
4684
|
+
],
|
|
4685
|
+
},
|
|
4686
|
+
]);
|
|
4687
|
+
if (choice) return choice;
|
|
4688
|
+
const { custom } = await inquirer.prompt([
|
|
4689
|
+
{
|
|
4690
|
+
type: "input",
|
|
4691
|
+
name: "custom",
|
|
4692
|
+
message: "请输入 claude 数据目录路径",
|
|
4693
|
+
validate: (v) => (v.trim() ? true : "路径不能为空"),
|
|
4694
|
+
},
|
|
4695
|
+
]);
|
|
4696
|
+
return path.resolve(custom.trim());
|
|
4697
|
+
}
|
|
4698
|
+
|
|
4699
|
+
// ─── 类别收集(export 暂存) ─────────────────────────────────────────────
|
|
4700
|
+
|
|
4701
|
+
function collectCategory(claudeDir, dataDir, cat, isVerbose) {
|
|
4702
|
+
if (CATEGORY_FILES[cat]) {
|
|
4703
|
+
for (const f of CATEGORY_FILES[cat]) {
|
|
4704
|
+
const src = path.join(claudeDir, f);
|
|
4705
|
+
if (fs.existsSync(src)) {
|
|
4706
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
4707
|
+
fs.copyFileSync(src, path.join(dataDir, f));
|
|
4708
|
+
verbose(f, isVerbose);
|
|
4709
|
+
}
|
|
4710
|
+
}
|
|
4711
|
+
} else if (CATEGORY_DIRS[cat]) {
|
|
4712
|
+
const dir = CATEGORY_DIRS[cat];
|
|
4713
|
+
const src = path.join(claudeDir, dir);
|
|
4714
|
+
if (fs.existsSync(src)) {
|
|
4715
|
+
const dst = path.join(dataDir, cat);
|
|
4716
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
4717
|
+
fs.cpSync(src, dst, { recursive: true });
|
|
4718
|
+
verbose(`${cat}/ (${countFiles(src)} 个文件)`, isVerbose);
|
|
4719
|
+
}
|
|
4720
|
+
} else if (cat === "plugins") {
|
|
4721
|
+
const src = path.join(claudeDir, "plugins");
|
|
4722
|
+
if (fs.existsSync(src)) {
|
|
4723
|
+
fs.mkdirSync(path.join(dataDir, "plugins"), { recursive: true });
|
|
4724
|
+
for (const f of PLUGIN_FILES) {
|
|
4725
|
+
if (fs.existsSync(path.join(src, f))) {
|
|
4726
|
+
fs.copyFileSync(path.join(src, f), path.join(dataDir, "plugins", f));
|
|
4727
|
+
verbose(`plugins/${f}`, isVerbose);
|
|
4728
|
+
}
|
|
4729
|
+
}
|
|
4730
|
+
}
|
|
4731
|
+
} else if (cat === "memory") {
|
|
4732
|
+
const memSrc = path.join(claudeDir, MEMORY_DIR);
|
|
4733
|
+
if (fs.existsSync(memSrc)) {
|
|
4734
|
+
fs.mkdirSync(path.join(dataDir, MEMORY_DIR), { recursive: true });
|
|
4735
|
+
fs.cpSync(memSrc, path.join(dataDir, MEMORY_DIR), { recursive: true });
|
|
4736
|
+
verbose(`memory/ (${countFiles(memSrc)} 个文件)`, isVerbose);
|
|
4737
|
+
}
|
|
4738
|
+
for (const f of MEMORY_FILES) {
|
|
4739
|
+
const src = path.join(claudeDir, f);
|
|
4740
|
+
if (fs.existsSync(src)) {
|
|
4741
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
4742
|
+
fs.copyFileSync(src, path.join(dataDir, f));
|
|
4743
|
+
verbose(f, isVerbose);
|
|
4744
|
+
}
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
}
|
|
4748
|
+
|
|
4749
|
+
// ─── 本地类别统计(list) ────────────────────────────────────────────────
|
|
4750
|
+
|
|
4751
|
+
function scanCategoryLocal(claudeDir, cat) {
|
|
4752
|
+
const exists = (p) => fs.existsSync(path.join(claudeDir, p));
|
|
4753
|
+
switch (cat) {
|
|
4754
|
+
case "settings": {
|
|
4755
|
+
const n = CATEGORY_FILES.settings.filter(exists).length;
|
|
4756
|
+
return n ? `${n} 个文件` : "未找到";
|
|
4757
|
+
}
|
|
4758
|
+
case "history": {
|
|
4759
|
+
const p = path.join(claudeDir, "history.jsonl");
|
|
4760
|
+
if (!fs.existsSync(p)) return "未找到";
|
|
4761
|
+
const lines = fs
|
|
4762
|
+
.readFileSync(p, "utf-8")
|
|
4763
|
+
.split("\n")
|
|
4764
|
+
.filter((l) => l.trim()).length;
|
|
4765
|
+
return `${lines} 条`;
|
|
4766
|
+
}
|
|
4767
|
+
case "plans": {
|
|
4768
|
+
const p = path.join(claudeDir, CATEGORY_DIRS[cat]);
|
|
4769
|
+
if (!fs.existsSync(p)) return "未找到";
|
|
4770
|
+
return `${countFiles(p)} 个文件`;
|
|
4771
|
+
}
|
|
4772
|
+
case "projects":
|
|
4773
|
+
case "skills": {
|
|
4774
|
+
const p = path.join(claudeDir, CATEGORY_DIRS[cat]);
|
|
4775
|
+
if (!fs.existsSync(p)) return "未找到";
|
|
4776
|
+
const dirs = fs
|
|
4777
|
+
.readdirSync(p, { withFileTypes: true })
|
|
4778
|
+
.filter((e) => e.isDirectory());
|
|
4779
|
+
const label = cat === "skills" ? "个 skill" : "个项目";
|
|
4780
|
+
return `${dirs.length} ${label}, ${countFiles(p)} 个文件`;
|
|
4781
|
+
}
|
|
4782
|
+
case "plugins": {
|
|
4783
|
+
const p = path.join(claudeDir, "plugins");
|
|
4784
|
+
if (!fs.existsSync(p)) return "未找到";
|
|
4785
|
+
const n = PLUGIN_FILES.filter((f) =>
|
|
4786
|
+
fs.existsSync(path.join(p, f))
|
|
4787
|
+
).length;
|
|
4788
|
+
return n ? `${n} 个文件` : "未找到";
|
|
4789
|
+
}
|
|
4790
|
+
case "memory": {
|
|
4791
|
+
let n = MEMORY_FILES.filter(exists).length;
|
|
4792
|
+
const memDir = path.join(claudeDir, MEMORY_DIR);
|
|
4793
|
+
if (fs.existsSync(memDir)) n += countFiles(memDir);
|
|
4794
|
+
return n ? `${n} 个文件` : "未找到";
|
|
4795
|
+
}
|
|
4796
|
+
default:
|
|
4797
|
+
return "未找到";
|
|
4798
|
+
}
|
|
4799
|
+
}
|
|
4800
|
+
|
|
4801
|
+
function printCategoryTable(claudeDir, scanFn) {
|
|
4802
|
+
console.log("");
|
|
4803
|
+
for (const cat of CATEGORY_LIST) {
|
|
4804
|
+
const info = scanFn(claudeDir, cat);
|
|
4805
|
+
const isFound = info !== "未找到" && !info.startsWith("0 ");
|
|
4806
|
+
const colored = isFound
|
|
4807
|
+
? chalk.green(cat.padEnd(10))
|
|
4808
|
+
: chalk.yellow(cat.padEnd(10));
|
|
4809
|
+
console.log(` ${colored} ${info}`);
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
function printDirDetails(dir, title, unit) {
|
|
4814
|
+
if (!fs.existsSync(dir)) return;
|
|
4815
|
+
const dirs = fs
|
|
4816
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
4817
|
+
.filter((e) => e.isDirectory());
|
|
4818
|
+
if (!dirs.length) return;
|
|
4819
|
+
console.log("");
|
|
4820
|
+
console.log(chalk.bold(`${title}:`));
|
|
4821
|
+
for (const d of dirs) {
|
|
4822
|
+
const n = countFiles(path.join(dir, d.name));
|
|
4823
|
+
console.log(` ${d.name} (${n} ${unit})`);
|
|
4824
|
+
}
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4827
|
+
function cmdListLocal(options) {
|
|
4828
|
+
return resolveClaudeDirForRead(options).then((claudeDir) => {
|
|
4829
|
+
console.log(chalk.bold(`本地 Claude 数据:`), claudeDir);
|
|
4830
|
+
printCategoryTable(claudeDir, scanCategoryLocal);
|
|
4831
|
+
printDirDetails(path.join(claudeDir, "projects"), "Projects", "个文件");
|
|
4832
|
+
printDirDetails(path.join(claudeDir, "skills"), "Skills", "个文件");
|
|
4833
|
+
});
|
|
4834
|
+
}
|
|
4835
|
+
|
|
4836
|
+
// ─── 归档类别统计(list 归档) ───────────────────────────────────────────
|
|
4837
|
+
|
|
4838
|
+
// adm-zip 兼容: 旧版 isDirectory() 是方法, 新版是 boolean 属性
|
|
4839
|
+
function entryIsDirectory(e) {
|
|
4840
|
+
return typeof e.isDirectory === "function"
|
|
4841
|
+
? e.isDirectory()
|
|
4842
|
+
: e.isDirectory === true;
|
|
4843
|
+
}
|
|
4844
|
+
|
|
4845
|
+
// 解析归档: 返回 { zip, root, manifest, relMap }(relMap: 相对 root 的路径 -> entry)
|
|
4846
|
+
function readArchive(archive) {
|
|
4847
|
+
if (!fs.existsSync(archive)) die(`归档不存在: ${archive}`);
|
|
4848
|
+
let zip;
|
|
4849
|
+
try {
|
|
4850
|
+
zip = new AdmZip(archive);
|
|
4851
|
+
} catch (error) {
|
|
4852
|
+
die(`读取归档失败: ${error.message}`);
|
|
4853
|
+
}
|
|
4854
|
+
const entries = zip.getEntries();
|
|
4855
|
+
const manifestEntry = entries.find(
|
|
4856
|
+
(e) => !entryIsDirectory(e) && e.entryName.split("/").pop() === "manifest.json"
|
|
4857
|
+
);
|
|
4858
|
+
if (!manifestEntry) die("归档中未找到 manifest.json");
|
|
4859
|
+
const root = manifestEntry.entryName.includes("/")
|
|
4860
|
+
? manifestEntry.entryName.slice(
|
|
4861
|
+
0,
|
|
4862
|
+
manifestEntry.entryName.lastIndexOf("/")
|
|
4863
|
+
)
|
|
4864
|
+
: "";
|
|
4865
|
+
let manifest;
|
|
4866
|
+
try {
|
|
4867
|
+
manifest = JSON.parse(zip.readAsText(manifestEntry.entryName));
|
|
4868
|
+
} catch (error) {
|
|
4869
|
+
die("manifest.json 解析失败");
|
|
4870
|
+
}
|
|
4871
|
+
const relMap = new Map();
|
|
4872
|
+
for (const e of entries) {
|
|
4873
|
+
if (entryIsDirectory(e)) continue;
|
|
4874
|
+
const rel = root ? e.entryName.slice(root.length + 1) : e.entryName;
|
|
4875
|
+
relMap.set(rel, e);
|
|
4876
|
+
}
|
|
4877
|
+
return { zip, root, manifest, relMap };
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
function scanCategoryArchive(relMap, zip, cat) {
|
|
4881
|
+
const has = (rel) => relMap.has(rel);
|
|
4882
|
+
switch (cat) {
|
|
4883
|
+
case "settings": {
|
|
4884
|
+
const n = CATEGORY_FILES.settings.filter((f) => has(`data/${f}`)).length;
|
|
4885
|
+
return n ? `${n} 个文件` : "未找到";
|
|
4886
|
+
}
|
|
4887
|
+
case "history": {
|
|
4888
|
+
const e = relMap.get("data/history.jsonl");
|
|
4889
|
+
if (!e) return "未找到";
|
|
4890
|
+
const lines = zip
|
|
4891
|
+
.readAsText(e.entryName)
|
|
4892
|
+
.split("\n")
|
|
4893
|
+
.filter((l) => l.trim()).length;
|
|
4894
|
+
return `${lines} 条`;
|
|
4895
|
+
}
|
|
4896
|
+
case "plans":
|
|
4897
|
+
case "skills": {
|
|
4898
|
+
const prefix = `data/${cat}/`;
|
|
4899
|
+
const n = [...relMap.keys()].filter((rel) =>
|
|
4900
|
+
rel.startsWith(prefix)
|
|
4901
|
+
).length;
|
|
4902
|
+
return n ? `${n} 个文件` : "未找到";
|
|
4903
|
+
}
|
|
4904
|
+
case "projects": {
|
|
4905
|
+
const prefix = "data/projects/";
|
|
4906
|
+
const rels = [...relMap.keys()].filter((rel) =>
|
|
4907
|
+
rel.startsWith(prefix)
|
|
4908
|
+
);
|
|
4909
|
+
if (!rels.length) return "未找到";
|
|
4910
|
+
const dirs = new Set(rels.map((rel) => rel.slice(prefix.length).split("/")[0]));
|
|
4911
|
+
return `${dirs.size} 个项目, ${rels.length} 个文件`;
|
|
4912
|
+
}
|
|
4913
|
+
case "plugins": {
|
|
4914
|
+
const n = PLUGIN_FILES.filter((f) => has(`data/plugins/${f}`)).length;
|
|
4915
|
+
return n ? `${n} 个文件` : "未找到";
|
|
4916
|
+
}
|
|
4917
|
+
case "memory": {
|
|
4918
|
+
let n = MEMORY_FILES.filter((f) => has(`data/${f}`)).length;
|
|
4919
|
+
n += [...relMap.keys()].filter((rel) =>
|
|
4920
|
+
rel.startsWith(`data/${MEMORY_DIR}/`)
|
|
4921
|
+
).length;
|
|
4922
|
+
return n ? `${n} 个文件` : "未找到";
|
|
4923
|
+
}
|
|
4924
|
+
default:
|
|
4925
|
+
return "未找到";
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
|
|
4929
|
+
function cmdListArchive(archive) {
|
|
4930
|
+
const { zip, manifest, relMap } = readArchive(archive);
|
|
4931
|
+
const platformLabel =
|
|
4932
|
+
manifest.platform ? ` [${manifest.platform}]` : "";
|
|
4933
|
+
console.log(chalk.bold(`归档:`), archive);
|
|
4934
|
+
console.log(chalk.bold(`来源:`), `${manifest.hostname || "unknown"} (${manifest.source_home})${platformLabel}`);
|
|
4935
|
+
console.log(chalk.bold(`日期:`), manifest.date || "unknown");
|
|
4936
|
+
printCategoryTable(relMap, (map, cat) =>
|
|
4937
|
+
scanCategoryArchive(map, zip, cat)
|
|
4938
|
+
);
|
|
4939
|
+
// projects / skills 一级列表
|
|
4940
|
+
for (const [cat, title] of [
|
|
4941
|
+
["projects", "Projects"],
|
|
4942
|
+
["skills", "Skills"],
|
|
4943
|
+
]) {
|
|
4944
|
+
const prefix = `data/${cat}/`;
|
|
4945
|
+
const names = new Set(
|
|
4946
|
+
[...relMap.keys()]
|
|
4947
|
+
.filter((rel) => rel.startsWith(prefix))
|
|
4948
|
+
.map((rel) => rel.slice(prefix.length).split("/")[0])
|
|
4949
|
+
);
|
|
4950
|
+
if (!names.size) continue;
|
|
4951
|
+
console.log("");
|
|
4952
|
+
console.log(chalk.bold(`${title}:`));
|
|
4953
|
+
for (const name of names) {
|
|
4954
|
+
const n = [...relMap.keys()].filter(
|
|
4955
|
+
(rel) => rel.startsWith(prefix + name + "/")
|
|
4956
|
+
).length;
|
|
4957
|
+
console.log(` ${name} (${n} 个文件)`);
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
}
|
|
4961
|
+
|
|
4962
|
+
// ─── export ───────────────────────────────────────────────────────────
|
|
4963
|
+
|
|
4964
|
+
function parseCategories(str) {
|
|
4965
|
+
const cats = String(str || ALL_CATEGORIES)
|
|
4966
|
+
.split(",")
|
|
4967
|
+
.map((s) => s.trim())
|
|
4968
|
+
.filter(Boolean);
|
|
4969
|
+
for (const c of cats) {
|
|
4970
|
+
if (!CATEGORY_LIST.includes(c)) {
|
|
4971
|
+
die(`未知类别: ${c} (可选: ${ALL_CATEGORIES})`);
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4974
|
+
return cats.length ? cats : CATEGORY_LIST;
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4977
|
+
// 类别中文说明(勾选列表展示用)
|
|
4978
|
+
const CATEGORY_LABELS = {
|
|
4979
|
+
settings: "配置(settings.json等)",
|
|
4980
|
+
history: "输入历史(history.jsonl, 体积大)",
|
|
4981
|
+
plans: "计划(plans/)",
|
|
4982
|
+
projects: "项目会话记录(projects/, 体积大)",
|
|
4983
|
+
plugins: "插件清单(installed_plugins等)",
|
|
4984
|
+
skills: "技能(skills/)",
|
|
4985
|
+
memory: "全局记忆(memory/ + CLAUDE.md)",
|
|
4986
|
+
};
|
|
4987
|
+
|
|
4988
|
+
/**
|
|
4989
|
+
* export 交互勾选导出类别(「全选」与子项父子联动, 复用 CheckboxAllPrompt)
|
|
4990
|
+
* history/projects 默认不勾选(体积大且含大量本机路径), 其余默认勾选
|
|
4991
|
+
*/
|
|
4992
|
+
async function selectExportCategories(claudeDir) {
|
|
4993
|
+
// prompt 注册幂等, 与 story.js/upload.js 各自使用前注册
|
|
4994
|
+
inquirer.registerPrompt("checkbox-all", CheckboxAllPrompt);
|
|
4995
|
+
const choices = [
|
|
4996
|
+
{
|
|
4997
|
+
name: "全选",
|
|
4998
|
+
value: SELECT_ALL,
|
|
4999
|
+
short: "全选",
|
|
5000
|
+
// 默认未全勾(history/projects), 与子项联动状态保持一致
|
|
5001
|
+
checked: false,
|
|
5002
|
+
},
|
|
5003
|
+
...CATEGORY_LIST.map((cat) => ({
|
|
5004
|
+
name: `${cat} — ${CATEGORY_LABELS[cat]} [${scanCategoryLocal(claudeDir, cat)}]`,
|
|
5005
|
+
value: cat,
|
|
5006
|
+
short: cat,
|
|
5007
|
+
checked: !UNCHECKED_BY_DEFAULT.includes(cat),
|
|
5008
|
+
})),
|
|
5009
|
+
];
|
|
5010
|
+
const { picked } = await inquirer.prompt([
|
|
5011
|
+
{
|
|
5012
|
+
message:
|
|
5013
|
+
"请勾选要导出的类别(空格选择, 回车确认; 「全选」与单个类别联动)",
|
|
5014
|
+
name: "picked",
|
|
5015
|
+
type: "checkbox-all",
|
|
5016
|
+
// 到列表边界停止, 不循环回第一条
|
|
5017
|
+
loop: false,
|
|
5018
|
+
choices,
|
|
5019
|
+
},
|
|
5020
|
+
]);
|
|
5021
|
+
const cats = picked.filter((v) => v !== SELECT_ALL);
|
|
5022
|
+
if (!cats.length) {
|
|
5023
|
+
warn("未勾选任何类别, 已取消导出");
|
|
5024
|
+
process.exit(0);
|
|
5025
|
+
}
|
|
5026
|
+
return cats;
|
|
5027
|
+
}
|
|
5028
|
+
|
|
5029
|
+
// 校验并归一化位置参数(local|jianguoyun)
|
|
5030
|
+
function normalizeLocation(value, optionName) {
|
|
5031
|
+
if (value === undefined) return null;
|
|
5032
|
+
if (value !== "local" && value !== "jianguoyun") {
|
|
5033
|
+
die(`参数 ${optionName} 的值 "${value}" 不合法, 可选值: local | jianguoyun`);
|
|
5034
|
+
}
|
|
5035
|
+
return value;
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
// 解析坚果云凭据: CLI参数 > --cache缓存 > 交互(展示缓存默认值), 输入后回写缓存
|
|
5039
|
+
function getJianguoyunSecret(options) {
|
|
5040
|
+
return resolveJianguoyunSecret(
|
|
5041
|
+
{
|
|
5042
|
+
account: options.account,
|
|
5043
|
+
appPassword: options.appPassword,
|
|
5044
|
+
remoteDir: options.remoteDir,
|
|
5045
|
+
},
|
|
5046
|
+
Boolean(options.cache)
|
|
5047
|
+
);
|
|
5048
|
+
}
|
|
5049
|
+
|
|
5050
|
+
// export 选保存位置: --dest 直通(一键), 否则交互选 本地/坚果云
|
|
5051
|
+
async function selectExportDest(options) {
|
|
5052
|
+
const dest = normalizeLocation(options.dest, "--dest");
|
|
5053
|
+
if (dest) return dest;
|
|
5054
|
+
const { picked } = await inquirer.prompt([
|
|
5055
|
+
{
|
|
5056
|
+
type: "list",
|
|
5057
|
+
name: "picked",
|
|
5058
|
+
message: "请选择归档保存位置",
|
|
5059
|
+
choices: [
|
|
5060
|
+
{ name: "本地(当前目录下 zip 文件)", value: "local" },
|
|
5061
|
+
{ name: "坚果云(WebDAV, 账号密码缓存后下次免输)", value: "jianguoyun" },
|
|
5062
|
+
],
|
|
5063
|
+
},
|
|
5064
|
+
]);
|
|
5065
|
+
return picked;
|
|
5066
|
+
}
|
|
5067
|
+
|
|
5068
|
+
async function cmdExport(options) {
|
|
5069
|
+
const claudeDir = await resolveClaudeDirForRead(options);
|
|
5070
|
+
// --categories 传了直通用(一键), 否则交互勾选
|
|
5071
|
+
const cats = options.categories
|
|
5072
|
+
? parseCategories(options.categories)
|
|
5073
|
+
: await selectExportCategories(claudeDir);
|
|
5074
|
+
// 保存位置: 本地 / 坚果云
|
|
5075
|
+
const dest = await selectExportDest(options);
|
|
5076
|
+
const isVerbose = Boolean(options.verbose);
|
|
5077
|
+
const timestamp = getTimestamp();
|
|
5078
|
+
const dateShort = timestamp.slice(0, 10);
|
|
5079
|
+
const fileName = options.output
|
|
5080
|
+
? options.output.split(/[\\/]/).pop()
|
|
5081
|
+
: `claude-export-${dateShort}.zip`;
|
|
5082
|
+
const output = dest === "jianguoyun"
|
|
5083
|
+
? path.join(os.tmpdir(), `claude-export-${timestamp}.zip`) // 坚果云: 先写临时再上传后删除
|
|
5084
|
+
: (options.output || fileName);
|
|
5085
|
+
|
|
5086
|
+
const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "claude-migrate-"));
|
|
5087
|
+
const stageRoot = path.join(tmpBase, `claude-migrate-${timestamp}`);
|
|
5088
|
+
const dataDir = path.join(stageRoot, "data");
|
|
5089
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
5090
|
+
|
|
5091
|
+
log(`导出 Claude 数据: ${claudeDir}`);
|
|
5092
|
+
for (const cat of cats) {
|
|
5093
|
+
collectCategory(claudeDir, dataDir, cat, isVerbose);
|
|
5094
|
+
}
|
|
5095
|
+
|
|
5096
|
+
const manifest = {
|
|
5097
|
+
version: MIGRATE_VERSION,
|
|
5098
|
+
source_home: os.homedir(),
|
|
5099
|
+
hostname: os.hostname(),
|
|
5100
|
+
platform: process.platform,
|
|
5101
|
+
date: timestamp,
|
|
5102
|
+
categories: cats,
|
|
5103
|
+
};
|
|
5104
|
+
fs.writeFileSync(
|
|
5105
|
+
path.join(stageRoot, "manifest.json"),
|
|
5106
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
5107
|
+
"utf-8"
|
|
5108
|
+
);
|
|
5109
|
+
|
|
5110
|
+
const fileCount = countFiles(dataDir);
|
|
5111
|
+
log(`已暂存 ${fileCount} 个文件`);
|
|
5112
|
+
if (!fileCount) {
|
|
5113
|
+
warn("没有可导出的数据");
|
|
5114
|
+
fs.rmSync(tmpBase, { recursive: true, force: true });
|
|
5115
|
+
process.exit(1);
|
|
5116
|
+
}
|
|
5117
|
+
|
|
5118
|
+
const zip = new AdmZip();
|
|
5119
|
+
// tmpBase 下仅 stageRoot 一个子目录, 整体打入 zip 使根目录为 claude-migrate-<ts>/
|
|
5120
|
+
zip.addLocalFolder(tmpBase, "");
|
|
5121
|
+
zip.writeZip(output);
|
|
5122
|
+
fs.rmSync(tmpBase, { recursive: true, force: true });
|
|
5123
|
+
const size = (fs.statSync(output).size / 1024).toFixed(1);
|
|
5124
|
+
|
|
5125
|
+
if (dest === "jianguoyun") {
|
|
5126
|
+
// 上传坚果云后删除本地临时文件
|
|
5127
|
+
const secret = await getJianguoyunSecret(options);
|
|
5128
|
+
log(`上传坚果云中...`);
|
|
5129
|
+
const remotePath = await jianguoyunUpload(secret, output, fileName);
|
|
5130
|
+
fs.rmSync(output, { force: true });
|
|
5131
|
+
log(`已上传至坚果云: ${remotePath} (${size} KB)`);
|
|
5132
|
+
} else {
|
|
5133
|
+
log(`已创建归档: ${output} (${size} KB)`);
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
// ─── import ───────────────────────────────────────────────────────────
|
|
5138
|
+
|
|
5139
|
+
// import 选归档来源: --source 直通(一键), 否则交互选 本地/坚果云
|
|
5140
|
+
async function selectImportSource(options) {
|
|
5141
|
+
const source = normalizeLocation(options.source, "--source");
|
|
5142
|
+
if (source) return source;
|
|
5143
|
+
const { picked } = await inquirer.prompt([
|
|
5144
|
+
{
|
|
5145
|
+
type: "list",
|
|
5146
|
+
name: "picked",
|
|
5147
|
+
message: "请选择归档来源",
|
|
5148
|
+
choices: [
|
|
5149
|
+
{ name: "本地(zip 文件路径)", value: "local" },
|
|
5150
|
+
{ name: "坚果云(WebDAV, 列出远程归档选择)", value: "jianguoyun" },
|
|
5151
|
+
],
|
|
5152
|
+
},
|
|
5153
|
+
]);
|
|
5154
|
+
return picked;
|
|
5155
|
+
}
|
|
5156
|
+
|
|
5157
|
+
// 交互输入本地归档路径
|
|
5158
|
+
async function askLocalArchivePath() {
|
|
5159
|
+
const { archive } = await inquirer.prompt([
|
|
5160
|
+
{
|
|
5161
|
+
type: "input",
|
|
5162
|
+
name: "archive",
|
|
5163
|
+
message: "请输入归档文件路径",
|
|
5164
|
+
validate: (v) => {
|
|
5165
|
+
if (!v.trim()) return "路径不能为空";
|
|
5166
|
+
if (!fs.existsSync(v.trim())) return "文件不存在, 请重新输入";
|
|
5167
|
+
return true;
|
|
5168
|
+
},
|
|
5169
|
+
},
|
|
5170
|
+
]);
|
|
5171
|
+
return archive.trim();
|
|
5172
|
+
}
|
|
5173
|
+
|
|
5174
|
+
// 从坚果云列归档选择并下载; 返回下载后的本地路径
|
|
5175
|
+
async function downloadFromJianguoyun(options, savePath) {
|
|
5176
|
+
const secret = await getJianguoyunSecret(options);
|
|
5177
|
+
log("读取坚果云远程目录...");
|
|
5178
|
+
const files = (await jianguoyunList(secret)).filter((f) =>
|
|
5179
|
+
/\.zip$/i.test(f.name)
|
|
5180
|
+
);
|
|
5181
|
+
if (!files.length) {
|
|
5182
|
+
die(`坚果云 ${secret.remoteDir} 目录下没有 zip 归档`);
|
|
5183
|
+
}
|
|
5184
|
+
// 按修改时间倒序, 最新在前
|
|
5185
|
+
files.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
|
|
5186
|
+
const { picked } = await inquirer.prompt([
|
|
5187
|
+
{
|
|
5188
|
+
type: "list",
|
|
5189
|
+
name: "picked",
|
|
5190
|
+
message: "请选择要导入的归档",
|
|
5191
|
+
choices: files.map((f) => ({
|
|
5192
|
+
name: `${f.name} (${(f.size / 1024).toFixed(1)} KB)`,
|
|
5193
|
+
value: f.name,
|
|
5194
|
+
})),
|
|
5195
|
+
},
|
|
5196
|
+
]);
|
|
5197
|
+
log(`下载中: ${picked}`);
|
|
5198
|
+
await jianguoyunDownload(secret, picked, savePath);
|
|
5199
|
+
return savePath;
|
|
5200
|
+
}
|
|
5201
|
+
|
|
5202
|
+
async function cmdImport(archive, options) {
|
|
5203
|
+
const strategy = options.strategy || "overwrite";
|
|
5204
|
+
if (!["overwrite", "merge", "skip"].includes(strategy)) {
|
|
5205
|
+
die(`无效策略: ${strategy} (可选: overwrite, merge, skip)`);
|
|
5206
|
+
}
|
|
5207
|
+
const isVerbose = Boolean(options.verbose);
|
|
5208
|
+
const dryRun = Boolean(options.dryRun);
|
|
5209
|
+
|
|
5210
|
+
// 归档解析: 传了路径直接用; 未传(或 --source)交互选 本地/坚果云
|
|
5211
|
+
let downloadedFile = null;
|
|
5212
|
+
try {
|
|
5213
|
+
if (!archive || options.source) {
|
|
5214
|
+
const source = await selectImportSource(options);
|
|
5215
|
+
if (source === "jianguoyun") {
|
|
5216
|
+
downloadedFile = path.join(
|
|
5217
|
+
os.tmpdir(),
|
|
5218
|
+
`claude-download-${getTimestamp()}.zip`
|
|
5219
|
+
);
|
|
5220
|
+
// die() 的 process.exit 不走 finally, 注册退出钩子兜底清理下载临时文件
|
|
5221
|
+
process.once("exit", () => fs.rmSync(downloadedFile, { force: true }));
|
|
5222
|
+
archive = await downloadFromJianguoyun(options, downloadedFile);
|
|
5223
|
+
} else if (!archive) {
|
|
5224
|
+
archive = await askLocalArchivePath();
|
|
5225
|
+
}
|
|
5226
|
+
}
|
|
5227
|
+
if (!fs.existsSync(archive)) die(`归档不存在: ${archive}`);
|
|
5228
|
+
|
|
5229
|
+
await doImport(archive, options, { strategy, isVerbose, dryRun });
|
|
5230
|
+
} finally {
|
|
5231
|
+
// 坚果云下载的临时文件用后即删
|
|
5232
|
+
if (downloadedFile) fs.rmSync(downloadedFile, { force: true });
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5235
|
+
|
|
5236
|
+
// 实际导入(archive 已是存在的本地文件)
|
|
5237
|
+
async function doImport(archive, options, { strategy, isVerbose, dryRun }) {
|
|
5238
|
+
const { zip, manifest, root } = readArchive(archive);
|
|
5239
|
+
const srcHome = manifest.source_home || "";
|
|
5240
|
+
const archiveCategories = manifest.categories || CATEGORY_LIST;
|
|
5241
|
+
const cats = options.categories
|
|
5242
|
+
? parseCategories(options.categories)
|
|
5243
|
+
: archiveCategories.filter((c) => CATEGORY_LIST.includes(c));
|
|
5244
|
+
|
|
5245
|
+
// 解压到临时目录
|
|
5246
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-import-"));
|
|
5247
|
+
// die() 的 process.exit 不走 finally, 注册退出钩子兜底清理解压临时目录
|
|
5248
|
+
process.once("exit", () => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
5249
|
+
try {
|
|
5250
|
+
zip.extractAllTo(tmpDir, true);
|
|
5251
|
+
const archiveRoot = root
|
|
5252
|
+
? path.join(tmpDir, ...root.split("/"))
|
|
5253
|
+
: tmpDir;
|
|
5254
|
+
const dataDir = path.join(archiveRoot, "data");
|
|
5255
|
+
if (!fs.existsSync(dataDir)) die("归档中未找到 data 目录");
|
|
5256
|
+
|
|
5257
|
+
// 目标目录确认(检测 + 确认/自定义)
|
|
5258
|
+
const claudeDir = await resolveClaudeDirForImport(options);
|
|
5259
|
+
const dstHome = path.dirname(path.resolve(claudeDir));
|
|
5260
|
+
const needsRemap = srcHome && srcHome !== dstHome;
|
|
5261
|
+
|
|
5262
|
+
console.log("");
|
|
5263
|
+
console.log(chalk.bold("导入信息:"));
|
|
5264
|
+
console.log(` 来源: ${manifest.hostname || "unknown"} (${srcHome || "unknown"})${manifest.platform ? ` [${manifest.platform}]` : ""}`);
|
|
5265
|
+
console.log(` 目标: ${claudeDir}`);
|
|
5266
|
+
console.log(` 日期: ${manifest.date || "unknown"}`);
|
|
5267
|
+
console.log(` 策略: ${strategy}`);
|
|
5268
|
+
console.log(` 类别: ${cats.join(",")}`);
|
|
5269
|
+
if (needsRemap) {
|
|
5270
|
+
console.log(` ${chalk.yellow("路径重映射")}: ${srcHome} → ${dstHome}`);
|
|
5271
|
+
}
|
|
5272
|
+
console.log("");
|
|
5273
|
+
|
|
5274
|
+
// 路径重映射(projects 目录名 + 文件内容)
|
|
5275
|
+
if (needsRemap) {
|
|
5276
|
+
log("重映射路径...");
|
|
5277
|
+
const projDir = path.join(dataDir, "projects");
|
|
5278
|
+
if (fs.existsSync(projDir)) {
|
|
5279
|
+
for (const name of fs.readdirSync(projDir)) {
|
|
5280
|
+
const newName = remapDirname(name, srcHome, dstHome);
|
|
5281
|
+
if (newName !== name) {
|
|
5282
|
+
fs.renameSync(path.join(projDir, name), path.join(projDir, newName));
|
|
5283
|
+
verbose(`目录重命名: ${name} → ${newName}`, isVerbose || dryRun);
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
5286
|
+
for (const f of walkFiles(projDir)) {
|
|
5287
|
+
if (f.endsWith(".jsonl")) {
|
|
5288
|
+
remapFileContents(f, srcHome, dstHome);
|
|
5289
|
+
verbose(`内容重映射: ${path.relative(dataDir, f)}`, isVerbose);
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
}
|
|
5293
|
+
const historyFile = path.join(dataDir, "history.jsonl");
|
|
5294
|
+
if (fs.existsSync(historyFile)) {
|
|
5295
|
+
remapFileContents(historyFile, srcHome, dstHome);
|
|
5296
|
+
verbose("内容重映射: history.jsonl", isVerbose);
|
|
5297
|
+
}
|
|
5298
|
+
for (const f of PLUGIN_FILES) {
|
|
5299
|
+
const p = path.join(dataDir, "plugins", f);
|
|
5300
|
+
if (fs.existsSync(p)) {
|
|
5301
|
+
remapFileContents(p, srcHome, dstHome);
|
|
5302
|
+
verbose(`内容重映射: plugins/${f}`, isVerbose);
|
|
5303
|
+
}
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
|
|
5307
|
+
// 预览模式
|
|
5308
|
+
if (dryRun) {
|
|
5309
|
+
log("预览(dry-run) — 将导入以下内容:");
|
|
5310
|
+
for (const cat of cats) {
|
|
5311
|
+
console.log(` ${chalk.bold(cat)}:`);
|
|
5312
|
+
dryRunCategory(cat, dataDir);
|
|
5313
|
+
}
|
|
5314
|
+
log("预览结束(未做任何修改)");
|
|
5315
|
+
return;
|
|
5316
|
+
}
|
|
5317
|
+
|
|
5318
|
+
// 备份现有数据
|
|
5319
|
+
if (options.backup !== false && fs.existsSync(claudeDir)) {
|
|
5320
|
+
const backupDir = `${claudeDir}-backup-${getTimestamp()}`;
|
|
5321
|
+
log(`备份 ${claudeDir} → ${backupDir}`);
|
|
5322
|
+
fs.cpSync(claudeDir, backupDir, { recursive: true });
|
|
5323
|
+
}
|
|
5324
|
+
|
|
5325
|
+
// 应用导入
|
|
5326
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
5327
|
+
for (const cat of cats) {
|
|
5328
|
+
importCategory(cat, dataDir, claudeDir, strategy, isVerbose);
|
|
5329
|
+
}
|
|
5330
|
+
log("导入完成!");
|
|
5331
|
+
} finally {
|
|
5332
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
5333
|
+
}
|
|
5334
|
+
}
|
|
5335
|
+
|
|
5336
|
+
function dryRunCategory(cat, dataDir) {
|
|
5337
|
+
const listFile = (f) =>
|
|
5338
|
+
fs.existsSync(path.join(dataDir, f)) && console.log(` ${f}`);
|
|
5339
|
+
const listDir = (d) => {
|
|
5340
|
+
const p = path.join(dataDir, d);
|
|
5341
|
+
if (!fs.existsSync(p)) return;
|
|
5342
|
+
const entries = fs.readdirSync(p);
|
|
5343
|
+
for (const name of entries.slice(0, 20)) {
|
|
5344
|
+
console.log(` ${d}/${name}${fs.statSync(path.join(p, name)).isDirectory() ? "/" : ""}`);
|
|
5345
|
+
}
|
|
5346
|
+
if (entries.length > 20) console.log(` ... 共 ${entries.length} 项`);
|
|
5347
|
+
};
|
|
5348
|
+
if (CATEGORY_FILES[cat]) {
|
|
5349
|
+
CATEGORY_FILES[cat].forEach(listFile);
|
|
5350
|
+
} else if (cat === "plugins") {
|
|
5351
|
+
PLUGIN_FILES.forEach((f) => listFile(`plugins/${f}`));
|
|
5352
|
+
} else if (cat === "memory") {
|
|
5353
|
+
MEMORY_FILES.forEach(listFile);
|
|
5354
|
+
listDir(MEMORY_DIR);
|
|
5355
|
+
} else {
|
|
5356
|
+
listDir(cat);
|
|
5357
|
+
}
|
|
5358
|
+
}
|
|
5359
|
+
|
|
5360
|
+
function importCategory(cat, dataDir, claudeDir, strategy, isVerbose) {
|
|
5361
|
+
if (CATEGORY_FILES[cat]) {
|
|
5362
|
+
importFiles(dataDir, claudeDir, CATEGORY_FILES[cat], strategy, isVerbose);
|
|
5363
|
+
} else if (CATEGORY_DIRS[cat]) {
|
|
5364
|
+
importDir(
|
|
5365
|
+
path.join(dataDir, cat),
|
|
5366
|
+
path.join(claudeDir, cat),
|
|
5367
|
+
strategy,
|
|
5368
|
+
isVerbose
|
|
5369
|
+
);
|
|
5370
|
+
} else if (cat === "plugins") {
|
|
5371
|
+
fs.mkdirSync(path.join(claudeDir, "plugins"), { recursive: true });
|
|
5372
|
+
importFiles(
|
|
5373
|
+
path.join(dataDir, "plugins"),
|
|
5374
|
+
path.join(claudeDir, "plugins"),
|
|
5375
|
+
PLUGIN_FILES,
|
|
5376
|
+
strategy,
|
|
5377
|
+
isVerbose
|
|
5378
|
+
);
|
|
5379
|
+
} else if (cat === "memory") {
|
|
5380
|
+
importFiles(dataDir, claudeDir, MEMORY_FILES, strategy, isVerbose);
|
|
5381
|
+
importDir(
|
|
5382
|
+
path.join(dataDir, MEMORY_DIR),
|
|
5383
|
+
path.join(claudeDir, MEMORY_DIR),
|
|
5384
|
+
strategy,
|
|
5385
|
+
isVerbose
|
|
5386
|
+
);
|
|
5387
|
+
}
|
|
5388
|
+
verbose(`已导入: ${cat}`, isVerbose);
|
|
5389
|
+
}
|
|
5390
|
+
|
|
5391
|
+
// 单文件按策略导入(json 浅合并 / jsonl 去重合并)
|
|
5392
|
+
function importFiles(srcDir, dstDir, files, strategy, isVerbose) {
|
|
5393
|
+
for (const f of files) {
|
|
5394
|
+
const src = path.join(srcDir, f);
|
|
5395
|
+
const dst = path.join(dstDir, f);
|
|
5396
|
+
if (!fs.existsSync(src)) continue;
|
|
5397
|
+
if (fs.existsSync(dst)) {
|
|
5398
|
+
if (strategy === "skip") {
|
|
5399
|
+
verbose(`跳过(已存在): ${f}`, isVerbose);
|
|
5400
|
+
continue;
|
|
5401
|
+
}
|
|
5402
|
+
if (strategy === "merge" && f.endsWith(".json")) {
|
|
5403
|
+
mergeJson(src, dst);
|
|
5404
|
+
verbose(`已合并: ${f}`, isVerbose);
|
|
5405
|
+
continue;
|
|
5406
|
+
}
|
|
5407
|
+
if (strategy === "merge" && f.endsWith(".jsonl")) {
|
|
5408
|
+
importJsonl(src, dst);
|
|
5409
|
+
verbose(`已合并 JSONL: ${f}`, isVerbose);
|
|
5410
|
+
continue;
|
|
5411
|
+
}
|
|
5412
|
+
}
|
|
5413
|
+
fs.mkdirSync(dstDir, { recursive: true });
|
|
5414
|
+
fs.copyFileSync(src, dst);
|
|
5415
|
+
verbose(`已拷贝: ${f}`, isVerbose);
|
|
5416
|
+
}
|
|
5417
|
+
}
|
|
5418
|
+
|
|
5419
|
+
// 目录按策略导入: overwrite 整目录覆盖; skip/merge 逐文件处理
|
|
5420
|
+
function importDir(src, dst, strategy, isVerbose) {
|
|
5421
|
+
if (!fs.existsSync(src)) return;
|
|
5422
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
5423
|
+
if (strategy === "overwrite") {
|
|
5424
|
+
fs.cpSync(src, dst, { recursive: true });
|
|
5425
|
+
return;
|
|
5426
|
+
}
|
|
5427
|
+
for (const file of walkFiles(src)) {
|
|
5428
|
+
const rel = path.relative(src, file);
|
|
5429
|
+
const dstFile = path.join(dst, rel);
|
|
5430
|
+
if (!fs.existsSync(dstFile)) {
|
|
5431
|
+
fs.mkdirSync(path.dirname(dstFile), { recursive: true });
|
|
5432
|
+
fs.copyFileSync(file, dstFile);
|
|
5433
|
+
verbose(`新增: ${rel}`, isVerbose);
|
|
5434
|
+
} else if (strategy === "skip") {
|
|
5435
|
+
verbose(`跳过(已存在): ${rel}`, isVerbose);
|
|
5436
|
+
} else if (file.endsWith(".jsonl")) {
|
|
5437
|
+
importJsonl(file, dstFile);
|
|
5438
|
+
verbose(`已合并 JSONL: ${rel}`, isVerbose);
|
|
5439
|
+
} else if (file.endsWith(".json")) {
|
|
5440
|
+
mergeJson(file, dstFile);
|
|
5441
|
+
verbose(`已合并 JSON: ${rel}`, isVerbose);
|
|
5442
|
+
} else {
|
|
5443
|
+
fs.copyFileSync(file, dstFile);
|
|
5444
|
+
verbose(`覆盖(非JSON): ${rel}`, isVerbose);
|
|
5445
|
+
}
|
|
5446
|
+
}
|
|
5447
|
+
}
|
|
5448
|
+
|
|
5449
|
+
// JSONL 按策略导入; merge 时按 timestamp+sessionId 去重(目标优先)
|
|
5450
|
+
function importJsonl(src, dst, strategy, isVerbose) {
|
|
5451
|
+
if (!fs.existsSync(dst)) {
|
|
5452
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
5453
|
+
fs.copyFileSync(src, dst);
|
|
5454
|
+
return;
|
|
5455
|
+
}
|
|
5456
|
+
const lines = [];
|
|
5457
|
+
const seen = new Set();
|
|
5458
|
+
// 先读目标(本机数据优先), 再读来源
|
|
5459
|
+
for (const p of [dst, src]) {
|
|
5460
|
+
const text = fs.readFileSync(p, "utf-8");
|
|
5461
|
+
for (let line of text.split("\n")) {
|
|
5462
|
+
line = line.replace(/\r$/, "");
|
|
5463
|
+
if (!line.trim()) continue;
|
|
5464
|
+
let key = null;
|
|
5465
|
+
try {
|
|
5466
|
+
const obj = JSON.parse(line);
|
|
5467
|
+
key = `${obj.timestamp || ""}|${obj.sessionId || ""}`;
|
|
5468
|
+
} catch {
|
|
5469
|
+
key = null;
|
|
5470
|
+
}
|
|
5471
|
+
if (key) {
|
|
5472
|
+
if (seen.has(key)) continue;
|
|
5473
|
+
seen.add(key);
|
|
5474
|
+
}
|
|
5475
|
+
lines.push(line);
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
fs.writeFileSync(dst, lines.join("\n") + "\n", "utf-8");
|
|
5479
|
+
}
|
|
5480
|
+
|
|
5481
|
+
// JSON 浅合并: 目标(本机)已有的 key 优先, 来源只补缺失
|
|
5482
|
+
function mergeJson(src, dst) {
|
|
5483
|
+
try {
|
|
5484
|
+
const srcObj = JSON.parse(fs.readFileSync(src, "utf-8"));
|
|
5485
|
+
const dstObj = JSON.parse(fs.readFileSync(dst, "utf-8"));
|
|
5486
|
+
if (
|
|
5487
|
+
srcObj &&
|
|
5488
|
+
dstObj &&
|
|
5489
|
+
typeof srcObj === "object" &&
|
|
5490
|
+
typeof dstObj === "object" &&
|
|
5491
|
+
!Array.isArray(srcObj) &&
|
|
5492
|
+
!Array.isArray(dstObj)
|
|
5493
|
+
) {
|
|
5494
|
+
for (const k of Object.keys(srcObj)) {
|
|
5495
|
+
if (!(k in dstObj)) dstObj[k] = srcObj[k];
|
|
5496
|
+
}
|
|
5497
|
+
fs.writeFileSync(dst, JSON.stringify(dstObj, null, 2) + "\n", "utf-8");
|
|
5498
|
+
}
|
|
5499
|
+
// 非对象 JSON: 保持目标不动
|
|
5500
|
+
} catch {
|
|
5501
|
+
// 解析失败: 保持目标不动
|
|
5502
|
+
}
|
|
5503
|
+
}
|
|
5504
|
+
|
|
5505
|
+
// ─── 命令入口 ─────────────────────────────────────────────────────────
|
|
5506
|
+
|
|
5507
|
+
async function runClaudeCommand(action, archive, options) {
|
|
5508
|
+
switch (action) {
|
|
5509
|
+
case "list":
|
|
5510
|
+
return archive
|
|
5511
|
+
? cmdListArchive(archive)
|
|
5512
|
+
: cmdListLocal(options);
|
|
5513
|
+
case "export":
|
|
5514
|
+
return cmdExport(options);
|
|
5515
|
+
case "import":
|
|
5516
|
+
return cmdImport(archive, options);
|
|
5517
|
+
default:
|
|
5518
|
+
die(`未知操作: ${action} (可选: list, export, import)`);
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
|
|
5522
|
+
function registerClaudeCommand(program, { markHandled, addHelpExample }) {
|
|
5523
|
+
program
|
|
5524
|
+
.command("claude <action> [archive]")
|
|
5525
|
+
.description(
|
|
5526
|
+
"Claude Code 数据迁移: list 查看本地/归档数据 | export 导出zip(本地/坚果云) | import 导入(本地/坚果云, 跨系统路径重映射)"
|
|
5527
|
+
)
|
|
5528
|
+
.option("--categories <cats>", `逗号分隔类别(默认全部): ${ALL_CATEGORIES}`)
|
|
5529
|
+
.option("--output <file>", "export 输出路径(默认 ./claude-export-YYYY-MM-DD.zip)")
|
|
5530
|
+
.option("--dest <dest>", "export 保存位置: local(默认交互选) | jianguoyun")
|
|
5531
|
+
.option("--source <source>", "import 归档来源: local(默认交互选) | jianguoyun")
|
|
5532
|
+
.option("--strategy <mode>", "import 策略: overwrite(默认) | merge | skip")
|
|
5533
|
+
.option("--claude-dir <path>", "指定 claude 数据目录(跳过检测与交互)")
|
|
5534
|
+
.option("--account <mail>", "坚果云账号(邮箱)")
|
|
5535
|
+
.option("--app-password <pwd>", "坚果云应用密码(网页端: 账户信息->安全选项->添加)")
|
|
5536
|
+
.option("--remote-dir <dir>", "坚果云远程目录(默认 claude-migrate)")
|
|
5537
|
+
.option("--cache", "坚果云凭据静默走缓存, 缓存缺失时回退交互")
|
|
5538
|
+
.option("--dry-run", "import 预览, 不写盘")
|
|
5539
|
+
.option("--no-backup", "import 跳过自动备份")
|
|
5540
|
+
.option("--verbose", "显示明细")
|
|
5541
|
+
.action((action, archive, options) => {
|
|
5542
|
+
// 同步置位标记, 防止 index.js 落入默认翻译分支
|
|
5543
|
+
markHandled();
|
|
5544
|
+
runClaudeCommand(action, archive, options).catch((error) => {
|
|
5545
|
+
console.error(chalk.red(error.message || String(error)));
|
|
5546
|
+
process.exit(1);
|
|
5547
|
+
});
|
|
5548
|
+
});
|
|
5549
|
+
addHelpExample("fe-it-beta claude list", "查看本地 Claude 数据概况");
|
|
5550
|
+
addHelpExample("fe-it-beta claude export --verbose", "导出数据(交互选类别与保存位置)");
|
|
5551
|
+
addHelpExample("fe-it-beta claude export --dest jianguoyun --cache", "导出直传坚果云, 凭据走缓存");
|
|
5552
|
+
addHelpExample("fe-it-beta claude import claude-export-2026-09-30.zip --dry-run", "预览导入内容");
|
|
5553
|
+
addHelpExample("fe-it-beta claude import --source jianguoyun --strategy merge --cache", "从坚果云选归档合并导入");
|
|
5554
|
+
}
|
|
5555
|
+
|
|
4184
5556
|
/**
|
|
4185
5557
|
* @fileoverview 不要单独中文
|
|
4186
5558
|
* @author ypf
|
|
@@ -5646,6 +7018,12 @@ registerStoryCommand(commander.program, {
|
|
|
5646
7018
|
addHelpExample: (cmd, desc) => helpExamples.push([cmd, desc]),
|
|
5647
7019
|
});
|
|
5648
7020
|
|
|
7021
|
+
let claudeHandled = false;
|
|
7022
|
+
registerClaudeCommand(commander.program, {
|
|
7023
|
+
markHandled: () => (claudeHandled = true),
|
|
7024
|
+
addHelpExample: (cmd, desc) => helpExamples.push([cmd, desc]),
|
|
7025
|
+
});
|
|
7026
|
+
|
|
5649
7027
|
commander.program.addHelpText(
|
|
5650
7028
|
"after",
|
|
5651
7029
|
"\n" +
|
|
@@ -5698,7 +7076,7 @@ const rules = [
|
|
|
5698
7076
|
],
|
|
5699
7077
|
},
|
|
5700
7078
|
];
|
|
5701
|
-
if (storyHandled) ; else if (sass) {
|
|
7079
|
+
if (claudeHandled) ; else if (storyHandled) ; else if (sass) {
|
|
5702
7080
|
sassMain(cliOpts).catch((error) => console.error(error));
|
|
5703
7081
|
} else if (upload) {
|
|
5704
7082
|
upload$1(cliOpts).catch((error) => console.error(error));
|