iivo-sub 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/bin/iivo-sub.js +168 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,11 +26,15 @@ The main menu includes:
|
|
|
26
26
|
- Quick configuration
|
|
27
27
|
- Backup configuration
|
|
28
28
|
- Restore backup
|
|
29
|
+
- Backup chat records
|
|
30
|
+
- Restore chat records
|
|
29
31
|
- Clear cache and backups
|
|
30
32
|
- Exit
|
|
31
33
|
|
|
32
34
|
Backup configuration lets you choose Codex, Claude Code, Hermes, OpenClaw, or all targets. You can name the backup yourself; the default name is `<date-time>_<target>`.
|
|
33
35
|
|
|
36
|
+
Chat record backup lets you back up common local conversation paths for Codex, Claude Code, Hermes, and OpenClaw. Restore creates a safety backup before replacing existing chat record paths.
|
|
37
|
+
|
|
34
38
|
After you enter an API key once, it is cached in `~/.iivo-sub/cache.json`. Next time the prompt shows a masked key such as `sk-x...abcd`; press Enter to reuse it or type a new key to replace it.
|
|
35
39
|
|
|
36
40
|
Existing config files are backed up under `~/.iivo-sub/backups/` before being replaced.
|
package/bin/iivo-sub.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from 'node:os'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import readline from 'node:readline'
|
|
7
7
|
|
|
8
|
-
const VERSION = '0.1.
|
|
8
|
+
const VERSION = '0.1.2'
|
|
9
9
|
const APP_DIR = path.join(os.homedir(), '.iivo-sub')
|
|
10
10
|
const CACHE_FILE = path.join(APP_DIR, 'cache.json')
|
|
11
11
|
const SCRIPTED_INPUT = !process.stdin.isTTY ? fs.readFileSync(0, 'utf8').split(/\r?\n/) : null
|
|
@@ -40,6 +40,8 @@ const MAIN_MENU = [
|
|
|
40
40
|
{ label: '快速配置', value: 'quick' },
|
|
41
41
|
{ label: '备份配置', value: 'backup' },
|
|
42
42
|
{ label: '恢复备份', value: 'restore' },
|
|
43
|
+
{ label: '备份聊天记录', value: 'backup-chats' },
|
|
44
|
+
{ label: '还原聊天记录', value: 'restore-chats' },
|
|
43
45
|
{ label: '清空缓存及备份', value: 'clear-cache' },
|
|
44
46
|
{ label: '退出', value: 'exit' }
|
|
45
47
|
]
|
|
@@ -52,6 +54,14 @@ const BACKUP_TARGETS = [
|
|
|
52
54
|
{ label: '全部备份', value: 'all' }
|
|
53
55
|
]
|
|
54
56
|
|
|
57
|
+
const CHAT_BACKUP_TARGETS = [
|
|
58
|
+
{ label: 'Codex 聊天记录', value: 'codex' },
|
|
59
|
+
{ label: 'Claude Code 聊天记录', value: 'claude-code' },
|
|
60
|
+
{ label: 'Hermes 聊天记录', value: 'hermes' },
|
|
61
|
+
{ label: 'OpenClaw 聊天记录', value: 'openclaw' },
|
|
62
|
+
{ label: '全部聊天记录', value: 'all' }
|
|
63
|
+
]
|
|
64
|
+
|
|
55
65
|
const RESTORE_CONFIRM_OPTIONS = [
|
|
56
66
|
{ label: '确认恢复', value: 'yes' },
|
|
57
67
|
{ label: '取消', value: 'no' }
|
|
@@ -260,6 +270,10 @@ function fileExists(filePath) {
|
|
|
260
270
|
return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
|
261
271
|
}
|
|
262
272
|
|
|
273
|
+
function pathExists(targetPath) {
|
|
274
|
+
return fs.existsSync(targetPath)
|
|
275
|
+
}
|
|
276
|
+
|
|
263
277
|
function configTargets() {
|
|
264
278
|
return {
|
|
265
279
|
codex: [
|
|
@@ -301,6 +315,38 @@ function filesForBackupTarget(target) {
|
|
|
301
315
|
return targets[target] ?? []
|
|
302
316
|
}
|
|
303
317
|
|
|
318
|
+
function chatTargets() {
|
|
319
|
+
return {
|
|
320
|
+
codex: [
|
|
321
|
+
path.join(os.homedir(), '.codex', 'sessions'),
|
|
322
|
+
path.join(os.homedir(), '.codex', 'history.json')
|
|
323
|
+
],
|
|
324
|
+
'claude-code': [
|
|
325
|
+
path.join(os.homedir(), '.claude', 'projects'),
|
|
326
|
+
path.join(os.homedir(), '.claude', 'todos'),
|
|
327
|
+
path.join(os.homedir(), '.claude', 'shell-snapshots')
|
|
328
|
+
],
|
|
329
|
+
hermes: [
|
|
330
|
+
path.join(os.homedir(), '.hermes', 'sessions'),
|
|
331
|
+
path.join(os.homedir(), '.hermes', 'chats'),
|
|
332
|
+
path.join(os.homedir(), '.hermes', 'history.json')
|
|
333
|
+
],
|
|
334
|
+
openclaw: [
|
|
335
|
+
path.join(os.homedir(), '.openclaw', 'sessions'),
|
|
336
|
+
path.join(os.homedir(), '.openclaw', 'chats'),
|
|
337
|
+
path.join(os.homedir(), '.openclaw', 'history.json')
|
|
338
|
+
]
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function pathsForChatBackupTarget(target) {
|
|
343
|
+
const targets = chatTargets()
|
|
344
|
+
if (target === 'all') {
|
|
345
|
+
return Object.values(targets).flat()
|
|
346
|
+
}
|
|
347
|
+
return targets[target] ?? []
|
|
348
|
+
}
|
|
349
|
+
|
|
304
350
|
function uniqueFiles(files) {
|
|
305
351
|
return [...new Set(files)]
|
|
306
352
|
}
|
|
@@ -309,6 +355,10 @@ function backupTargetLabel(value) {
|
|
|
309
355
|
return BACKUP_TARGETS.find((item) => item.value === value)?.label ?? value
|
|
310
356
|
}
|
|
311
357
|
|
|
358
|
+
function chatBackupTargetLabel(value) {
|
|
359
|
+
return CHAT_BACKUP_TARGETS.find((item) => item.value === value)?.label ?? value
|
|
360
|
+
}
|
|
361
|
+
|
|
312
362
|
function timestampForName() {
|
|
313
363
|
const now = new Date()
|
|
314
364
|
const pad = (value) => String(value).padStart(2, '0')
|
|
@@ -349,6 +399,19 @@ function copyConfigToBackup(filePath, backupRoot) {
|
|
|
349
399
|
}
|
|
350
400
|
}
|
|
351
401
|
|
|
402
|
+
function copyPathToBackup(sourcePath, backupRoot) {
|
|
403
|
+
const stat = fs.statSync(sourcePath)
|
|
404
|
+
const backupFile = toBackupFileName(sourcePath)
|
|
405
|
+
const backupPath = path.join(backupRoot, 'files', backupFile)
|
|
406
|
+
ensureDir(path.dirname(backupPath))
|
|
407
|
+
fs.cpSync(sourcePath, backupPath, { recursive: true, force: true })
|
|
408
|
+
return {
|
|
409
|
+
source: sourcePath,
|
|
410
|
+
backup: path.relative(backupRoot, backupPath),
|
|
411
|
+
type: stat.isDirectory() ? 'directory' : 'file'
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
352
415
|
function loadBackupManifest(dir) {
|
|
353
416
|
const manifestPath = path.join(dir, 'manifest.json')
|
|
354
417
|
if (!fileExists(manifestPath)) return null
|
|
@@ -746,6 +809,7 @@ async function backupConfig() {
|
|
|
746
809
|
ensureDir(backupRoot)
|
|
747
810
|
const files = existing.map((filePath) => copyConfigToBackup(filePath, backupRoot))
|
|
748
811
|
const manifest = {
|
|
812
|
+
kind: 'config',
|
|
749
813
|
name,
|
|
750
814
|
target,
|
|
751
815
|
target_label: backupTargetLabel(target),
|
|
@@ -769,27 +833,105 @@ async function backupConfig() {
|
|
|
769
833
|
await pause()
|
|
770
834
|
}
|
|
771
835
|
|
|
836
|
+
async function backupChatRecords() {
|
|
837
|
+
clear()
|
|
838
|
+
banner()
|
|
839
|
+
const target = await select('请选择聊天记录备份目标:', CHAT_BACKUP_TARGETS)
|
|
840
|
+
if (target === '__exit__') return '__exit__'
|
|
841
|
+
if (!target) return
|
|
842
|
+
|
|
843
|
+
const defaultName = `${timestampForName()}_chats_${target}`
|
|
844
|
+
clear()
|
|
845
|
+
banner()
|
|
846
|
+
const rawName = await question('请输入聊天记录备份名称 [Esc 返回上一步]', { defaultValue: defaultName })
|
|
847
|
+
if (rawName === '__exit__') return '__exit__'
|
|
848
|
+
if (rawName === null) return backupChatRecords()
|
|
849
|
+
|
|
850
|
+
const name = safeBackupName(rawName) || defaultName
|
|
851
|
+
const backupRoot = path.join(APP_DIR, 'backups', name)
|
|
852
|
+
if (fs.existsSync(backupRoot)) {
|
|
853
|
+
console.log(c('red', `备份名称已存在: ${name}`))
|
|
854
|
+
await pause()
|
|
855
|
+
return backupChatRecords()
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const candidates = uniqueFiles(pathsForChatBackupTarget(target))
|
|
859
|
+
const existing = candidates.filter(pathExists)
|
|
860
|
+
if (existing.length === 0) {
|
|
861
|
+
console.log(c('yellow', `没有找到可备份的 ${chatBackupTargetLabel(target)}。`))
|
|
862
|
+
console.log()
|
|
863
|
+
console.log(c('dim', '已检查常见目录:'))
|
|
864
|
+
for (const item of candidates) {
|
|
865
|
+
console.log(c('dim', `- ${item}`))
|
|
866
|
+
}
|
|
867
|
+
await pause()
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
ensureDir(backupRoot)
|
|
872
|
+
const files = existing.map((sourcePath) => copyPathToBackup(sourcePath, backupRoot))
|
|
873
|
+
const manifest = {
|
|
874
|
+
kind: 'chat',
|
|
875
|
+
name,
|
|
876
|
+
target,
|
|
877
|
+
target_label: chatBackupTargetLabel(target),
|
|
878
|
+
created_at: new Date().toISOString(),
|
|
879
|
+
files
|
|
880
|
+
}
|
|
881
|
+
fs.writeFileSync(path.join(backupRoot, 'manifest.json'), json(manifest), 'utf8')
|
|
882
|
+
|
|
883
|
+
clear()
|
|
884
|
+
banner()
|
|
885
|
+
console.log(c('green', '聊天记录备份完成。'))
|
|
886
|
+
console.log()
|
|
887
|
+
console.log(`备份名称: ${name}`)
|
|
888
|
+
console.log(`备份目标: ${chatBackupTargetLabel(target)}`)
|
|
889
|
+
console.log(`备份目录: ${backupRoot}`)
|
|
890
|
+
console.log()
|
|
891
|
+
console.log(c('cyan', '已备份路径:'))
|
|
892
|
+
for (const file of files) {
|
|
893
|
+
console.log(`- ${file.source}`)
|
|
894
|
+
}
|
|
895
|
+
await pause()
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function backupEntryMatchesKind(entryName, backupDir, kind) {
|
|
899
|
+
const manifest = loadBackupManifest(path.join(backupDir, entryName))
|
|
900
|
+
if (!manifest) return kind === 'config'
|
|
901
|
+
if (kind === 'config') return !manifest.kind || manifest.kind === 'config'
|
|
902
|
+
return manifest.kind === kind
|
|
903
|
+
}
|
|
904
|
+
|
|
772
905
|
async function restoreBackup() {
|
|
906
|
+
return restoreBackupByKind('config', '请选择要查看的配置备份:', '暂无配置备份。')
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function restoreChatRecords() {
|
|
910
|
+
return restoreBackupByKind('chat', '请选择要还原的聊天记录备份:', '暂无聊天记录备份。')
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
773
914
|
clear()
|
|
774
915
|
banner()
|
|
775
916
|
const backupDir = path.join(APP_DIR, 'backups')
|
|
776
917
|
if (!fs.existsSync(backupDir)) {
|
|
777
|
-
console.log(c('yellow',
|
|
918
|
+
console.log(c('yellow', emptyMessage))
|
|
778
919
|
return pause()
|
|
779
920
|
}
|
|
780
921
|
|
|
781
922
|
const entries = fs.readdirSync(backupDir, { withFileTypes: true })
|
|
782
923
|
.filter((entry) => entry.isDirectory())
|
|
783
924
|
.map((entry) => entry.name)
|
|
925
|
+
.filter((name) => backupEntryMatchesKind(name, backupDir, kind))
|
|
784
926
|
.sort()
|
|
785
927
|
.reverse()
|
|
786
928
|
|
|
787
929
|
if (entries.length === 0) {
|
|
788
|
-
console.log(c('yellow',
|
|
930
|
+
console.log(c('yellow', emptyMessage))
|
|
789
931
|
return pause()
|
|
790
932
|
}
|
|
791
933
|
|
|
792
|
-
const selected = await select(
|
|
934
|
+
const selected = await select(title, [
|
|
793
935
|
...entries.map((name) => ({ label: name, value: name })),
|
|
794
936
|
{ label: '返回', value: null }
|
|
795
937
|
])
|
|
@@ -816,13 +958,16 @@ async function restoreBackup() {
|
|
|
816
958
|
console.log(`备份目标: ${manifest.target_label || manifest.target || '未知'}`)
|
|
817
959
|
console.log(`创建时间: ${manifest.created_at || '未知'}`)
|
|
818
960
|
console.log()
|
|
819
|
-
console.log(c('cyan', '将恢复以下文件:'))
|
|
961
|
+
console.log(c('cyan', kind === 'chat' ? '将还原以下聊天记录路径:' : '将恢复以下文件:'))
|
|
820
962
|
for (const file of manifest.files || []) {
|
|
821
963
|
console.log(`- ${file.source}`)
|
|
822
964
|
}
|
|
823
965
|
console.log()
|
|
824
966
|
|
|
825
|
-
const
|
|
967
|
+
const confirmText = kind === 'chat'
|
|
968
|
+
? '确认还原这个聊天记录备份吗?当前同名聊天记录会先自动备份。'
|
|
969
|
+
: '确认恢复这个备份吗?当前同名配置会先自动备份。'
|
|
970
|
+
const confirm = await select(confirmText, RESTORE_CONFIRM_OPTIONS)
|
|
826
971
|
if (confirm === '__exit__') return '__exit__'
|
|
827
972
|
if (confirm !== 'yes') return
|
|
828
973
|
|
|
@@ -832,18 +977,21 @@ async function restoreBackup() {
|
|
|
832
977
|
for (const file of manifest.files || []) {
|
|
833
978
|
const source = file.source
|
|
834
979
|
const backupPath = path.join(selectedDir, file.backup)
|
|
835
|
-
if (!source || !
|
|
836
|
-
if (
|
|
837
|
-
|
|
980
|
+
if (!source || !pathExists(backupPath)) continue
|
|
981
|
+
if (pathExists(source)) {
|
|
982
|
+
copyPathToBackup(source, safetyBackup)
|
|
838
983
|
}
|
|
839
984
|
ensureDir(path.dirname(source))
|
|
840
|
-
|
|
985
|
+
if (pathExists(source)) {
|
|
986
|
+
deletePath(source)
|
|
987
|
+
}
|
|
988
|
+
fs.cpSync(backupPath, source, { recursive: true, force: true })
|
|
841
989
|
restored.push(source)
|
|
842
990
|
}
|
|
843
991
|
|
|
844
992
|
clear()
|
|
845
993
|
banner()
|
|
846
|
-
console.log(c('green', '恢复完成。'))
|
|
994
|
+
console.log(c('green', kind === 'chat' ? '聊天记录还原完成。' : '恢复完成。'))
|
|
847
995
|
console.log()
|
|
848
996
|
for (const file of restored) {
|
|
849
997
|
console.log(`- ${file}`)
|
|
@@ -858,7 +1006,7 @@ async function clearCacheAndBackups() {
|
|
|
858
1006
|
banner()
|
|
859
1007
|
console.log(c('yellow', '将删除以下 iivo-sub 数据:'))
|
|
860
1008
|
console.log(`- API Key 缓存: ${CACHE_FILE}`)
|
|
861
|
-
console.log(`- 备份目录: ${path.join(APP_DIR, 'backups')}`)
|
|
1009
|
+
console.log(`- 备份目录: ${path.join(APP_DIR, 'backups')} ${c('dim', '(包含配置备份和聊天记录备份)')}`)
|
|
862
1010
|
console.log(`- 临时配置脚本: ${path.join(APP_DIR, '*.env / *.ps1 / *.cmd')}`)
|
|
863
1011
|
console.log(`- 最近配置摘要: ${path.join(APP_DIR, 'config.json')}`)
|
|
864
1012
|
console.log()
|
|
@@ -925,6 +1073,14 @@ async function main() {
|
|
|
925
1073
|
const result = await restoreBackup()
|
|
926
1074
|
if (result === '__exit__') break
|
|
927
1075
|
}
|
|
1076
|
+
if (action === 'backup-chats') {
|
|
1077
|
+
const result = await backupChatRecords()
|
|
1078
|
+
if (result === '__exit__') break
|
|
1079
|
+
}
|
|
1080
|
+
if (action === 'restore-chats') {
|
|
1081
|
+
const result = await restoreChatRecords()
|
|
1082
|
+
if (result === '__exit__') break
|
|
1083
|
+
}
|
|
928
1084
|
if (action === 'clear-cache') {
|
|
929
1085
|
const result = await clearCacheAndBackups()
|
|
930
1086
|
if (result === '__exit__') break
|