opencode-manager 0.3.1 → 0.4.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/PROJECT-SUMMARY.md +104 -24
- package/README.md +335 -7
- package/bun.lock +17 -1
- package/manage_opencode_projects.py +71 -66
- package/package.json +6 -3
- package/src/bin/opencode-manager.ts +133 -3
- package/src/cli/backup.ts +324 -0
- package/src/cli/commands/chat.ts +322 -0
- package/src/cli/commands/projects.ts +222 -0
- package/src/cli/commands/sessions.ts +495 -0
- package/src/cli/commands/tokens.ts +168 -0
- package/src/cli/commands/tui.ts +36 -0
- package/src/cli/errors.ts +259 -0
- package/src/cli/formatters/json.ts +184 -0
- package/src/cli/formatters/ndjson.ts +71 -0
- package/src/cli/formatters/table.ts +837 -0
- package/src/cli/index.ts +169 -0
- package/src/cli/output.ts +661 -0
- package/src/cli/resolvers.ts +249 -0
- package/src/lib/clipboard.ts +37 -0
- package/src/lib/opencode-data.ts +380 -1
- package/src/lib/search.ts +170 -0
- package/src/{opencode-tui.tsx → tui/app.tsx} +739 -105
- package/src/tui/args.ts +92 -0
- package/src/tui/index.tsx +46 -0
|
@@ -1,93 +1,98 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Launch the OpenCode metadata TUI
|
|
2
|
+
"""Launch the OpenCode metadata manager (TUI or CLI).
|
|
3
3
|
|
|
4
|
-
This wrapper keeps the previous entry point name but
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
This wrapper keeps the previous entry point name but shells out to the
|
|
5
|
+
Bun-powered entrypoint at ``src/bin/opencode-manager.ts``. The routing logic
|
|
6
|
+
detects CLI subcommands (projects, sessions, chat, tokens) and passes them
|
|
7
|
+
directly, otherwise defaults to the TUI.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
manage_opencode_projects.py # Launch TUI (default)
|
|
11
|
+
manage_opencode_projects.py projects list # CLI: list projects
|
|
12
|
+
manage_opencode_projects.py sessions list # CLI: list sessions
|
|
13
|
+
manage_opencode_projects.py -- --help # Show TUI help
|
|
7
14
|
"""
|
|
8
15
|
|
|
9
16
|
from __future__ import annotations
|
|
10
17
|
|
|
11
|
-
import argparse
|
|
12
18
|
import shutil
|
|
13
19
|
import subprocess
|
|
14
20
|
import sys
|
|
15
21
|
from pathlib import Path
|
|
16
22
|
from typing import Sequence
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
# CLI subcommands that route to the CLI module instead of TUI
|
|
25
|
+
CLI_SUBCOMMANDS = frozenset({"projects", "sessions", "chat", "tokens"})
|
|
26
|
+
|
|
19
27
|
PROJECT_DIR = Path(__file__).resolve().parent
|
|
20
28
|
|
|
21
29
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"Examples:\n"
|
|
27
|
-
" manage_opencode_projects.py\n"
|
|
28
|
-
" Launch the TUI using the default metadata root.\n\n"
|
|
29
|
-
" manage_opencode_projects.py --root /tmp/opencode\n"
|
|
30
|
-
" Launch the TUI against a different storage directory.\n\n"
|
|
31
|
-
" manage_opencode_projects.py -- --help\n"
|
|
32
|
-
" Show the TUI's built-in CLI help output.\n"
|
|
33
|
-
),
|
|
34
|
-
)
|
|
35
|
-
parser.add_argument(
|
|
36
|
-
"--root",
|
|
37
|
-
type=Path,
|
|
38
|
-
default=DEFAULT_ROOT,
|
|
39
|
-
help="Metadata root to inspect (defaults to ~/.local/share/opencode)",
|
|
40
|
-
)
|
|
41
|
-
parser.add_argument(
|
|
42
|
-
"--bun",
|
|
43
|
-
type=Path,
|
|
44
|
-
default=None,
|
|
45
|
-
help="Optional path to the bun executable if it's not on PATH",
|
|
46
|
-
)
|
|
47
|
-
parser.add_argument(
|
|
48
|
-
"tui_args",
|
|
49
|
-
nargs=argparse.REMAINDER,
|
|
50
|
-
help=(
|
|
51
|
-
"Additional arguments forwarded to the TUI after '--'. For example: "
|
|
52
|
-
"manage_opencode_projects.py -- --help"
|
|
53
|
-
),
|
|
54
|
-
)
|
|
55
|
-
return parser.parse_args(argv)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def find_bun(explicit: Path | None) -> str:
|
|
59
|
-
if explicit:
|
|
60
|
-
return str(explicit)
|
|
30
|
+
def find_bun(explicit_path: str | None = None) -> str:
|
|
31
|
+
"""Locate bun executable, preferring explicit path if provided."""
|
|
32
|
+
if explicit_path:
|
|
33
|
+
return explicit_path
|
|
61
34
|
bun_path = shutil.which("bun")
|
|
62
35
|
if bun_path:
|
|
63
36
|
return bun_path
|
|
64
|
-
raise SystemExit("bun executable not found. Please install Bun
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
str(root.expanduser()),
|
|
78
|
-
]
|
|
79
|
-
if extra_args:
|
|
80
|
-
cmd.extend(extra_args)
|
|
37
|
+
raise SystemExit("bun executable not found. Please install Bun.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_cli_subcommand(args: Sequence[str]) -> bool:
|
|
41
|
+
"""Check if the first non-flag argument is a CLI subcommand."""
|
|
42
|
+
for arg in args:
|
|
43
|
+
if arg.startswith("-"):
|
|
44
|
+
# Skip flags like --bun, --root, etc.
|
|
45
|
+
continue
|
|
46
|
+
# First positional argument determines routing
|
|
47
|
+
return arg in CLI_SUBCOMMANDS
|
|
48
|
+
return False
|
|
49
|
+
|
|
81
50
|
|
|
51
|
+
def run_entrypoint(bun_exe: str, args: Sequence[str]) -> int:
|
|
52
|
+
"""Run the main entrypoint with given arguments.
|
|
53
|
+
|
|
54
|
+
The TypeScript entrypoint handles all routing internally:
|
|
55
|
+
- CLI subcommands (projects, sessions, chat, tokens) → CLI module
|
|
56
|
+
- Everything else → TUI
|
|
57
|
+
"""
|
|
58
|
+
# Normalize: drop leading "--" if present (legacy passthrough syntax)
|
|
59
|
+
args_list = list(args)
|
|
60
|
+
if args_list and args_list[0] == "--":
|
|
61
|
+
args_list = args_list[1:]
|
|
62
|
+
|
|
63
|
+
cmd = [bun_exe, "src/bin/opencode-manager.ts"] + args_list
|
|
82
64
|
process = subprocess.run(cmd, cwd=PROJECT_DIR)
|
|
83
65
|
return process.returncode
|
|
84
66
|
|
|
85
67
|
|
|
86
68
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
69
|
+
"""Main entry point.
|
|
70
|
+
|
|
71
|
+
Parses minimal wrapper-level options (--bun) and forwards everything
|
|
72
|
+
else to the TypeScript entrypoint which handles TUI/CLI routing.
|
|
73
|
+
"""
|
|
74
|
+
if argv is None:
|
|
75
|
+
argv = sys.argv[1:]
|
|
76
|
+
|
|
77
|
+
args = list(argv)
|
|
78
|
+
bun_exe_path: str | None = None
|
|
79
|
+
|
|
80
|
+
# Extract --bun option if present (wrapper-level option only)
|
|
81
|
+
filtered_args: list[str] = []
|
|
82
|
+
i = 0
|
|
83
|
+
while i < len(args):
|
|
84
|
+
if args[i] == "--bun" and i + 1 < len(args):
|
|
85
|
+
bun_exe_path = args[i + 1]
|
|
86
|
+
i += 2
|
|
87
|
+
elif args[i].startswith("--bun="):
|
|
88
|
+
bun_exe_path = args[i].split("=", 1)[1]
|
|
89
|
+
i += 1
|
|
90
|
+
else:
|
|
91
|
+
filtered_args.append(args[i])
|
|
92
|
+
i += 1
|
|
93
|
+
|
|
94
|
+
bun_exe = find_bun(bun_exe_path)
|
|
95
|
+
return run_entrypoint(bun_exe, filtered_args)
|
|
91
96
|
|
|
92
97
|
|
|
93
98
|
if __name__ == "__main__":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Terminal UI for inspecting OpenCode metadata stores.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
"LICENSE"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"tui": "bun src/
|
|
24
|
-
"dev": "bun --watch src/
|
|
23
|
+
"tui": "bun src/tui/index.tsx",
|
|
24
|
+
"dev": "bun --watch src/tui/index.tsx",
|
|
25
|
+
"test": "bun test",
|
|
25
26
|
"typecheck": "bunx tsc --noEmit",
|
|
26
27
|
"prepublishOnly": "bun run typecheck"
|
|
27
28
|
},
|
|
@@ -45,6 +46,8 @@
|
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@opentui/core": "^0.1.44",
|
|
47
48
|
"@opentui/react": "^0.1.44",
|
|
49
|
+
"commander": "^12.0.0",
|
|
50
|
+
"fast-fuzzy": "^1.12.0",
|
|
48
51
|
"react": "^19.0.0"
|
|
49
52
|
},
|
|
50
53
|
"devDependencies": {
|
|
@@ -1,4 +1,134 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Main CLI entrypoint for opencode-manager.
|
|
4
|
+
*
|
|
5
|
+
* Routes between TUI and CLI modes based on provided subcommands:
|
|
6
|
+
* - No subcommand → shows help
|
|
7
|
+
* - "tui" subcommand → launches TUI
|
|
8
|
+
* - CLI subcommands (projects, sessions, chat, tokens) → launches CLI
|
|
9
|
+
*
|
|
10
|
+
* Uses dynamic imports to keep initial load fast and avoid loading
|
|
11
|
+
* unused modules.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Known CLI subcommands that should route to the CLI module
|
|
15
|
+
const CLI_SUBCOMMANDS = new Set([
|
|
16
|
+
"projects",
|
|
17
|
+
"sessions",
|
|
18
|
+
"chat",
|
|
19
|
+
"tokens",
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
// Subcommands that explicitly request TUI
|
|
23
|
+
const TUI_SUBCOMMANDS = new Set(["tui"])
|
|
24
|
+
|
|
25
|
+
// Version from package.json
|
|
26
|
+
const VERSION = "0.4.0"
|
|
27
|
+
|
|
28
|
+
function printHelp(): void {
|
|
29
|
+
console.log(`opencode-manager v${VERSION}
|
|
30
|
+
|
|
31
|
+
Inspect and manage OpenCode metadata stores via TUI or CLI.
|
|
32
|
+
|
|
33
|
+
USAGE:
|
|
34
|
+
opencode-manager [command] [options]
|
|
35
|
+
|
|
36
|
+
MODES:
|
|
37
|
+
tui Launch interactive TUI (terminal user interface)
|
|
38
|
+
<command> Run CLI command (see below)
|
|
39
|
+
|
|
40
|
+
CLI COMMANDS:
|
|
41
|
+
projects list List all projects
|
|
42
|
+
projects delete Delete a project's metadata
|
|
43
|
+
|
|
44
|
+
sessions list List sessions (optionally filter by project)
|
|
45
|
+
sessions delete Delete a session's metadata
|
|
46
|
+
sessions rename Rename a session
|
|
47
|
+
sessions move Move a session to another project
|
|
48
|
+
sessions copy Copy a session to another project
|
|
49
|
+
|
|
50
|
+
chat list List messages in a session
|
|
51
|
+
chat show Show a specific message
|
|
52
|
+
chat search Search chat content across sessions
|
|
53
|
+
|
|
54
|
+
tokens session Show token usage for a session
|
|
55
|
+
tokens project Show token usage for a project
|
|
56
|
+
tokens global Show global token usage
|
|
57
|
+
|
|
58
|
+
OPTIONS:
|
|
59
|
+
--help, -h Show this help
|
|
60
|
+
--version, -v Show version
|
|
61
|
+
|
|
62
|
+
EXAMPLES:
|
|
63
|
+
opencode-manager tui # Launch TUI
|
|
64
|
+
opencode-manager projects list --format json # List projects as JSON
|
|
65
|
+
opencode-manager sessions list --project X # List sessions for project
|
|
66
|
+
opencode-manager chat search --query "error" # Search chat content
|
|
67
|
+
|
|
68
|
+
For detailed help on any command:
|
|
69
|
+
opencode-manager <command> --help
|
|
70
|
+
opencode-manager <command> <subcommand> --help
|
|
71
|
+
`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printVersion(): void {
|
|
75
|
+
console.log(VERSION)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function main(): Promise<void> {
|
|
79
|
+
const args = process.argv.slice(2)
|
|
80
|
+
const firstArg = args[0]
|
|
81
|
+
|
|
82
|
+
// Handle no args, --help, or -h → show help
|
|
83
|
+
if (!firstArg || firstArg === "--help" || firstArg === "-h") {
|
|
84
|
+
printHelp()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Handle --version or -v
|
|
89
|
+
if (firstArg === "--version" || firstArg === "-v") {
|
|
90
|
+
printVersion()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Determine routing based on first argument
|
|
95
|
+
const isCliSubcommand = CLI_SUBCOMMANDS.has(firstArg)
|
|
96
|
+
const isTuiSubcommand = TUI_SUBCOMMANDS.has(firstArg)
|
|
97
|
+
|
|
98
|
+
if (isCliSubcommand) {
|
|
99
|
+
// Route to CLI module (dynamically imported)
|
|
100
|
+
// Using string path to avoid TypeScript errors before CLI module exists
|
|
101
|
+
const cliModulePath = "../cli/index"
|
|
102
|
+
try {
|
|
103
|
+
const cliModule = await import(cliModulePath)
|
|
104
|
+
if (typeof cliModule.runCLI !== "function") {
|
|
105
|
+
throw new Error("CLI module missing runCLI export")
|
|
106
|
+
}
|
|
107
|
+
await cliModule.runCLI(args)
|
|
108
|
+
} catch (error) {
|
|
109
|
+
const errCode = (error as NodeJS.ErrnoException).code
|
|
110
|
+
const errMessage = (error as Error).message
|
|
111
|
+
if (errCode === "ERR_MODULE_NOT_FOUND" || errMessage.includes("Cannot find module")) {
|
|
112
|
+
console.error(`CLI module not yet implemented. Subcommand: ${firstArg}`)
|
|
113
|
+
console.error("Run without subcommand to launch TUI, or use --help for usage.")
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
} else if (isTuiSubcommand) {
|
|
119
|
+
// Explicit TUI request - strip "tui" subcommand so TUI args parser doesn't see it
|
|
120
|
+
const tuiArgs = args.slice(1)
|
|
121
|
+
const { bootstrap } = await import("../tui/index")
|
|
122
|
+
await bootstrap(tuiArgs)
|
|
123
|
+
} else {
|
|
124
|
+
// Unknown command
|
|
125
|
+
console.error(`Unknown command: ${firstArg}\n`)
|
|
126
|
+
printHelp()
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
main().catch((error) => {
|
|
132
|
+
console.error(error)
|
|
133
|
+
process.exit(1)
|
|
134
|
+
})
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI backup utilities module.
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for backing up files and directories before
|
|
5
|
+
* destructive operations like delete.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { promises as fs } from "node:fs"
|
|
9
|
+
import { basename, dirname, join, relative, resolve } from "node:path"
|
|
10
|
+
import { FileOperationError } from "./errors"
|
|
11
|
+
|
|
12
|
+
// ========================
|
|
13
|
+
// Types
|
|
14
|
+
// ========================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Options for backup operations.
|
|
18
|
+
*/
|
|
19
|
+
export interface BackupOptions {
|
|
20
|
+
/** Base directory for backups. Files are copied here with structure preserved. */
|
|
21
|
+
backupDir: string
|
|
22
|
+
/** Optional prefix for backup directory name (defaults to timestamp). */
|
|
23
|
+
prefix?: string
|
|
24
|
+
/** Whether to preserve the original directory structure relative to a root. */
|
|
25
|
+
preserveStructure?: boolean
|
|
26
|
+
/** Root directory for preserving structure (paths are relative to this). */
|
|
27
|
+
structureRoot?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Result of a backup operation.
|
|
32
|
+
*/
|
|
33
|
+
export interface BackupResult {
|
|
34
|
+
/** Source paths that were backed up. */
|
|
35
|
+
sources: string[]
|
|
36
|
+
/** Destination paths where backups were created. */
|
|
37
|
+
destinations: string[]
|
|
38
|
+
/** The backup directory used (may include timestamp subdirectory). */
|
|
39
|
+
backupDir: string
|
|
40
|
+
/** Any paths that failed to backup. */
|
|
41
|
+
failed: Array<{ path: string; error: string }>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ========================
|
|
45
|
+
// Helpers
|
|
46
|
+
// ========================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate a timestamp string for backup directory names.
|
|
50
|
+
*
|
|
51
|
+
* @returns ISO-like timestamp without colons (filesystem safe)
|
|
52
|
+
*/
|
|
53
|
+
export function generateBackupTimestamp(): string {
|
|
54
|
+
const now = new Date()
|
|
55
|
+
// Format: YYYY-MM-DD_HH-MM-SS
|
|
56
|
+
const year = now.getFullYear()
|
|
57
|
+
const month = String(now.getMonth() + 1).padStart(2, "0")
|
|
58
|
+
const day = String(now.getDate()).padStart(2, "0")
|
|
59
|
+
const hours = String(now.getHours()).padStart(2, "0")
|
|
60
|
+
const minutes = String(now.getMinutes()).padStart(2, "0")
|
|
61
|
+
const seconds = String(now.getSeconds()).padStart(2, "0")
|
|
62
|
+
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ensure a directory exists, creating it if necessary.
|
|
67
|
+
*
|
|
68
|
+
* @param dir - Directory path to ensure
|
|
69
|
+
*/
|
|
70
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
71
|
+
await fs.mkdir(dir, { recursive: true })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a path exists.
|
|
76
|
+
*
|
|
77
|
+
* @param path - Path to check
|
|
78
|
+
* @returns true if exists, false otherwise
|
|
79
|
+
*/
|
|
80
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
await fs.access(path)
|
|
83
|
+
return true
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if a path is a directory.
|
|
91
|
+
*
|
|
92
|
+
* @param path - Path to check
|
|
93
|
+
* @returns true if directory, false otherwise
|
|
94
|
+
*/
|
|
95
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
96
|
+
try {
|
|
97
|
+
const stat = await fs.stat(path)
|
|
98
|
+
return stat.isDirectory()
|
|
99
|
+
} catch {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Recursively copy a directory.
|
|
106
|
+
*
|
|
107
|
+
* @param src - Source directory
|
|
108
|
+
* @param dest - Destination directory
|
|
109
|
+
*/
|
|
110
|
+
async function copyDir(src: string, dest: string): Promise<void> {
|
|
111
|
+
await ensureDir(dest)
|
|
112
|
+
const entries = await fs.readdir(src, { withFileTypes: true })
|
|
113
|
+
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const srcPath = join(src, entry.name)
|
|
116
|
+
const destPath = join(dest, entry.name)
|
|
117
|
+
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
await copyDir(srcPath, destPath)
|
|
120
|
+
} else {
|
|
121
|
+
await fs.copyFile(srcPath, destPath)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Copy a file or directory to a destination.
|
|
128
|
+
*
|
|
129
|
+
* @param src - Source path (file or directory)
|
|
130
|
+
* @param dest - Destination path
|
|
131
|
+
*/
|
|
132
|
+
async function copyPath(src: string, dest: string): Promise<void> {
|
|
133
|
+
if (await isDirectory(src)) {
|
|
134
|
+
await copyDir(src, dest)
|
|
135
|
+
} else {
|
|
136
|
+
// Ensure parent directory exists
|
|
137
|
+
await ensureDir(dirname(dest))
|
|
138
|
+
await fs.copyFile(src, dest)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ========================
|
|
143
|
+
// Main Backup Functions
|
|
144
|
+
// ========================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Copy files to a backup directory before deletion.
|
|
148
|
+
*
|
|
149
|
+
* Creates a timestamped subdirectory within backupDir to store the backups.
|
|
150
|
+
* Preserves directory structure relative to structureRoot if specified.
|
|
151
|
+
*
|
|
152
|
+
* @param paths - Array of file/directory paths to backup
|
|
153
|
+
* @param options - Backup options
|
|
154
|
+
* @returns BackupResult with details of the operation
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```ts
|
|
158
|
+
* // Simple backup
|
|
159
|
+
* const result = await copyToBackupDir(
|
|
160
|
+
* ["/path/to/project.json", "/path/to/session/data"],
|
|
161
|
+
* { backupDir: "/backups" }
|
|
162
|
+
* )
|
|
163
|
+
* // Files are copied to /backups/2024-01-15_12-30-45/...
|
|
164
|
+
*
|
|
165
|
+
* // Preserve structure
|
|
166
|
+
* const result = await copyToBackupDir(
|
|
167
|
+
* ["/data/storage/project/abc.json"],
|
|
168
|
+
* {
|
|
169
|
+
* backupDir: "/backups",
|
|
170
|
+
* preserveStructure: true,
|
|
171
|
+
* structureRoot: "/data"
|
|
172
|
+
* }
|
|
173
|
+
* )
|
|
174
|
+
* // File is copied to /backups/2024-01-15_12-30-45/storage/project/abc.json
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export async function copyToBackupDir(
|
|
178
|
+
paths: string[],
|
|
179
|
+
options: BackupOptions
|
|
180
|
+
): Promise<BackupResult> {
|
|
181
|
+
const { backupDir, prefix, preserveStructure, structureRoot } = options
|
|
182
|
+
|
|
183
|
+
// Validate backup directory
|
|
184
|
+
const resolvedBackupDir = resolve(backupDir)
|
|
185
|
+
|
|
186
|
+
// Create timestamped subdirectory
|
|
187
|
+
const timestamp = generateBackupTimestamp()
|
|
188
|
+
const backupSubdir = prefix
|
|
189
|
+
? `${prefix}_${timestamp}`
|
|
190
|
+
: timestamp
|
|
191
|
+
const targetBackupDir = join(resolvedBackupDir, backupSubdir)
|
|
192
|
+
|
|
193
|
+
const result: BackupResult = {
|
|
194
|
+
sources: [],
|
|
195
|
+
destinations: [],
|
|
196
|
+
backupDir: targetBackupDir,
|
|
197
|
+
failed: [],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// If no paths, return early
|
|
201
|
+
if (paths.length === 0) {
|
|
202
|
+
return result
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Ensure backup directory exists
|
|
206
|
+
try {
|
|
207
|
+
await ensureDir(targetBackupDir)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new FileOperationError(
|
|
210
|
+
`Failed to create backup directory: ${targetBackupDir}`,
|
|
211
|
+
"backup"
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Copy each path
|
|
216
|
+
for (const srcPath of paths) {
|
|
217
|
+
const resolvedSrc = resolve(srcPath)
|
|
218
|
+
|
|
219
|
+
// Check if source exists
|
|
220
|
+
if (!(await pathExists(resolvedSrc))) {
|
|
221
|
+
result.failed.push({
|
|
222
|
+
path: resolvedSrc,
|
|
223
|
+
error: "Source path does not exist",
|
|
224
|
+
})
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Determine destination path
|
|
229
|
+
let destPath: string
|
|
230
|
+
if (preserveStructure && structureRoot) {
|
|
231
|
+
// Preserve directory structure relative to root
|
|
232
|
+
const relativePath = relative(resolve(structureRoot), resolvedSrc)
|
|
233
|
+
if (relativePath.startsWith("..")) {
|
|
234
|
+
// Path is outside structureRoot, use basename
|
|
235
|
+
destPath = join(targetBackupDir, basename(resolvedSrc))
|
|
236
|
+
} else {
|
|
237
|
+
destPath = join(targetBackupDir, relativePath)
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
// Just use the basename
|
|
241
|
+
destPath = join(targetBackupDir, basename(resolvedSrc))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Copy the file/directory
|
|
245
|
+
try {
|
|
246
|
+
await copyPath(resolvedSrc, destPath)
|
|
247
|
+
result.sources.push(resolvedSrc)
|
|
248
|
+
result.destinations.push(destPath)
|
|
249
|
+
} catch (error) {
|
|
250
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
251
|
+
result.failed.push({
|
|
252
|
+
path: resolvedSrc,
|
|
253
|
+
error: errorMessage,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return result
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get the paths that would be backed up (for dry-run display).
|
|
263
|
+
*
|
|
264
|
+
* @param paths - Source paths to backup
|
|
265
|
+
* @param options - Backup options
|
|
266
|
+
* @returns Object with source and computed destination paths
|
|
267
|
+
*/
|
|
268
|
+
export function previewBackupPaths(
|
|
269
|
+
paths: string[],
|
|
270
|
+
options: BackupOptions
|
|
271
|
+
): { sources: string[]; destinations: string[]; backupDir: string } {
|
|
272
|
+
const { backupDir, prefix, preserveStructure, structureRoot } = options
|
|
273
|
+
|
|
274
|
+
const resolvedBackupDir = resolve(backupDir)
|
|
275
|
+
const timestamp = generateBackupTimestamp()
|
|
276
|
+
const backupSubdir = prefix ? `${prefix}_${timestamp}` : timestamp
|
|
277
|
+
const targetBackupDir = join(resolvedBackupDir, backupSubdir)
|
|
278
|
+
|
|
279
|
+
const sources: string[] = []
|
|
280
|
+
const destinations: string[] = []
|
|
281
|
+
|
|
282
|
+
for (const srcPath of paths) {
|
|
283
|
+
const resolvedSrc = resolve(srcPath)
|
|
284
|
+
sources.push(resolvedSrc)
|
|
285
|
+
|
|
286
|
+
let destPath: string
|
|
287
|
+
if (preserveStructure && structureRoot) {
|
|
288
|
+
const relativePath = relative(resolve(structureRoot), resolvedSrc)
|
|
289
|
+
if (relativePath.startsWith("..")) {
|
|
290
|
+
destPath = join(targetBackupDir, basename(resolvedSrc))
|
|
291
|
+
} else {
|
|
292
|
+
destPath = join(targetBackupDir, relativePath)
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
destPath = join(targetBackupDir, basename(resolvedSrc))
|
|
296
|
+
}
|
|
297
|
+
destinations.push(destPath)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { sources, destinations, backupDir: targetBackupDir }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Format backup result for display.
|
|
305
|
+
*
|
|
306
|
+
* @param result - Backup result to format
|
|
307
|
+
* @returns Human-readable summary string
|
|
308
|
+
*/
|
|
309
|
+
export function formatBackupResult(result: BackupResult): string {
|
|
310
|
+
const lines: string[] = []
|
|
311
|
+
|
|
312
|
+
if (result.sources.length > 0) {
|
|
313
|
+
lines.push(`Backed up ${result.sources.length} item(s) to: ${result.backupDir}`)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (result.failed.length > 0) {
|
|
317
|
+
lines.push(`Failed to backup ${result.failed.length} item(s):`)
|
|
318
|
+
for (const { path, error } of result.failed) {
|
|
319
|
+
lines.push(` ${path}: ${error}`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return lines.join("\n")
|
|
324
|
+
}
|