refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
+11
View File
@@ -0,0 +1,11 @@
export {
uploadObject,
deleteObject,
listObjects,
getObjectMetadata,
isR2Configured,
getR2PublicUrl,
isR2Url,
getR2ConfigStatus,
} from "./r2-client"
export type { ObjectInfo, ObjectMetadata } from "./r2-client"
+136
View File
@@ -0,0 +1,136 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand,
} from "@aws-sdk/client-s3"
// ── Configuration ──────────────────────────────────────────────────
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? "deckyvault"
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? ""
let _client: S3Client | null = null
function getR2ConfigStatus(): { configured: boolean; reason?: string } {
if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) {
return { configured: false, reason: "Missing R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, or R2_SECRET_ACCESS_KEY" }
}
return { configured: true }
}
function getClient(): S3Client {
if (_client) return _client
const status = getR2ConfigStatus()
if (!status.configured) {
throw new Error(`R2 not configured: ${status.reason}`)
}
_client = new S3Client({
region: "auto",
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID!,
secretAccessKey: R2_SECRET_ACCESS_KEY!,
},
})
return _client
}
// ── Upload ──────────────────────────────────────────────────────────
export async function uploadObject(
key: string,
body: Buffer | Uint8Array,
contentType: string,
metadata?: Record<string, string>,
): Promise<string> {
const client = getClient()
await client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: body,
ContentType: contentType,
Metadata: metadata,
}),
)
return `${R2_PUBLIC_URL}/${key}`
}
// ── Delete ──────────────────────────────────────────────────────────
export async function deleteObject(key: string): Promise<void> {
const client = getClient()
await client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
}),
)
}
// ── List ────────────────────────────────────────────────────────────
export interface ObjectInfo {
key: string
size: number
lastModified: Date | undefined
}
export async function listObjects(prefix?: string): Promise<ObjectInfo[]> {
const client = getClient()
const result = await client.send(
new ListObjectsV2Command({
Bucket: R2_BUCKET_NAME,
Prefix: prefix,
MaxKeys: 1000,
}),
)
return (result.Contents ?? []).map((obj) => ({
key: obj.Key!,
size: obj.Size ?? 0,
lastModified: obj.LastModified,
}))
}
// ── Metadata ────────────────────────────────────────────────────────
export interface ObjectMetadata {
contentType: string
size: number
lastModified: Date | undefined
}
export async function getObjectMetadata(key: string): Promise<ObjectMetadata | null> {
const client = getClient()
try {
const result = await client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
}),
)
return {
contentType: result.ContentType ?? "application/octet-stream",
size: result.ContentLength ?? 0,
lastModified: result.LastModified,
}
} catch {
return null
}
}
// ── Helpers ─────────────────────────────────────────────────────────
export function isR2Configured(): boolean {
return getR2ConfigStatus().configured
}
export function getR2PublicUrl(): string {
return R2_PUBLIC_URL
}
export function isR2Url(url: string | null): boolean {
if (!url || !R2_PUBLIC_URL) return false
return url.startsWith(R2_PUBLIC_URL)
}
export { getR2ConfigStatus }