@wildusk/dsh-backup 0.1.0
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 +54 -0
- package/cordis.patch.yml +4 -0
- package/package.json +56 -0
- package/src/backup-core.ts +176 -0
- package/src/commands.ts +271 -0
- package/src/index.ts +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @wildusk/dsh-backup
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness plugin backup. Archives are written to `~/.dsh-backup`.
|
|
4
|
+
|
|
5
|
+
Repository: https://github.com/usertoandy/dsh-backup.git
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
| Command | Function |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `/backup` | Archive the DSH home to `~/.dsh-backup/dsh-backup-<timestamp>.tar.gz` and report the archive path and size. |
|
|
12
|
+
| `/backup-list` | List the existing archives in `~/.dsh-backup` (number, filename, size, date). |
|
|
13
|
+
| `/backup-restore` | Ask (via the harness question UI) which backup to restore and how, then extract it into the DSH home. |
|
|
14
|
+
|
|
15
|
+
### Restore modes
|
|
16
|
+
|
|
17
|
+
The confirm question offers the original tool's three answers:
|
|
18
|
+
|
|
19
|
+
- `yes` — extract over the current files; files not in the backup are kept.
|
|
20
|
+
- `clean` — empty the DSH home first, then extract (exact restore of the backup).
|
|
21
|
+
Refused when the target resolves to `/` or the user's home directory.
|
|
22
|
+
- `no` — cancel the restore.
|
|
23
|
+
|
|
24
|
+
After a restore you may need to restart any running `dsh` processes.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
The host loads `src/index.ts` directly (no build step).
|
|
29
|
+
|
|
30
|
+
1. Download from GitHub:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
git clone https://github.com/usertoandy/dsh-backup.git
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
2. Register the plugin with a profile (the bundles list is updated
|
|
37
|
+
automatically by `dsh plugin add`):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
dsh plugin --profile web add ./dsh-backup
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Restart `dsh web` afterwards; `/backup`, `/backup-list` and `/backup-restore`
|
|
44
|
+
then appear in the command palette.
|
|
45
|
+
|
|
46
|
+
At runtime the plugin uses the host-provided `commands` and `userQuestions`
|
|
47
|
+
services plus `tar` on the PATH.
|
|
48
|
+
|
|
49
|
+
## Files
|
|
50
|
+
|
|
51
|
+
- `src/index.ts` — plugin entry: `name` / `inject` / `apply`.
|
|
52
|
+
- `src/commands.ts` — the three command handlers and their output text.
|
|
53
|
+
- `src/backup-core.ts` — timestamped tar.gz creation, listing and guarded
|
|
54
|
+
restore (asynchronous, abort-aware).
|
package/cordis.patch.yml
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wildusk/dsh-backup",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: /backup, /backup-list and /backup-restore commands to back up, list and restore the DSH home directory. DeepSeek Harness 备份插件:一键备份 DSH 主目录(~/.dsh)为时间戳 tar.gz 归档、查看历史备份并恢复。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"cordis.patch.yml"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "tsx tests/core.test.mjs"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dshhub": {
|
|
25
|
+
"schemaVersion": 1,
|
|
26
|
+
"displayName": "@wildusk/dsh-backup",
|
|
27
|
+
"summary": "DeepSeek Harness 备份插件:/backup 一键将 DSH 主目录(~/.dsh)备份为时间戳 tar.gz 归档,/backup-list 查看历史备份,/backup-restore 交互式恢复到指定备份。",
|
|
28
|
+
"categories": [
|
|
29
|
+
"备份"
|
|
30
|
+
],
|
|
31
|
+
"surfaces": [
|
|
32
|
+
"host"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"deepseek-harness",
|
|
37
|
+
"dsh",
|
|
38
|
+
"dsh-plugin",
|
|
39
|
+
"cordis",
|
|
40
|
+
"backup",
|
|
41
|
+
"restore"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
46
|
+
"@deepseek-ai/dsh-commands": ">=0.1.2-alpha.4",
|
|
47
|
+
"@deepseek-ai/dsh-user-questions": ">=0.1.2-alpha.4"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
51
|
+
"@deepseek-ai/dsh-commands": "^0.1.2-rc.1",
|
|
52
|
+
"@deepseek-ai/dsh-user-questions": "^0.1.2-rc.1",
|
|
53
|
+
"@types/node": "^24",
|
|
54
|
+
"tsx": "^4.19.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core backup/list/restore logic: timestamped `tar -czf` archives of the DSH home under the backup directory,
|
|
3
|
+
* a newest-first listing, and guarded extraction back into the DSH home.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the original CLI tool this runs inside the harness host process, so
|
|
6
|
+
* every operation is asynchronous, `tar` output is captured instead of
|
|
7
|
+
* inherited, and long-running work honours an abort signal.
|
|
8
|
+
*
|
|
9
|
+
* @module @wildusk/dsh-backup/backup-core
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'child_process'
|
|
13
|
+
import { existsSync } from 'fs'
|
|
14
|
+
import { mkdir, readdir, rm, stat } from 'fs/promises'
|
|
15
|
+
import * as os from 'os'
|
|
16
|
+
import * as path from 'path'
|
|
17
|
+
|
|
18
|
+
/** Filename shape shared by every archive this tool writes. */
|
|
19
|
+
export const BACKUP_PATTERN = /^dsh-backup-.+\.tar\.gz$/u
|
|
20
|
+
|
|
21
|
+
/** Most recent backups offered as selectable options in the restore dialog. */
|
|
22
|
+
export const RESTORE_CHOICES = 10
|
|
23
|
+
|
|
24
|
+
/** One existing backup archive. */
|
|
25
|
+
export interface BackupEntry {
|
|
26
|
+
/** Archive file name inside the backup directory. */
|
|
27
|
+
readonly file: string
|
|
28
|
+
/** Absolute archive path. */
|
|
29
|
+
readonly path: string
|
|
30
|
+
/** File size in MB with two decimals. */
|
|
31
|
+
readonly sizeMB: string
|
|
32
|
+
/** Modification time as `YYYY-MM-DD HH:MM:SS`. */
|
|
33
|
+
readonly date: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolved directories this plugin operates on. */
|
|
37
|
+
export interface BackupPaths {
|
|
38
|
+
/** DSH home directory being backed up and restored. */
|
|
39
|
+
readonly dshHome: string
|
|
40
|
+
/** Directory receiving the `dsh-backup-*.tar.gz` archives. */
|
|
41
|
+
readonly backupDir: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Domain error whose message is safe to show to the user. */
|
|
45
|
+
export class BackupError extends Error {
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message)
|
|
48
|
+
this.name = 'BackupError'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Raised when the invocation signal aborted the pending tar work. */
|
|
53
|
+
export class BackupCancelledError extends BackupError {
|
|
54
|
+
constructor() {
|
|
55
|
+
super('Operation cancelled.')
|
|
56
|
+
this.name = 'BackupCancelledError'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Restore modes mirroring the original tool's yes/clean answer. */
|
|
61
|
+
export type RestoreMode = 'yes' | 'clean'
|
|
62
|
+
|
|
63
|
+
/** Local-time timestamp in the shared `YYYY-MM-DD_HH-MM-SS` format. */
|
|
64
|
+
export function getTimestamp(): string {
|
|
65
|
+
const date = new Date()
|
|
66
|
+
const timezoneOffsetMinutes = date.getTimezoneOffset()
|
|
67
|
+
const offsetMs = timezoneOffsetMinutes * 60 * 1000
|
|
68
|
+
const localAsUtc = new Date(date.getTime() - offsetMs)
|
|
69
|
+
return localAsUtc.toISOString()
|
|
70
|
+
.replace(/[:.]/gu, '-')
|
|
71
|
+
.replace('T', '_')
|
|
72
|
+
.slice(0, 19)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Human-readable MB size with two decimals. */
|
|
76
|
+
export function formatSizeMB(size: number): string {
|
|
77
|
+
return (size / 1024 / 1024).toFixed(2)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** List the backup directory, newest archive first; empty when it does not exist. */
|
|
81
|
+
export async function listBackups(backupDir: string): Promise<BackupEntry[]> {
|
|
82
|
+
if (!existsSync(backupDir)) return []
|
|
83
|
+
const files = (await readdir(backupDir))
|
|
84
|
+
.filter(file => BACKUP_PATTERN.test(file))
|
|
85
|
+
.sort()
|
|
86
|
+
.reverse()
|
|
87
|
+
const entries: BackupEntry[] = []
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
const filePath = path.join(backupDir, file)
|
|
90
|
+
const stats = await stat(filePath)
|
|
91
|
+
entries.push({
|
|
92
|
+
file,
|
|
93
|
+
path: filePath,
|
|
94
|
+
sizeMB: formatSizeMB(stats.size),
|
|
95
|
+
date: stats.mtime.toISOString().replace('T', ' ').slice(0, 19),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
return entries
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Create a timestamped tar.gz archive of the DSH home inside the backup directory. */
|
|
102
|
+
export async function createBackupArchive(paths: BackupPaths, signal: AbortSignal): Promise<BackupEntry> {
|
|
103
|
+
if (!existsSync(paths.dshHome)) {
|
|
104
|
+
throw new BackupError(`DSH_HOME directory does not exist: ${paths.dshHome}`)
|
|
105
|
+
}
|
|
106
|
+
await mkdir(paths.backupDir, { recursive: true })
|
|
107
|
+
const file = `dsh-backup-${getTimestamp()}.tar.gz`
|
|
108
|
+
const archivePath = path.join(paths.backupDir, file)
|
|
109
|
+
await runTar(['-czf', archivePath, '-C', paths.dshHome, '.'], signal)
|
|
110
|
+
const stats = await stat(archivePath)
|
|
111
|
+
return {
|
|
112
|
+
file,
|
|
113
|
+
path: archivePath,
|
|
114
|
+
sizeMB: formatSizeMB(stats.size),
|
|
115
|
+
date: stats.mtime.toISOString().replace('T', ' ').slice(0, 19),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Extract a backup archive into the DSH home; `clean` empties the target first. */
|
|
120
|
+
export async function restoreBackupArchive(
|
|
121
|
+
archivePath: string,
|
|
122
|
+
paths: BackupPaths,
|
|
123
|
+
mode: RestoreMode,
|
|
124
|
+
signal: AbortSignal,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
if (!existsSync(archivePath)) {
|
|
127
|
+
throw new BackupError(`Backup archive does not exist: ${archivePath}`)
|
|
128
|
+
}
|
|
129
|
+
if (mode === 'clean') {
|
|
130
|
+
// Clean mode must remove files absent from the archive, so wipe the target first.
|
|
131
|
+
const target = path.resolve(paths.dshHome)
|
|
132
|
+
if (target === '/' || target === path.resolve(os.homedir())) {
|
|
133
|
+
throw new BackupError(`Refusing to clean unsafe DSH_HOME: ${paths.dshHome}`)
|
|
134
|
+
}
|
|
135
|
+
await rm(paths.dshHome, { recursive: true, force: true })
|
|
136
|
+
}
|
|
137
|
+
await mkdir(paths.dshHome, { recursive: true })
|
|
138
|
+
await runTar(['-xzf', archivePath, '-C', paths.dshHome], signal)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Run one tar invocation, killing the child when the signal fires. */
|
|
142
|
+
function runTar(args: readonly string[], signal: AbortSignal): Promise<void> {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
if (signal.aborted) {
|
|
145
|
+
reject(new BackupCancelledError())
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const child = spawn('tar', [...args], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
149
|
+
let stderr = ''
|
|
150
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
151
|
+
stderr += chunk.toString()
|
|
152
|
+
})
|
|
153
|
+
const onAbort = (): void => {
|
|
154
|
+
child.kill('SIGTERM')
|
|
155
|
+
}
|
|
156
|
+
const cleanup = (): void => {
|
|
157
|
+
signal.removeEventListener('abort', onAbort)
|
|
158
|
+
}
|
|
159
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
160
|
+
child.on('error', error => {
|
|
161
|
+
cleanup()
|
|
162
|
+
reject(new BackupError(`Failed to run tar: ${error.message}`))
|
|
163
|
+
})
|
|
164
|
+
child.on('close', code => {
|
|
165
|
+
cleanup()
|
|
166
|
+
if (signal.aborted) {
|
|
167
|
+
reject(new BackupCancelledError())
|
|
168
|
+
} else if (code === 0) {
|
|
169
|
+
resolve()
|
|
170
|
+
} else {
|
|
171
|
+
const detail = stderr.trim()
|
|
172
|
+
reject(new BackupError(`tar exited with code ${code}${detail === '' ? '' : `: ${detail}`}`))
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-command handlers for /backup, /backup-list and /backup-restore.
|
|
3
|
+
*
|
|
4
|
+
* Output follows the harness's built-in command conventions: handlers return
|
|
5
|
+
* multi-line plain text rendered in the command card, argument errors carry a
|
|
6
|
+
* `Usage:` trailer, operational events go to the cordis logger with a
|
|
7
|
+
* `dsh-backup:` prefix, and the restore selection/confirmation prompts use the
|
|
8
|
+
* user-questions UI — the chat equivalent of the original tool's readline flow.
|
|
9
|
+
*
|
|
10
|
+
* @module @wildusk/dsh-backup/commands
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
|
|
15
|
+
import type {
|
|
16
|
+
AskUserQuestionAnswerItem,
|
|
17
|
+
AskUserQuestionItem,
|
|
18
|
+
} from '@deepseek-ai/dsh-user-questions'
|
|
19
|
+
import {
|
|
20
|
+
type BackupEntry,
|
|
21
|
+
BackupCancelledError,
|
|
22
|
+
type BackupPaths,
|
|
23
|
+
BackupError,
|
|
24
|
+
RESTORE_CHOICES,
|
|
25
|
+
type RestoreMode,
|
|
26
|
+
createBackupArchive,
|
|
27
|
+
listBackups,
|
|
28
|
+
restoreBackupArchive,
|
|
29
|
+
} from './backup-core.ts'
|
|
30
|
+
|
|
31
|
+
const USAGE_BACKUP = 'Usage: /backup'
|
|
32
|
+
const USAGE_LIST = 'Usage: /backup-list'
|
|
33
|
+
const USAGE_RESTORE = 'Usage: /backup-restore'
|
|
34
|
+
|
|
35
|
+
/** Register the three commands for the composed command adapter. */
|
|
36
|
+
export function registerBackupCommands(ctx: Context, paths: BackupPaths): void {
|
|
37
|
+
ctx.effect(() => ctx.commands.register({
|
|
38
|
+
name: 'backup',
|
|
39
|
+
description: 'back up the DSH home directory to a timestamped tar.gz archive',
|
|
40
|
+
handler: invocation => runBackupCommand(ctx, paths, invocation),
|
|
41
|
+
}), 'dsh-backup: /backup')
|
|
42
|
+
ctx.effect(() => ctx.commands.register({
|
|
43
|
+
name: 'backup-list',
|
|
44
|
+
description: 'list the existing dsh-backup archives',
|
|
45
|
+
handler: invocation => runBackupListCommand(ctx, paths, invocation),
|
|
46
|
+
}), 'dsh-backup: /backup-list')
|
|
47
|
+
ctx.effect(() => ctx.commands.register({
|
|
48
|
+
name: 'backup-restore',
|
|
49
|
+
description: 'restore the DSH home directory from a backup archive (asks which one and confirms)',
|
|
50
|
+
handler: invocation => runBackupRestoreCommand(ctx, paths, invocation),
|
|
51
|
+
}), 'dsh-backup: /backup-restore')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `/backup`: archive the DSH home into the backup directory. */
|
|
55
|
+
async function runBackupCommand(
|
|
56
|
+
ctx: Context,
|
|
57
|
+
paths: BackupPaths,
|
|
58
|
+
invocation: CommandInvocation,
|
|
59
|
+
): Promise<CommandResult> {
|
|
60
|
+
if (invocation.rawInput.trim() !== '') {
|
|
61
|
+
return { kind: 'error', text: `The /backup command takes no arguments. ${USAGE_BACKUP}` }
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const entry = await createBackupArchive(paths, invocation.signal)
|
|
65
|
+
ctx.logger.info(`dsh-backup: created ${entry.path} (${entry.sizeMB} MB)`)
|
|
66
|
+
return {
|
|
67
|
+
kind: 'success',
|
|
68
|
+
text: [
|
|
69
|
+
'Backup completed.',
|
|
70
|
+
`Source: ${paths.dshHome}`,
|
|
71
|
+
`Archive: ${entry.path}`,
|
|
72
|
+
`Size: ${entry.sizeMB} MB`,
|
|
73
|
+
'',
|
|
74
|
+
'Tip: /backup-list shows all backups; /backup-restore restores one.',
|
|
75
|
+
].join('\n'),
|
|
76
|
+
}
|
|
77
|
+
} catch (error: unknown) {
|
|
78
|
+
return settleFailure(ctx, 'Backup', error)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `/backup-list`: render the backup directory as the original tool's table. */
|
|
83
|
+
async function runBackupListCommand(
|
|
84
|
+
ctx: Context,
|
|
85
|
+
paths: BackupPaths,
|
|
86
|
+
invocation: CommandInvocation,
|
|
87
|
+
): Promise<CommandResult> {
|
|
88
|
+
if (invocation.rawInput.trim() !== '') {
|
|
89
|
+
return { kind: 'error', text: `The /backup-list command takes no arguments. ${USAGE_LIST}` }
|
|
90
|
+
}
|
|
91
|
+
let backups: BackupEntry[]
|
|
92
|
+
try {
|
|
93
|
+
backups = await listBackups(paths.backupDir)
|
|
94
|
+
} catch (error: unknown) {
|
|
95
|
+
return settleFailure(ctx, 'List', error)
|
|
96
|
+
}
|
|
97
|
+
if (backups.length === 0) {
|
|
98
|
+
return {
|
|
99
|
+
kind: 'success',
|
|
100
|
+
text: `No backups found in ${paths.backupDir}.\nCreate one with /backup.`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const lines = [
|
|
104
|
+
`Existing backups in ${paths.backupDir}: ${backups.length}`,
|
|
105
|
+
'',
|
|
106
|
+
' # Filename Size Date',
|
|
107
|
+
' ── ─────────────────────────────────────────── ────────── ────────────────────',
|
|
108
|
+
]
|
|
109
|
+
backups.forEach((backup, index) => {
|
|
110
|
+
lines.push(` ${String(index + 1).padStart(2)} ${backup.file.padEnd(43)} ${backup.sizeMB.padStart(6)} MB ${backup.date}`)
|
|
111
|
+
})
|
|
112
|
+
lines.push('', ` Total: ${backups.length} backup(s)`)
|
|
113
|
+
return { kind: 'success', text: lines.join('\n') }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `/backup-restore`: pick a backup and confirm through the user-questions UI. */
|
|
117
|
+
async function runBackupRestoreCommand(
|
|
118
|
+
ctx: Context,
|
|
119
|
+
paths: BackupPaths,
|
|
120
|
+
invocation: CommandInvocation,
|
|
121
|
+
): Promise<CommandResult> {
|
|
122
|
+
if (invocation.rawInput.trim() !== '') {
|
|
123
|
+
return { kind: 'error', text: `The /backup-restore command takes no arguments. ${USAGE_RESTORE}` }
|
|
124
|
+
}
|
|
125
|
+
let backups: BackupEntry[]
|
|
126
|
+
try {
|
|
127
|
+
backups = await listBackups(paths.backupDir)
|
|
128
|
+
} catch (error: unknown) {
|
|
129
|
+
return settleFailure(ctx, 'Restore', error)
|
|
130
|
+
}
|
|
131
|
+
if (backups.length === 0) {
|
|
132
|
+
return {
|
|
133
|
+
kind: 'error',
|
|
134
|
+
text: `No backups found to restore in ${paths.backupDir}.\nCreate one with /backup.`,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const choice = await askRestoreChoice(ctx, paths, backups, invocation)
|
|
138
|
+
if (choice === undefined) return { kind: 'success', text: 'Restore cancelled.' }
|
|
139
|
+
try {
|
|
140
|
+
await restoreBackupArchive(choice.entry.path, paths, choice.mode, invocation.signal)
|
|
141
|
+
} catch (error: unknown) {
|
|
142
|
+
return settleFailure(ctx, 'Restore', error)
|
|
143
|
+
}
|
|
144
|
+
ctx.logger.info(
|
|
145
|
+
`dsh-backup: restored ${choice.entry.path} (${choice.mode}) into ${paths.dshHome}`)
|
|
146
|
+
return {
|
|
147
|
+
kind: 'success',
|
|
148
|
+
text: [
|
|
149
|
+
'Restore completed successfully.',
|
|
150
|
+
`Archive: ${choice.entry.path}`,
|
|
151
|
+
`Target: ${paths.dshHome}`,
|
|
152
|
+
`Mode: ${choice.mode === 'clean'
|
|
153
|
+
? 'clean — DSH_HOME was emptied first, the backup is restored exactly'
|
|
154
|
+
: 'yes — extracted over the current files; files not in the backup are kept'}`,
|
|
155
|
+
'',
|
|
156
|
+
'You may need to restart any running dsh processes.',
|
|
157
|
+
].join('\n'),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The resolved restore choice, or `undefined` when the user cancelled. */
|
|
162
|
+
type RestoreChoice = { readonly entry: BackupEntry; readonly mode: RestoreMode }
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ask which backup to restore and how, mirroring the original tool's
|
|
166
|
+
* selection prompt and yes/clean/no confirmation in one dialog.
|
|
167
|
+
*
|
|
168
|
+
* @returns The resolved choice, or `undefined` for a cancel/decline answer.
|
|
169
|
+
* @throws {BackupError} when the dialog fails or the selection is unusable.
|
|
170
|
+
*/
|
|
171
|
+
async function askRestoreChoice(
|
|
172
|
+
ctx: Context,
|
|
173
|
+
paths: BackupPaths,
|
|
174
|
+
backups: readonly BackupEntry[],
|
|
175
|
+
invocation: CommandInvocation,
|
|
176
|
+
): Promise<RestoreChoice | undefined> {
|
|
177
|
+
const questions: AskUserQuestionItem[] = [
|
|
178
|
+
{
|
|
179
|
+
id: 'backup',
|
|
180
|
+
header: 'Backup',
|
|
181
|
+
question: `Which backup do you want to restore into ${paths.dshHome}?`,
|
|
182
|
+
options: backups.slice(0, RESTORE_CHOICES).map((backup, index) => ({
|
|
183
|
+
label: `#${index + 1} ${backup.file}`,
|
|
184
|
+
description: `${backup.sizeMB} MB, ${backup.date}`,
|
|
185
|
+
})),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: 'confirm',
|
|
189
|
+
header: 'Confirm',
|
|
190
|
+
question: 'Are you sure? This will overwrite the current DSH_HOME directory.',
|
|
191
|
+
detail: [
|
|
192
|
+
`Target: ${paths.dshHome}`,
|
|
193
|
+
'',
|
|
194
|
+
'yes - extract over current files (files not in the backup are kept)',
|
|
195
|
+
'clean - empty DSH_HOME first, then extract (exact restore of the backup)',
|
|
196
|
+
'no - cancel the restore',
|
|
197
|
+
].join('\n'),
|
|
198
|
+
options: [
|
|
199
|
+
{ label: 'yes', description: 'extract over current files' },
|
|
200
|
+
{ label: 'clean', description: 'empty DSH_HOME first, then extract' },
|
|
201
|
+
{ label: 'no', description: 'cancel the restore' },
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
let answer: { readonly answers: readonly AskUserQuestionAnswerItem[] }
|
|
206
|
+
try {
|
|
207
|
+
answer = await ctx.userQuestions.ask({
|
|
208
|
+
questions,
|
|
209
|
+
agent: invocation.agent,
|
|
210
|
+
signal: invocation.signal,
|
|
211
|
+
})
|
|
212
|
+
} catch (error: unknown) {
|
|
213
|
+
if (isCancelledAsk(error)) return undefined
|
|
214
|
+
// No answerer accepted the dialog (e.g. a headless context): say why.
|
|
215
|
+
ctx.logger.error(`dsh-backup: restore dialog failed: ${errorMessage(error)}`)
|
|
216
|
+
throw new BackupError(`Restore dialog failed: ${errorMessage(error)}`)
|
|
217
|
+
}
|
|
218
|
+
const mode = normalizeMode(answer.answers.find(item => item.id === 'confirm')?.selected[0])
|
|
219
|
+
if (mode === undefined) return undefined
|
|
220
|
+
const entry = resolveBackupSelection(backups, answer.answers.find(item => item.id === 'backup'))
|
|
221
|
+
if (entry === undefined) {
|
|
222
|
+
throw new BackupError(`Invalid backup selection. ${USAGE_RESTORE}`)
|
|
223
|
+
}
|
|
224
|
+
return { entry, mode }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Resolve the first question's answer back to a listed archive. */
|
|
228
|
+
function resolveBackupSelection(
|
|
229
|
+
backups: readonly BackupEntry[],
|
|
230
|
+
answer: AskUserQuestionAnswerItem | undefined,
|
|
231
|
+
): BackupEntry | undefined {
|
|
232
|
+
const custom = answer?.custom?.trim() ?? ''
|
|
233
|
+
if (custom !== '') {
|
|
234
|
+
const numeric = /^(\d+)$/u.exec(custom)
|
|
235
|
+
if (numeric !== null) {
|
|
236
|
+
const index = Number(numeric[1]) - 1
|
|
237
|
+
return index >= 0 && index < backups.length ? backups[index] : undefined
|
|
238
|
+
}
|
|
239
|
+
return backups.find(backup => backup.file === custom || backup.path === custom)
|
|
240
|
+
}
|
|
241
|
+
const label = answer?.selected[0]?.trim() ?? ''
|
|
242
|
+
const numbered = /^#\d+\s+(.+)$/u.exec(label)
|
|
243
|
+
const file = numbered?.[1]?.trim() ?? label
|
|
244
|
+
if (file === '') return undefined
|
|
245
|
+
return backups.find(backup => backup.file === file || backup.path === file)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Normalize the confirm answer; anything but yes/clean declines the restore. */
|
|
249
|
+
function normalizeMode(label: string | undefined): RestoreMode | undefined {
|
|
250
|
+
const mode = label?.trim().toLowerCase()
|
|
251
|
+
return mode === 'yes' || mode === 'clean' ? mode : undefined
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Whether the user-questions dialog was cancelled by the user or the signal. */
|
|
255
|
+
function isCancelledAsk(error: unknown): boolean {
|
|
256
|
+
return (error as { code?: unknown } | null | undefined)?.code === 'ASK_ABORTED'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Render one settled failure; cancelled work is a normal outcome, not an error. */
|
|
260
|
+
function settleFailure(ctx: Context, action: string, error: unknown): CommandResult {
|
|
261
|
+
if (error instanceof BackupCancelledError) {
|
|
262
|
+
return { kind: 'success', text: `${action} cancelled.` }
|
|
263
|
+
}
|
|
264
|
+
const message = errorMessage(error)
|
|
265
|
+
ctx.logger.error(`dsh-backup: ${action.toLowerCase()} failed: ${message}`)
|
|
266
|
+
return { kind: 'error', text: `${action} failed: ${message}` }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function errorMessage(error: unknown): string {
|
|
270
|
+
return error instanceof Error ? error.message : String(error)
|
|
271
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-backup: /backup, /backup-list and /backup-restore slash commands that
|
|
3
|
+
* back up, list and restore the DSH home directory; output follows the
|
|
4
|
+
* harness's built-in command conventions and the restore prompts use the
|
|
5
|
+
* user-questions UI.
|
|
6
|
+
*
|
|
7
|
+
* @module @wildusk/dsh-backup
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as os from 'os'
|
|
11
|
+
import * as path from 'path'
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import type { BackupPaths } from './backup-core.ts'
|
|
14
|
+
import { registerBackupCommands } from './commands.ts'
|
|
15
|
+
|
|
16
|
+
export const name = '@wildusk/dsh-backup'
|
|
17
|
+
|
|
18
|
+
export const inject = ['commands', 'userQuestions']
|
|
19
|
+
|
|
20
|
+
export function apply(ctx: Context): void {
|
|
21
|
+
const paths: BackupPaths = {
|
|
22
|
+
dshHome: firstNonEmpty(process.env.DSH_HOME, path.join(os.homedir(), '.dsh')),
|
|
23
|
+
backupDir: path.join(os.homedir(), '.dsh-backup'),
|
|
24
|
+
}
|
|
25
|
+
registerBackupCommands(ctx, paths)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function firstNonEmpty(...values: readonly (string | undefined)[]): string {
|
|
29
|
+
for (const value of values) {
|
|
30
|
+
const trimmed = value?.trim()
|
|
31
|
+
if (trimmed !== undefined && trimmed !== '') return trimmed
|
|
32
|
+
}
|
|
33
|
+
return path.join(os.homedir(), '.dsh')
|
|
34
|
+
}
|