oh-my-opencode-dashboard 0.0.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,317 @@
1
+ import * as fs from "node:fs"
2
+ import * as path from "node:path"
3
+ import type { OpenCodeStorageRoots, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session"
4
+ import { getMessageDir } from "./session"
5
+
6
+ export type BackgroundTaskRow = {
7
+ id: string
8
+ description: string
9
+ agent: string
10
+ status: "queued" | "running" | "completed" | "error" | "unknown"
11
+ toolCalls: number | null
12
+ lastTool: string | null
13
+ timeline: string
14
+ sessionId: string | null
15
+ }
16
+
17
+ const DESCRIPTION_MAX = 120
18
+ const AGENT_MAX = 30
19
+
20
+ function clampString(value: unknown, maxLen: number): string | null {
21
+ if (typeof value !== "string") return null
22
+ const s = value.trim()
23
+ if (!s) return null
24
+ return s.length <= maxLen ? s : s.slice(0, maxLen)
25
+ }
26
+
27
+ function readJsonFile<T>(filePath: string): T | null {
28
+ try {
29
+ const content = fs.readFileSync(filePath, "utf8")
30
+ return JSON.parse(content) as T
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ function listJsonFiles(dir: string): string[] {
37
+ try {
38
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".json"))
39
+ } catch {
40
+ return []
41
+ }
42
+ }
43
+
44
+ function readToolPartsForMessage(storage: OpenCodeStorageRoots, messageID: string): StoredToolPart[] {
45
+ const partDir = path.join(storage.part, messageID)
46
+ if (!fs.existsSync(partDir)) return []
47
+
48
+ const files = listJsonFiles(partDir).sort()
49
+ const parts: StoredToolPart[] = []
50
+ for (const f of files) {
51
+ const p = readJsonFile<StoredToolPart>(path.join(partDir, f))
52
+ if (p && p.type === "tool" && typeof p.tool === "string" && p.state && typeof p.state === "object") {
53
+ parts.push(p)
54
+ }
55
+ }
56
+ return parts
57
+ }
58
+
59
+ function readRecentMessageMetas(messageDir: string, maxMessages: number): StoredMessageMeta[] {
60
+ if (!messageDir || !fs.existsSync(messageDir)) return []
61
+ const files = listJsonFiles(messageDir)
62
+ .map((f) => ({
63
+ f,
64
+ mtime: (() => {
65
+ try {
66
+ return fs.statSync(path.join(messageDir, f)).mtimeMs
67
+ } catch {
68
+ return 0
69
+ }
70
+ })(),
71
+ }))
72
+ .sort((a, b) => b.mtime - a.mtime)
73
+ .slice(0, maxMessages)
74
+
75
+ const metas: StoredMessageMeta[] = []
76
+ for (const item of files) {
77
+ const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f))
78
+ if (meta && typeof meta.id === "string") metas.push(meta)
79
+ }
80
+ return metas
81
+ }
82
+
83
+ export function readAllSessionMetas(sessionStorage: string): SessionMetadata[] {
84
+ if (!fs.existsSync(sessionStorage)) return []
85
+ const metas: SessionMetadata[] = []
86
+ try {
87
+ const projectDirs = fs.readdirSync(sessionStorage, { withFileTypes: true })
88
+ for (const d of projectDirs) {
89
+ if (!d.isDirectory()) continue
90
+ const projectPath = path.join(sessionStorage, d.name)
91
+ for (const file of listJsonFiles(projectPath)) {
92
+ const meta = readJsonFile<SessionMetadata>(path.join(projectPath, file))
93
+ if (meta && typeof meta.id === "string") metas.push(meta)
94
+ }
95
+ }
96
+ } catch {
97
+ return []
98
+ }
99
+ return metas
100
+ }
101
+
102
+ function findBackgroundSessionId(opts: {
103
+ allSessionMetas: SessionMetadata[]
104
+ parentSessionId: string
105
+ description: string
106
+ startedAt: number
107
+ }): string | null {
108
+ const title = `Background: ${opts.description}`
109
+ const windowStart = opts.startedAt
110
+ const windowEnd = opts.startedAt + 60_000
111
+
112
+ const candidates = opts.allSessionMetas.filter(
113
+ (m) =>
114
+ m.parentID === opts.parentSessionId &&
115
+ m.title === title &&
116
+ m.time?.created >= windowStart &&
117
+ m.time?.created <= windowEnd
118
+ )
119
+ // Deterministic tie-breaking: max by time.created, then lexicographic id
120
+ candidates.sort((a, b) => {
121
+ const at = a.time?.created ?? 0
122
+ const bt = b.time?.created ?? 0
123
+ if (at !== bt) return bt - at
124
+ return String(a.id).localeCompare(String(b.id))
125
+ })
126
+ return candidates[0]?.id ?? null
127
+ }
128
+
129
+ function findTaskSessionId(opts: {
130
+ allSessionMetas: SessionMetadata[]
131
+ parentSessionId: string
132
+ description: string
133
+ startedAt: number
134
+ }): string | null {
135
+ const title = `Task: ${opts.description}`
136
+ const windowStart = opts.startedAt
137
+ const windowEnd = opts.startedAt + 60_000
138
+
139
+ const candidates = opts.allSessionMetas.filter(
140
+ (m) =>
141
+ m.parentID === opts.parentSessionId &&
142
+ m.title === title &&
143
+ m.time?.created >= windowStart &&
144
+ m.time?.created <= windowEnd
145
+ )
146
+ candidates.sort((a, b) => {
147
+ const at = a.time?.created ?? 0
148
+ const bt = b.time?.created ?? 0
149
+ if (at !== bt) return bt - at
150
+ return String(a.id).localeCompare(String(b.id))
151
+ })
152
+ return candidates[0]?.id ?? null
153
+ }
154
+
155
+ function deriveBackgroundSessionStats(storage: OpenCodeStorageRoots, sessionId: string): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null } {
156
+ const messageDir = getMessageDir(storage.message, sessionId)
157
+ const metas = readRecentMessageMetas(messageDir, 200)
158
+ let toolCalls = 0
159
+ let lastTool: string | null = null
160
+ let lastUpdateAt: number | null = null
161
+
162
+ // Deterministic ordering by time.created then id.
163
+ const ordered = [...metas].sort((a, b) => {
164
+ const at = a.time?.created ?? 0
165
+ const bt = b.time?.created ?? 0
166
+ if (at !== bt) return at - bt
167
+ return String(a.id).localeCompare(String(b.id))
168
+ })
169
+
170
+ for (const meta of ordered) {
171
+ const created = meta.time?.created
172
+ if (typeof created === "number") lastUpdateAt = created
173
+ const parts = readToolPartsForMessage(storage, meta.id)
174
+ for (const p of parts) {
175
+ toolCalls += 1
176
+ lastTool = p.tool
177
+ }
178
+ }
179
+
180
+ return { toolCalls, lastTool, lastUpdateAt }
181
+ }
182
+
183
+ function formatIsoNoMs(ts: number): string {
184
+ const iso = new Date(ts).toISOString()
185
+ return iso.replace(/\.\d{3}Z$/, "Z")
186
+ }
187
+
188
+ function formatElapsed(ms: number): string {
189
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000))
190
+ const seconds = totalSeconds % 60
191
+ const totalMinutes = Math.floor(totalSeconds / 60)
192
+ const minutes = totalMinutes % 60
193
+ const totalHours = Math.floor(totalMinutes / 60)
194
+ const hours = totalHours % 24
195
+ const days = Math.floor(totalHours / 24)
196
+
197
+ if (days > 0) return hours > 0 ? `${days}d${hours}h` : `${days}d`
198
+ if (totalHours > 0) return minutes > 0 ? `${totalHours}h${minutes}m` : `${totalHours}h`
199
+ if (totalMinutes > 0) return seconds > 0 ? `${totalMinutes}m${seconds}s` : `${totalMinutes}m`
200
+ return `${seconds}s`
201
+ }
202
+
203
+ function formatTimeline(startAt: number | null, endAtMs: number): string {
204
+ if (typeof startAt !== "number") return ""
205
+ const start = formatIsoNoMs(startAt)
206
+ const elapsed = formatElapsed(endAtMs - startAt)
207
+ return `${start}: ${elapsed}`
208
+ }
209
+
210
+ export function deriveBackgroundTasks(opts: {
211
+ storage: OpenCodeStorageRoots
212
+ mainSessionId: string
213
+ nowMs?: number
214
+ }): BackgroundTaskRow[] {
215
+ const nowMs = opts.nowMs ?? Date.now()
216
+ const messageDir = getMessageDir(opts.storage.message, opts.mainSessionId)
217
+ const metas = readRecentMessageMetas(messageDir, 200)
218
+ const allSessionMetas = readAllSessionMetas(opts.storage.session)
219
+
220
+ const rows: BackgroundTaskRow[] = []
221
+
222
+ // Iterate newest-first to cap list and keep latest tasks.
223
+ const ordered = [...metas].sort((a, b) => (b.time?.created ?? 0) - (a.time?.created ?? 0))
224
+ for (const meta of ordered) {
225
+ const startedAt = meta.time?.created ?? null
226
+ if (typeof startedAt !== "number") continue
227
+
228
+ const parts = readToolPartsForMessage(opts.storage, meta.id)
229
+ for (const part of parts) {
230
+ if (part.tool !== "delegate_task") continue
231
+ if (!part.state || typeof part.state !== "object") continue
232
+
233
+ const input = part.state.input ?? {}
234
+ if (typeof input !== "object" || input === null) continue
235
+
236
+ const runInBackground = (input as Record<string, unknown>).run_in_background
237
+ if (runInBackground !== true && runInBackground !== false) continue
238
+
239
+ const description = clampString((input as Record<string, unknown>).description, DESCRIPTION_MAX)
240
+ if (!description) continue
241
+
242
+ const subagentType = clampString((input as Record<string, unknown>).subagent_type, AGENT_MAX)
243
+ const category = clampString((input as Record<string, unknown>).category, AGENT_MAX)
244
+ const agent = subagentType ?? (category ? `sisyphus-junior (${category})` : "unknown")
245
+
246
+ let backgroundSessionId: string | null = null
247
+
248
+ if (runInBackground) {
249
+ backgroundSessionId = findBackgroundSessionId({
250
+ allSessionMetas,
251
+ parentSessionId: opts.mainSessionId,
252
+ description,
253
+ startedAt,
254
+ })
255
+ } else {
256
+ // For sync tasks, check if resume is specified
257
+ const resume = (input as Record<string, unknown>).resume
258
+ if (typeof resume === "string" && resume.trim() !== "") {
259
+ // Check if resumed session exists (has readable messages dir)
260
+ const resumeMessageDir = getMessageDir(opts.storage.message, resume.trim())
261
+ if (fs.existsSync(resumeMessageDir) && fs.readdirSync(resumeMessageDir).length > 0) {
262
+ backgroundSessionId = resume.trim()
263
+ }
264
+ }
265
+
266
+ if (!backgroundSessionId) {
267
+ backgroundSessionId = findBackgroundSessionId({
268
+ allSessionMetas,
269
+ parentSessionId: opts.mainSessionId,
270
+ description,
271
+ startedAt,
272
+ })
273
+
274
+ if (!backgroundSessionId) {
275
+ backgroundSessionId = findTaskSessionId({
276
+ allSessionMetas,
277
+ parentSessionId: opts.mainSessionId,
278
+ description,
279
+ startedAt,
280
+ })
281
+ }
282
+ }
283
+ }
284
+
285
+ const stats = backgroundSessionId
286
+ ? deriveBackgroundSessionStats(opts.storage, backgroundSessionId)
287
+ : { toolCalls: 0, lastTool: null, lastUpdateAt: startedAt }
288
+
289
+ // Best-effort status: if background session exists and has any tool calls, treat as running unless idle.
290
+ let status: BackgroundTaskRow["status"] = "unknown"
291
+ if (!backgroundSessionId) {
292
+ status = "queued"
293
+ } else if (stats.lastUpdateAt && nowMs - stats.lastUpdateAt <= 15_000) {
294
+ status = "running"
295
+ } else if (stats.toolCalls > 0) {
296
+ status = "completed"
297
+ }
298
+
299
+ const timelineEndMs = status === "completed" ? (stats.lastUpdateAt ?? nowMs) : nowMs
300
+
301
+ rows.push({
302
+ id: part.callID,
303
+ description,
304
+ agent,
305
+ status,
306
+ toolCalls: backgroundSessionId ? stats.toolCalls : null,
307
+ lastTool: stats.lastTool,
308
+ timeline: formatTimeline(startedAt, timelineEndMs),
309
+ sessionId: backgroundSessionId,
310
+ })
311
+ }
312
+
313
+ if (rows.length >= 50) break
314
+ }
315
+
316
+ return rows
317
+ }
@@ -0,0 +1,77 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+ import { readBoulderState, readPlanProgress } from "./boulder"
6
+
7
+ function mkProjectRoot(): string {
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
9
+ fs.mkdirSync(path.join(root, ".sisyphus"), { recursive: true })
10
+ return root
11
+ }
12
+
13
+ describe("readBoulderState", () => {
14
+ it("returns null if boulder.json is missing", () => {
15
+ const projectRoot = mkProjectRoot()
16
+ expect(readBoulderState(projectRoot)).toBe(null)
17
+ })
18
+
19
+ it("returns null if boulder.json is invalid JSON", () => {
20
+ const projectRoot = mkProjectRoot()
21
+ fs.writeFileSync(path.join(projectRoot, ".sisyphus", "boulder.json"), "{nope", "utf8")
22
+ expect(readBoulderState(projectRoot)).toBe(null)
23
+ })
24
+ })
25
+
26
+ describe("readPlanProgress", () => {
27
+ it("computes checkbox progress for an existing plan", () => {
28
+ const projectRoot = mkProjectRoot()
29
+ const planPath = path.join(projectRoot, ".sisyphus", "plans", "plan.md")
30
+ fs.mkdirSync(path.dirname(planPath), { recursive: true })
31
+ fs.writeFileSync(
32
+ planPath,
33
+ ["- [ ] Task 1", "- [x] Task 2", "- [X] Task 3"].join("\n"),
34
+ "utf8"
35
+ )
36
+
37
+ const progress = readPlanProgress(projectRoot, planPath)
38
+ expect(progress.missing).toBe(false)
39
+ expect(progress.total).toBe(3)
40
+ expect(progress.completed).toBe(2)
41
+ expect(progress.isComplete).toBe(false)
42
+ })
43
+
44
+ it("treats missing plan file as not started", () => {
45
+ const projectRoot = mkProjectRoot()
46
+ const planPath = path.join(projectRoot, ".sisyphus", "plans", "missing.md")
47
+ const progress = readPlanProgress(projectRoot, planPath)
48
+ expect(progress.missing).toBe(true)
49
+ expect(progress.total).toBe(0)
50
+ expect(progress.completed).toBe(0)
51
+ expect(progress.isComplete).toBe(false)
52
+ })
53
+
54
+ it("treats a plan with zero checkboxes as complete", () => {
55
+ const projectRoot = mkProjectRoot()
56
+ const planPath = path.join(projectRoot, ".sisyphus", "plans", "empty.md")
57
+ fs.mkdirSync(path.dirname(planPath), { recursive: true })
58
+ fs.writeFileSync(planPath, "# No tasks\nJust text", "utf8")
59
+
60
+ const progress = readPlanProgress(projectRoot, planPath)
61
+ expect(progress.missing).toBe(false)
62
+ expect(progress.total).toBe(0)
63
+ expect(progress.completed).toBe(0)
64
+ expect(progress.isComplete).toBe(true)
65
+ })
66
+
67
+ it("rejects active_plan paths outside projectRoot", () => {
68
+ const projectRoot = mkProjectRoot()
69
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), "omo-outside-"))
70
+ const outsidePlan = path.join(outside, "plan.md")
71
+ fs.writeFileSync(outsidePlan, "- [x] outside", "utf8")
72
+
73
+ const progress = readPlanProgress(projectRoot, outsidePlan)
74
+ expect(progress.missing).toBe(true)
75
+ expect(progress.isComplete).toBe(false)
76
+ })
77
+ })
@@ -0,0 +1,71 @@
1
+ import * as fs from "node:fs"
2
+ import * as path from "node:path"
3
+ import { assertAllowedPath } from "./paths"
4
+
5
+ export type BoulderState = {
6
+ active_plan: string
7
+ started_at: string
8
+ session_ids: string[]
9
+ plan_name: string
10
+ }
11
+
12
+ export type PlanProgress = {
13
+ total: number
14
+ completed: number
15
+ isComplete: boolean
16
+ missing: boolean
17
+ }
18
+
19
+ export function readBoulderState(projectRoot: string): BoulderState | null {
20
+ const filePath = assertAllowedPath({
21
+ candidatePath: path.join(projectRoot, ".sisyphus", "boulder.json"),
22
+ allowedRoots: [projectRoot],
23
+ })
24
+
25
+ if (!fs.existsSync(filePath)) return null
26
+
27
+ try {
28
+ const content = fs.readFileSync(filePath, "utf8")
29
+ return JSON.parse(content) as BoulderState
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ export function getPlanProgressFromMarkdown(content: string): Omit<PlanProgress, "missing"> {
36
+ const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
37
+ const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
38
+
39
+ const total = uncheckedMatches.length + checkedMatches.length
40
+ const completed = checkedMatches.length
41
+
42
+ return {
43
+ total,
44
+ completed,
45
+ isComplete: total === 0 || completed === total,
46
+ }
47
+ }
48
+
49
+ export function readPlanProgress(projectRoot: string, planPath: string): PlanProgress {
50
+ let planReal: string
51
+ try {
52
+ planReal = assertAllowedPath({
53
+ candidatePath: planPath,
54
+ allowedRoots: [projectRoot],
55
+ })
56
+ } catch {
57
+ return { total: 0, completed: 0, isComplete: false, missing: true }
58
+ }
59
+
60
+ if (!fs.existsSync(planReal)) {
61
+ return { total: 0, completed: 0, isComplete: false, missing: true }
62
+ }
63
+
64
+ try {
65
+ const content = fs.readFileSync(planReal, "utf8")
66
+ const progress = getPlanProgressFromMarkdown(content)
67
+ return { ...progress, missing: false }
68
+ } catch {
69
+ return { total: 0, completed: 0, isComplete: false, missing: true }
70
+ }
71
+ }
@@ -0,0 +1,82 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+ import { assertAllowedPath, getOpenCodeStorageDir } from "./paths"
6
+
7
+ describe("getOpenCodeStorageDir", () => {
8
+ it("uses XDG_DATA_HOME when set", () => {
9
+ const got = getOpenCodeStorageDir({ XDG_DATA_HOME: "/tmp/xdg" }, "/home/test")
10
+ expect(got).toBe("/tmp/xdg/opencode/storage")
11
+ })
12
+
13
+ it("falls back to ~/.local/share when XDG_DATA_HOME is unset", () => {
14
+ const got = getOpenCodeStorageDir({}, "/home/test")
15
+ expect(got).toBe("/home/test/.local/share/opencode/storage")
16
+ })
17
+ })
18
+
19
+ describe("assertAllowedPath", () => {
20
+ it("allows paths inside allowed roots", () => {
21
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-"))
22
+ const allowed = path.join(base, "allowed")
23
+ fs.mkdirSync(allowed, { recursive: true })
24
+
25
+ const resolved = assertAllowedPath({
26
+ candidatePath: path.join(allowed, "file.txt"),
27
+ allowedRoots: [allowed],
28
+ })
29
+
30
+ expect(resolved).toContain("/allowed/")
31
+ })
32
+
33
+ it("rejects traversal outside allowed root", () => {
34
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-"))
35
+ const allowed = path.join(base, "allowed")
36
+ fs.mkdirSync(allowed, { recursive: true })
37
+ const outside = path.join(base, "outside.txt")
38
+
39
+ expect(() =>
40
+ assertAllowedPath({
41
+ candidatePath: outside,
42
+ allowedRoots: [allowed],
43
+ })
44
+ ).toThrow("Access denied")
45
+ })
46
+
47
+ it("rejects symlink escapes", () => {
48
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-"))
49
+ const allowed = path.join(base, "allowed")
50
+ const outsideDir = path.join(base, "outside")
51
+ fs.mkdirSync(allowed, { recursive: true })
52
+ fs.mkdirSync(outsideDir, { recursive: true })
53
+
54
+ const outsideFile = path.join(outsideDir, "secret.txt")
55
+ fs.writeFileSync(outsideFile, "nope", "utf8")
56
+
57
+ const linkPath = path.join(allowed, "link.txt")
58
+ fs.symlinkSync(outsideFile, linkPath)
59
+
60
+ expect(() =>
61
+ assertAllowedPath({
62
+ candidatePath: linkPath,
63
+ allowedRoots: [allowed],
64
+ })
65
+ ).toThrow("Access denied")
66
+ })
67
+
68
+ it("allows a non-existent file path if its nearest existing parent is inside", () => {
69
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-"))
70
+ const allowed = path.join(base, "allowed")
71
+ fs.mkdirSync(allowed, { recursive: true })
72
+
73
+ const candidate = path.join(allowed, "missing", "file.txt")
74
+ const resolved = assertAllowedPath({
75
+ candidatePath: candidate,
76
+ allowedRoots: [allowed],
77
+ })
78
+
79
+ expect(resolved).toContain("/allowed/")
80
+ expect(resolved.endsWith(path.join("missing", "file.txt"))).toBe(true)
81
+ })
82
+ })
@@ -0,0 +1,76 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+
5
+ export type Env = Record<string, string | undefined>
6
+
7
+ export function getDataDir(env: Env = process.env, homedir: string = os.homedir()): string {
8
+ // Match oh-my-opencode behavior exactly:
9
+ // XDG_DATA_HOME or ~/.local/share on all platforms.
10
+ return env.XDG_DATA_HOME ?? path.join(homedir, ".local", "share")
11
+ }
12
+
13
+ export function getOpenCodeStorageDir(env: Env = process.env, homedir: string = os.homedir()): string {
14
+ return path.join(getDataDir(env, homedir), "opencode", "storage")
15
+ }
16
+
17
+ export function realpathSafe(p: string): string | null {
18
+ try {
19
+ return fs.realpathSync(p)
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ export function isPathInside(rootReal: string, candidateReal: string): boolean {
26
+ const rel = path.relative(rootReal, candidateReal)
27
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
28
+ }
29
+
30
+ function resolveCandidateReal(candidateAbs: string): string | null {
31
+ const existing = realpathSafe(candidateAbs)
32
+ if (existing) return existing
33
+
34
+ // If the target doesn't exist, resolve the nearest existing parent and
35
+ // re-append the relative suffix.
36
+ let cur = candidateAbs
37
+ let prev = ""
38
+ while (cur !== prev) {
39
+ if (fs.existsSync(cur)) {
40
+ const parentReal = realpathSafe(cur)
41
+ if (!parentReal) return null
42
+ const suffix = path.relative(cur, candidateAbs)
43
+ return suffix ? path.join(parentReal, suffix) : parentReal
44
+ }
45
+ prev = cur
46
+ cur = path.dirname(cur)
47
+ }
48
+
49
+ return null
50
+ }
51
+
52
+ export type AssertAllowedPathOptions = {
53
+ candidatePath: string
54
+ allowedRoots: string[]
55
+ baseDir?: string
56
+ }
57
+
58
+ export function assertAllowedPath(opts: AssertAllowedPathOptions): string {
59
+ const baseDir = opts.baseDir ?? process.cwd()
60
+ const candidateAbs = path.resolve(baseDir, opts.candidatePath)
61
+
62
+ const candidateReal = resolveCandidateReal(candidateAbs)
63
+ if (!candidateReal) {
64
+ throw new Error("Access denied")
65
+ }
66
+
67
+ for (const root of opts.allowedRoots) {
68
+ const rootAbs = path.resolve(baseDir, root)
69
+ const rootReal = resolveCandidateReal(rootAbs) ?? rootAbs
70
+ if (isPathInside(rootReal, candidateReal)) {
71
+ return candidateReal
72
+ }
73
+ }
74
+
75
+ throw new Error("Access denied")
76
+ }