Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions sdk/app-builder/src/app/api/recent-apps/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
deleteRecentApp,
updateRecentApp,
} from "@/lib/app-builder/recent-apps"
import { parseRecentAppPatchRequest } from "@/lib/app-builder/recent-apps-api"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

type RecentAppRouteContext = {
params: Promise<{ id: string }>
}

export async function PATCH(
request: Request,
{ params }: RecentAppRouteContext
) {
const { id } = await params
const body = await request.json().catch(() => ({}))
const parsed = parseRecentAppPatchRequest(id, body)

if (!parsed.ok) {
return Response.json(
{ error: parsed.error },
{ status: 400 }
)
}

const app = await updateRecentApp(parsed.value.id, parsed.value.patch)
if (!app) {
return Response.json({ error: "Recent app not found." }, { status: 404 })
}

return Response.json({ app })
}

export async function DELETE(
_request: Request,
{ params }: RecentAppRouteContext
) {
const { id } = await params
const deleted = await deleteRecentApp(id.trim())

return Response.json({ ok: true, deleted })
}
29 changes: 29 additions & 0 deletions sdk/app-builder/src/app/api/recent-apps/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
listRecentAppsSorted,
upsertRecentApp,
} from "@/lib/app-builder/recent-apps"
import { parseRecentAppsRequest } from "@/lib/app-builder/recent-apps-api"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

export async function GET() {
const apps = await listRecentAppsSorted()
return Response.json({ apps })
}

export async function POST(request: Request) {
const body = await request.json().catch(() => ({}))
const parsed = parseRecentAppsRequest(body)

if (!parsed.ok) {
return Response.json(
{ error: parsed.error },
{ status: 400 }
)
}

const app = await upsertRecentApp(parsed.value)

return Response.json({ app })
}
66 changes: 66 additions & 0 deletions sdk/app-builder/src/lib/app-builder/recent-apps-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {
RecentAppPatch,
UpsertRecentAppInput,
} from "./recent-apps-model"

type ParsedRequest<T> =
| { ok: true; value: T }
| { ok: false; error: string }

type RecentAppsRequest = {
id?: unknown
title?: unknown
touch?: unknown
}

type RecentAppPatchRequest = {
favorite?: unknown
title?: unknown
}

export function parseRecentAppsRequest(
body: unknown
): ParsedRequest<UpsertRecentAppInput> {
if (!body || typeof body !== "object") {
return { ok: false, error: "A recent app id is required." }
}

const request = body as RecentAppsRequest
const id = typeof request.id === "string" ? request.id.trim() : ""

if (!id) {
return { ok: false, error: "A recent app id is required." }
}

return {
ok: true,
value: {
id,
title: typeof request.title === "string" ? request.title : undefined,
touch: request.touch === false ? false : undefined,
},
}
}

export function parseRecentAppPatchRequest(
recentAppId: string,
body: unknown
): ParsedRequest<{ id: string; patch: RecentAppPatch }> {
const id = recentAppId.trim()
if (!body || typeof body !== "object") {
return { ok: false, error: "A favorite or title update is required." }
}

const request = body as RecentAppPatchRequest
const patch = {
favorite:
typeof request.favorite === "boolean" ? request.favorite : undefined,
title: typeof request.title === "string" ? request.title : undefined,
}

if (!id || (patch.favorite === undefined && patch.title === undefined)) {
return { ok: false, error: "A favorite or title update is required." }
}

return { ok: true, value: { id, patch } }
}
89 changes: 89 additions & 0 deletions sdk/app-builder/src/lib/app-builder/recent-apps-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
export type RecentApp = {
id: string
title: string
favorite: boolean
createdAt: number
updatedAt: number
}

export type PersistedRecentApps = {
apps: RecentApp[]
}

export type UpsertRecentAppInput = {
id: string
title?: string
touch?: boolean
}

export type RecentAppPatch = {
favorite?: boolean
title?: string
}

export const DEFAULT_RECENT_APP_TITLE = "Untitled App"

export function sanitizeRecentApp(value: unknown): RecentApp | null {
if (!value || typeof value !== "object") {
return null
}

const app = value as Record<string, unknown>
if (
typeof app.id !== "string" ||
typeof app.title !== "string" ||
!isTimestampLike(app.createdAt) ||
!isTimestampLike(app.updatedAt)
) {
return null
}

const id = app.id.trim()
const title = normalizeRecentAppTitle(app.title)

if (!id || !title) {
return null
}

return {
id,
title,
favorite: app.favorite === true,
createdAt: normalizeTimestamp(app.createdAt),
updatedAt: normalizeTimestamp(app.updatedAt),
}
}

export function sortRecentApps(apps: RecentApp[]) {
return [...apps].sort((a, b) => {
if (a.favorite !== b.favorite) {
return a.favorite ? -1 : 1
}

return getTime(b.updatedAt) - getTime(a.updatedAt)
})
}

export function normalizeRecentAppTitle(title: string) {
const normalized = title.replace(/\s+/g, " ").trim()
return normalized || null
}

function isTimestampLike(value: unknown): value is number | string {
return (
(typeof value === "number" && Number.isFinite(value)) ||
(typeof value === "string" && Number.isFinite(new Date(value).getTime()))
)
}

function normalizeTimestamp(value: number | string) {
if (typeof value === "number") {
return value
}

return new Date(value).getTime()
}

function getTime(value: number) {
return Number.isFinite(value) ? value : 0
}
132 changes: 132 additions & 0 deletions sdk/app-builder/src/lib/app-builder/recent-apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { promises as fs } from "node:fs"
import os from "node:os"
import path from "node:path"

import {
DEFAULT_RECENT_APP_TITLE,
normalizeRecentAppTitle,
sanitizeRecentApp,
sortRecentApps,
type PersistedRecentApps,
type RecentApp,
type RecentAppPatch,
type UpsertRecentAppInput,
} from "./recent-apps-model"

const appBuilderRoot = path.join(os.homedir(), ".app-builder")
const recentAppsPath = path.join(appBuilderRoot, "recent-apps.json")

export async function listRecentAppsSorted(): Promise<RecentApp[]> {
const apps = await readPersistedRecentApps()
return sortRecentApps(apps)
}

export async function upsertRecentApp(
input: UpsertRecentAppInput
): Promise<RecentApp> {
const apps = await readPersistedRecentApps()
const now = Date.now()
const existing = apps.find((app) => app.id === input.id)

if (existing) {
if (input.title !== undefined) {
existing.title =
normalizeRecentAppTitle(input.title) ?? DEFAULT_RECENT_APP_TITLE
}

if (input.touch !== false) {
existing.updatedAt = now
}

await writePersistedRecentApps({ apps: sortRecentApps(apps) })
return existing
}

const app: RecentApp = {
id: input.id,
title: input.title
? normalizeRecentAppTitle(input.title) ?? DEFAULT_RECENT_APP_TITLE
: DEFAULT_RECENT_APP_TITLE,
favorite: false,
createdAt: now,
updatedAt: now,
}

apps.push(app)
await writePersistedRecentApps({ apps: sortRecentApps(apps) })
return app
}

export async function updateRecentApp(
id: string,
patch: RecentAppPatch
): Promise<RecentApp | null> {
const apps = await readPersistedRecentApps()
const app = apps.find((item) => item.id === id)

if (!app) {
return null
}

if (patch.favorite !== undefined) {
app.favorite = patch.favorite
}

if (patch.title !== undefined) {
app.title = normalizeRecentAppTitle(patch.title) ?? app.title
}

app.updatedAt = Date.now()
await writePersistedRecentApps({ apps: sortRecentApps(apps) })
return app
}

export async function deleteRecentApp(id: string): Promise<boolean> {
const apps = await readPersistedRecentApps()
const nextApps = apps.filter((app) => app.id !== id)

if (nextApps.length === apps.length) {
return false
}

await writePersistedRecentApps({ apps: sortRecentApps(nextApps) })
return true
}

async function readPersistedRecentApps(): Promise<RecentApp[]> {
try {
const raw = await fs.readFile(recentAppsPath, "utf8")
const parsed = JSON.parse(raw) as Partial<PersistedRecentApps>

if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.apps)) {
return []
}

return parsed.apps.flatMap((app) => {
const sanitized = sanitizeRecentApp(app)
return sanitized ? [sanitized] : []
})
} catch (error) {
if (isNodeFileError(error) && error.code === "ENOENT") {
return []
}

return []
}
}

async function writePersistedRecentApps(recentApps: PersistedRecentApps) {
await fs.mkdir(appBuilderRoot, { recursive: true })
await fs.writeFile(
recentAppsPath,
`${JSON.stringify(recentApps, null, 2)}\n`,
{
mode: 0o600,
}
)
await fs.chmod(recentAppsPath, 0o600).catch(() => {})
}

function isNodeFileError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated utility function and constant from sibling module

Low Severity · Code Quality

The private isNodeFileError function is an exact duplicate of the one in server.ts (line 534) in the same lib/app-builder/ directory. Similarly, the appBuilderRoot constant on line 16 is identical to server.ts line 129. Both files also share the same writeFile-with-chmod pattern. Extracting the shared helper and constant into a small common module would avoid the duplication and keep them in sync.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 666106e. Configure here.