opencode-manager 0.3.1 → 0.4.1

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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Projects CLI subcommands.
3
+ *
4
+ * Provides commands for listing and deleting OpenCode projects.
5
+ */
6
+
7
+ import { Command, type OptionValues } from "commander"
8
+ import { parseGlobalOptions, type GlobalOptions } from "../index"
9
+ import {
10
+ filterProjectsByState,
11
+ type ProjectRecord,
12
+ } from "../../lib/opencode-data"
13
+ import { createProviderFromGlobalOptions } from "../../lib/opencode-data-provider"
14
+ import {
15
+ getOutputOptions,
16
+ printProjectsOutput,
17
+ printDryRunOutput,
18
+ createDryRunResult,
19
+ printSuccessOutput,
20
+ } from "../output"
21
+ import { tokenizedSearch } from "../../lib/search"
22
+ import { resolveProjectId } from "../resolvers"
23
+ import { requireConfirmation, withErrorHandling, FileOperationError } from "../errors"
24
+ import { copyToBackupDir, formatBackupResult } from "../backup"
25
+
26
+ /**
27
+ * Collect all options from a command and its ancestors.
28
+ * Commander stores global options on the root program, not on subcommands.
29
+ */
30
+ function collectOptions(cmd: Command): OptionValues {
31
+ const opts: OptionValues = {}
32
+ let current: Command | null = cmd
33
+ while (current) {
34
+ Object.assign(opts, current.opts())
35
+ current = current.parent
36
+ }
37
+ return opts
38
+ }
39
+
40
+ /**
41
+ * Options specific to the projects list command.
42
+ */
43
+ export interface ProjectsListOptions {
44
+ /** Only show projects with missing directories */
45
+ missingOnly: boolean
46
+ /** Search query to filter projects */
47
+ search?: string
48
+ }
49
+
50
+ /**
51
+ * Options specific to the projects delete command.
52
+ */
53
+ export interface ProjectsDeleteOptions {
54
+ /** Project ID to delete */
55
+ id: string
56
+ /** Skip confirmation prompt */
57
+ yes: boolean
58
+ /** Preview changes without deleting */
59
+ dryRun: boolean
60
+ /** Directory to backup files before deletion */
61
+ backupDir?: string
62
+ }
63
+
64
+ /**
65
+ * Register projects subcommands on the given parent command.
66
+ */
67
+ export function registerProjectsCommands(parent: Command): void {
68
+ const projects = parent
69
+ .command("projects")
70
+ .description("Manage OpenCode projects")
71
+
72
+ projects
73
+ .command("list")
74
+ .description("List projects")
75
+ .option("--missing-only", "Only show projects with missing directories", false)
76
+ .option("-s, --search <query>", "Search query to filter projects")
77
+ .action(function (this: Command) {
78
+ const globalOpts = parseGlobalOptions(collectOptions(this))
79
+ const cmdOpts = this.opts()
80
+ const listOpts: ProjectsListOptions = {
81
+ missingOnly: Boolean(cmdOpts.missingOnly),
82
+ search: cmdOpts.search as string | undefined,
83
+ }
84
+ handleProjectsList(globalOpts, listOpts)
85
+ })
86
+
87
+ projects
88
+ .command("delete")
89
+ .description("Delete a project's metadata file")
90
+ .requiredOption("--id <projectId>", "Project ID to delete")
91
+ .option("--yes", "Skip confirmation prompt", false)
92
+ .option("--dry-run", "Preview changes without deleting", false)
93
+ .option("--backup-dir <dir>", "Directory to backup files before deletion")
94
+ .action(async function (this: Command) {
95
+ const allOpts = collectOptions(this)
96
+ const globalOpts = parseGlobalOptions(allOpts)
97
+ const cmdOpts = this.opts()
98
+ const deleteOpts: ProjectsDeleteOptions = {
99
+ id: String(cmdOpts.id),
100
+ yes: Boolean(allOpts.yes ?? cmdOpts.yes),
101
+ dryRun: Boolean(allOpts.dryRun ?? cmdOpts.dryRun),
102
+ backupDir: (allOpts.backupDir ?? cmdOpts.backupDir) as string | undefined,
103
+ }
104
+ await withErrorHandling(handleProjectsDelete, getOutputOptions(globalOpts).format)(
105
+ globalOpts,
106
+ deleteOpts
107
+ )
108
+ })
109
+
110
+ projects.addHelpText(
111
+ "after",
112
+ [
113
+ "",
114
+ "Examples:",
115
+ " opencode-manager projects list --experimental-sqlite",
116
+ " opencode-manager projects list --db ~/.local/share/opencode/opencode.db",
117
+ ].join("\n")
118
+ )
119
+ }
120
+
121
+ /**
122
+ * Handle the projects list command.
123
+ */
124
+ async function handleProjectsList(
125
+ globalOpts: GlobalOptions,
126
+ listOpts: ProjectsListOptions
127
+ ): Promise<void> {
128
+ // Create data provider based on global options (JSONL or SQLite backend)
129
+ const provider = createProviderFromGlobalOptions(globalOpts)
130
+
131
+ // Load project records from the data layer
132
+ let projects = await provider.loadProjectRecords()
133
+
134
+ // Apply missing-only filter if requested
135
+ if (listOpts.missingOnly) {
136
+ projects = filterProjectsByState(projects, "missing")
137
+ }
138
+
139
+ // Apply tokenized search if query provided (matches TUI semantics)
140
+ if (listOpts.search) {
141
+ projects = tokenizedSearch(
142
+ projects,
143
+ listOpts.search,
144
+ (p) => [p.projectId, p.worktree],
145
+ { limit: globalOpts.limit }
146
+ )
147
+ } else {
148
+ // Apply limit cap even without search (default 200)
149
+ projects = projects.slice(0, globalOpts.limit)
150
+ }
151
+
152
+ // Output the projects using the appropriate formatter
153
+ const outputOpts = getOutputOptions(globalOpts)
154
+ printProjectsOutput(projects, outputOpts)
155
+ }
156
+
157
+ /**
158
+ * Handle the projects delete command.
159
+ *
160
+ * This command deletes a project's metadata file from the OpenCode storage.
161
+ * It does NOT delete the actual project directory on disk.
162
+ *
163
+ * Exit codes:
164
+ * - 0: Success (or dry-run completed)
165
+ * - 2: Usage error (--yes not provided for destructive operation)
166
+ * - 3: Project not found
167
+ * - 4: File operation failure (backup or delete failed)
168
+ */
169
+ async function handleProjectsDelete(
170
+ globalOpts: GlobalOptions,
171
+ deleteOpts: ProjectsDeleteOptions
172
+ ): Promise<void> {
173
+ const outputOpts = getOutputOptions(globalOpts)
174
+
175
+ // Create data provider based on global options (JSONL or SQLite backend)
176
+ const provider = createProviderFromGlobalOptions(globalOpts)
177
+
178
+ // Resolve project ID to a project record (use provider for backend-agnostic resolution)
179
+ const { project } = await resolveProjectId(deleteOpts.id, {
180
+ root: globalOpts.root,
181
+ allowPrefix: true,
182
+ provider,
183
+ })
184
+
185
+ const pathsToDelete = [project.filePath]
186
+
187
+ // Handle dry-run mode
188
+ if (deleteOpts.dryRun) {
189
+ const dryRunResult = createDryRunResult(pathsToDelete, "delete", "project")
190
+ printDryRunOutput(dryRunResult, outputOpts.format)
191
+ return
192
+ }
193
+
194
+ // Require confirmation for destructive operation
195
+ requireConfirmation(deleteOpts.yes, "Project deletion")
196
+
197
+ // Backup files if requested (only applies to JSONL backend - SQLite has no files to backup)
198
+ if (deleteOpts.backupDir && provider.backend === "jsonl") {
199
+ const backupResult = await copyToBackupDir(pathsToDelete, {
200
+ backupDir: deleteOpts.backupDir,
201
+ prefix: "project",
202
+ preserveStructure: true,
203
+ structureRoot: globalOpts.root,
204
+ })
205
+
206
+ if (backupResult.failed.length > 0) {
207
+ throw new FileOperationError(
208
+ `Backup failed for ${backupResult.failed.length} file(s): ${backupResult.failed
209
+ .map((f) => f.path)
210
+ .join(", ")}`,
211
+ "backup"
212
+ )
213
+ }
214
+
215
+ if (!globalOpts.quiet) {
216
+ console.log(formatBackupResult(backupResult))
217
+ }
218
+ }
219
+
220
+ // Perform the deletion using the provider (handles both JSONL and SQLite)
221
+ const deleteResult = await provider.deleteProjectMetadata([project], { dryRun: false })
222
+
223
+ if (deleteResult.failed.length > 0) {
224
+ throw new FileOperationError(
225
+ `Failed to delete ${deleteResult.failed.length} file(s): ${deleteResult.failed
226
+ .map((f: { path: string; error?: string }) => `${f.path}: ${f.error || "unknown error"}`)
227
+ .join(", ")}`,
228
+ "delete"
229
+ )
230
+ }
231
+
232
+ // Output success
233
+ printSuccessOutput(
234
+ `Deleted project: ${project.projectId}`,
235
+ { projectId: project.projectId, deleted: deleteResult.removed },
236
+ outputOpts.format
237
+ )
238
+ }