opencode-manager 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.
@@ -0,0 +1,311 @@
1
+ import { constants, promises as fs } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import { homedir } from "node:os"
4
+
5
+ export type ProjectState = "present" | "missing" | "unknown"
6
+
7
+ export interface ProjectRecord {
8
+ index: number
9
+ bucket: ProjectBucket
10
+ filePath: string
11
+ projectId: string
12
+ worktree: string
13
+ vcs: string | null
14
+ createdAt: Date | null
15
+ state: ProjectState
16
+ }
17
+
18
+ export interface SessionRecord {
19
+ index: number
20
+ filePath: string
21
+ sessionId: string
22
+ projectId: string
23
+ directory: string
24
+ title: string
25
+ version: string
26
+ createdAt: Date | null
27
+ updatedAt: Date | null
28
+ }
29
+
30
+ export const DEFAULT_ROOT = join(homedir(), ".local", "share", "opencode")
31
+ const PROJECT_BUCKETS = ["project", "sessions"] as const
32
+ type ProjectBucket = (typeof PROJECT_BUCKETS)[number]
33
+
34
+ export interface LoadOptions {
35
+ root?: string
36
+ }
37
+
38
+ export interface SessionLoadOptions extends LoadOptions {
39
+ projectId?: string
40
+ }
41
+
42
+ export interface DeleteOptions {
43
+ dryRun?: boolean
44
+ }
45
+
46
+ export interface DeleteResult {
47
+ removed: string[]
48
+ failed: { path: string; error?: string }[]
49
+ }
50
+
51
+ const BUCKET_SORT = new Map(PROJECT_BUCKETS.map((bucket, idx) => [bucket, idx]))
52
+
53
+ function expandUserPath(rawPath?: string): string | null {
54
+ if (!rawPath) {
55
+ return null
56
+ }
57
+ if (rawPath === "~") {
58
+ return homedir()
59
+ }
60
+ if (rawPath.startsWith("~/")) {
61
+ return join(homedir(), rawPath.slice(2))
62
+ }
63
+ return resolve(rawPath)
64
+ }
65
+
66
+ function shortenPath(path: string): string {
67
+ if (!path) {
68
+ return path
69
+ }
70
+ const home = homedir()
71
+ return path.startsWith(home) ? path.replace(home, "~") : path
72
+ }
73
+
74
+ async function pathExists(path: string): Promise<boolean> {
75
+ try {
76
+ await fs.access(path, constants.F_OK)
77
+ return true
78
+ } catch (error) {
79
+ return false
80
+ }
81
+ }
82
+
83
+ async function readJsonFile<T>(filePath: string): Promise<T | null> {
84
+ try {
85
+ const raw = await fs.readFile(filePath, "utf8")
86
+ return JSON.parse(raw) as T
87
+ } catch (error) {
88
+ return null
89
+ }
90
+ }
91
+
92
+ function msToDate(ms?: number | null): Date | null {
93
+ if (typeof ms !== "number" || Number.isNaN(ms)) {
94
+ return null
95
+ }
96
+ return new Date(ms)
97
+ }
98
+
99
+ async function computeState(worktree: string | null): Promise<ProjectState> {
100
+ if (!worktree) {
101
+ return "unknown"
102
+ }
103
+ try {
104
+ const stat = await fs.stat(worktree)
105
+ return stat.isDirectory() ? "present" : "missing"
106
+ } catch (error) {
107
+ return "missing"
108
+ }
109
+ }
110
+
111
+ function compareDates(a: Date | null, b: Date | null): number {
112
+ const aTime = a?.getTime() ?? 0
113
+ const bTime = b?.getTime() ?? 0
114
+ return bTime - aTime
115
+ }
116
+
117
+ function withIndex<T extends { index: number }>(records: T[]): T[] {
118
+ return records.map((record, idx) => ({ ...record, index: idx + 1 }))
119
+ }
120
+
121
+ export function formatDisplayPath(path: string, options?: { fullPath?: boolean }): string {
122
+ if (options?.fullPath || !path) {
123
+ return path
124
+ }
125
+ return shortenPath(path)
126
+ }
127
+
128
+ export async function loadProjectRecords(options: LoadOptions = {}): Promise<ProjectRecord[]> {
129
+ const root = resolve(options.root ?? DEFAULT_ROOT)
130
+ const records: ProjectRecord[] = []
131
+
132
+ for (const bucket of PROJECT_BUCKETS) {
133
+ const bucketDir = join(root, "storage", bucket)
134
+ if (!(await pathExists(bucketDir))) {
135
+ continue
136
+ }
137
+ const entries = await fs.readdir(bucketDir)
138
+ for (const entry of entries) {
139
+ if (!entry.endsWith(".json")) {
140
+ continue
141
+ }
142
+ const filePath = join(bucketDir, entry)
143
+ const payload = await readJsonFile<any>(filePath)
144
+ if (!payload) {
145
+ continue
146
+ }
147
+ const createdAt = msToDate(payload?.time?.created)
148
+ const worktree = expandUserPath(payload?.worktree ?? undefined)
149
+ const state = await computeState(worktree)
150
+ records.push({
151
+ index: 0,
152
+ bucket,
153
+ filePath,
154
+ projectId: String(payload?.id ?? entry.replace(/\.json$/i, "")),
155
+ worktree: worktree ?? "",
156
+ vcs: typeof payload?.vcs === "string" ? payload.vcs : null,
157
+ createdAt,
158
+ state,
159
+ })
160
+ }
161
+ }
162
+
163
+ records.sort((a, b) => {
164
+ const dateDelta = compareDates(a.createdAt, b.createdAt)
165
+ if (dateDelta !== 0) {
166
+ return dateDelta
167
+ }
168
+ const bucketDelta = (BUCKET_SORT.get(a.bucket) ?? 0) - (BUCKET_SORT.get(b.bucket) ?? 0)
169
+ if (bucketDelta !== 0) {
170
+ return bucketDelta
171
+ }
172
+ return a.projectId.localeCompare(b.projectId)
173
+ })
174
+
175
+ return withIndex(records)
176
+ }
177
+
178
+ export async function loadSessionRecords(options: SessionLoadOptions = {}): Promise<SessionRecord[]> {
179
+ const root = resolve(options.root ?? DEFAULT_ROOT)
180
+ const sessionRoot = join(root, "storage", "session")
181
+
182
+ if (!(await pathExists(sessionRoot))) {
183
+ return []
184
+ }
185
+
186
+ const projectDirs = await fs.readdir(sessionRoot, { withFileTypes: true })
187
+ const sessions: SessionRecord[] = []
188
+
189
+ for (const dirent of projectDirs) {
190
+ if (!dirent.isDirectory()) {
191
+ continue
192
+ }
193
+ const currentProjectId = dirent.name
194
+ if (options.projectId && options.projectId !== currentProjectId) {
195
+ continue
196
+ }
197
+ const projectDir = join(sessionRoot, dirent.name)
198
+ const files = await fs.readdir(projectDir)
199
+ for (const file of files) {
200
+ if (!file.endsWith(".json")) {
201
+ continue
202
+ }
203
+ const filePath = join(projectDir, file)
204
+ const payload = await readJsonFile<any>(filePath)
205
+ if (!payload) {
206
+ continue
207
+ }
208
+ const createdAt = msToDate(payload?.time?.created)
209
+ const updatedAt = msToDate(payload?.time?.updated)
210
+ const directory = expandUserPath(payload?.directory ?? undefined)
211
+ sessions.push({
212
+ index: 0,
213
+ filePath,
214
+ sessionId: String(payload?.id ?? file.replace(/\.json$/i, "")),
215
+ projectId: String(payload?.projectID ?? currentProjectId),
216
+ directory: directory ?? "",
217
+ title: typeof payload?.title === "string" ? payload.title : "",
218
+ version: typeof payload?.version === "string" ? payload.version : "",
219
+ createdAt,
220
+ updatedAt,
221
+ })
222
+ }
223
+ }
224
+
225
+ sessions.sort((a, b) => {
226
+ const updatedDelta = compareDates(a.updatedAt ?? a.createdAt, b.updatedAt ?? b.createdAt)
227
+ if (updatedDelta !== 0) {
228
+ return updatedDelta
229
+ }
230
+ return a.sessionId.localeCompare(b.sessionId)
231
+ })
232
+
233
+ return withIndex(sessions)
234
+ }
235
+
236
+ export async function deleteProjectMetadata(
237
+ records: ProjectRecord[],
238
+ options: DeleteOptions = {},
239
+ ): Promise<DeleteResult> {
240
+ const removed: string[] = []
241
+ const failed: { path: string; error?: string }[] = []
242
+ for (const record of records) {
243
+ if (options.dryRun) {
244
+ removed.push(record.filePath)
245
+ continue
246
+ }
247
+ try {
248
+ await fs.unlink(record.filePath)
249
+ removed.push(record.filePath)
250
+ } catch (error) {
251
+ failed.push({ path: record.filePath, error: error instanceof Error ? error.message : String(error) })
252
+ }
253
+ }
254
+ return { removed, failed }
255
+ }
256
+
257
+ export async function deleteSessionMetadata(
258
+ records: SessionRecord[],
259
+ options: DeleteOptions = {},
260
+ ): Promise<DeleteResult> {
261
+ const removed: string[] = []
262
+ const failed: { path: string; error?: string }[] = []
263
+ for (const session of records) {
264
+ if (options.dryRun) {
265
+ removed.push(session.filePath)
266
+ continue
267
+ }
268
+ try {
269
+ await fs.unlink(session.filePath)
270
+ removed.push(session.filePath)
271
+ } catch (error) {
272
+ failed.push({ path: session.filePath, error: error instanceof Error ? error.message : String(error) })
273
+ }
274
+ }
275
+ return { removed, failed }
276
+ }
277
+
278
+ export function filterProjectsByState(records: ProjectRecord[], state: ProjectState): ProjectRecord[] {
279
+ return records.filter((record) => record.state === state)
280
+ }
281
+
282
+ export function filterProjectsByIds(records: ProjectRecord[], ids: Set<string>): ProjectRecord[] {
283
+ return records.filter((record) => ids.has(record.projectId))
284
+ }
285
+
286
+ export function filterProjectsByIndexes(records: ProjectRecord[], indexes: Set<number>): ProjectRecord[] {
287
+ return records.filter((record) => indexes.has(record.index))
288
+ }
289
+
290
+ export function filterSessionsByIndexes(records: SessionRecord[], indexes: Set<number>): SessionRecord[] {
291
+ return records.filter((record) => indexes.has(record.index))
292
+ }
293
+
294
+ export function formatDate(date: Date | null): string {
295
+ if (!date) {
296
+ return "?"
297
+ }
298
+ return date.toLocaleString()
299
+ }
300
+
301
+ export function describeProject(record: ProjectRecord, options?: { fullPath?: boolean }): string {
302
+ return `${record.bucket}:${record.projectId} (${formatDisplayPath(record.worktree, options)})`
303
+ }
304
+
305
+ export function describeSession(record: SessionRecord, options?: { fullPath?: boolean }): string {
306
+ return `${record.sessionId} [${record.projectId}] (${record.title || "no title"})`
307
+ }
308
+
309
+ export async function ensureDirectory(path: string): Promise<void> {
310
+ await fs.mkdir(dirname(path), { recursive: true })
311
+ }