diff --git a/apps/lifeforge--movies/client/components/MovieGrid.tsx b/apps/lifeforge--movies/client/components/MovieGrid.tsx new file mode 100644 index 000000000..732d0b3cf --- /dev/null +++ b/apps/lifeforge--movies/client/components/MovieGrid.tsx @@ -0,0 +1,14 @@ +import type { MovieEntry } from '..' +import MovieItem from './MovieItem' + +function MovieGrid({ data }: { data: MovieEntry[] }) { + return ( + + ) +} + +export default MovieGrid diff --git a/apps/lifeforge--movies/client/components/MovieItem.tsx b/apps/lifeforge--movies/client/components/MovieItem.tsx new file mode 100644 index 000000000..2e1e44656 --- /dev/null +++ b/apps/lifeforge--movies/client/components/MovieItem.tsx @@ -0,0 +1,285 @@ +import forgeAPI from '@/utils/forgeAPI' +import { Icon } from '@iconify/react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import clsx from 'clsx' +import dayjs from 'dayjs' +import duration from 'dayjs/plugin/duration' +import relativeTime from 'dayjs/plugin/relativeTime' +import { + Button, + ConfirmationModal, + ContextMenu, + ContextMenuItem, + Card +} from 'lifeforge-ui' +import { useModalStore } from 'lifeforge-ui' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'react-toastify' +import type { InferOutput } from 'shared' +import { usePersonalization, usePromiseLoading } from 'shared' + +import ModifyTicketModal from '../modals/ModifyTicketModal' +import ShowTicketModal from '../modals/ShowTicketModal' + +dayjs.extend(duration) +dayjs.extend(relativeTime) + +function MovieItem({ + data, + type +}: { + data: InferOutput['entries'][number] + type: 'grid' | 'list' +}) { + const queryClient = useQueryClient() + + const { t } = useTranslation('apps.movies') + + const { language } = usePersonalization() + + const open = useModalStore(state => state.open) + + const toggleWatchedMutation = useMutation( + forgeAPI.movies.entries.toggleWatchStatus + .input({ + id: data.id + }) + .mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['movies', 'entries'] + }) + }, + onError: () => { + toast.error('Failed to mark movie as watched.') + } + }) + ) + + const [toggleWatchedLoading, handleToggleWatched] = usePromiseLoading(() => + toggleWatchedMutation.mutateAsync({}) + ) + + const updateMovieDataMutation = useMutation( + forgeAPI.movies.entries.update.input({ id: data.id }).mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['movies', 'entries'] + }) + + toast.success('Movie data updated successfully.') + }, + onError: () => { + toast.error('Failed to update movie data.') + } + }) + ) + + const [updateMovieDataLoading, handleUpdateMovieData] = usePromiseLoading( + () => updateMovieDataMutation.mutateAsync({}) + ) + + const handleShowTicket = useCallback(() => { + open(ShowTicketModal, { + entry: data + }) + }, [data, open]) + + const handleUpdateTicket = useCallback(() => { + open(ModifyTicketModal, { + initialData: data, + type: data.ticket_number ? 'update' : 'create' + }) + }, [data]) + + const deleteMutation = useMutation( + forgeAPI.movies.entries.remove.input({ id: data.id }).mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['movies'] }) + }, + onError: () => { + toast.error('Failed to delete movie entry') + } + }) + ) + + const handleDeleteTicket = useCallback(() => { + open(ConfirmationModal, { + title: 'Delete Movie', + description: 'Are you sure you want to delete this movie?', + confirmationButton: 'delete', + onConfirm: async () => { + await deleteMutation.mutateAsync({}) + } + }) + }, [data]) + + return ( + +
+ + +
+
+

+ {dayjs(data.release_date).year()} +

+

+ {data.title} + + ({data.original_title}) + +

+

{data.overview}

+
+
+
+
+ + Genres +
+
{data.genres.join(', ')}
+
+
+
+ + Release Date +
+
+ {data.release_date + ? dayjs(data.release_date).format('DD MMM YYYY') + : 'TBA'} +
+
+
+
+ + Duration +
+
+ {dayjs + .duration(data.duration, 'minutes') + .format('H [h] mm [m]')} +
+
+
+
+ + Language +
+
{data.language}
+
+
+
+ + Countries +
+
+ {data.countries.map((country: string, index: number) => ( +
+ + {country} +
+ ))} +
+
+
+
+
+ {!data.is_watched && ( + + )} + {data.ticket_number && ( + + )} + {data.is_watched && ( +
+ + {t('misc.watched', { + date: dayjs(data.watch_date).locale(language).fromNow() + })} +
+ )} +
+
+ + {data.is_watched && ( + + )} + + + + +
+ ) +} + +export default MovieItem diff --git a/apps/lifeforge--movies/client/components/MovieList.tsx b/apps/lifeforge--movies/client/components/MovieList.tsx new file mode 100644 index 000000000..2204d0b54 --- /dev/null +++ b/apps/lifeforge--movies/client/components/MovieList.tsx @@ -0,0 +1,14 @@ +import type { MovieEntry } from '..' +import MovieItem from './MovieItem' + +function MovieList({ data }: { data: MovieEntry[] }) { + return ( + + ) +} + +export default MovieList diff --git a/apps/lifeforge--movies/client/components/TMDBLogo.svg b/apps/lifeforge--movies/client/components/TMDBLogo.svg new file mode 100644 index 000000000..42f31f154 --- /dev/null +++ b/apps/lifeforge--movies/client/components/TMDBLogo.svg @@ -0,0 +1 @@ +Asset 2 \ No newline at end of file diff --git a/apps/lifeforge--movies/client/index.tsx b/apps/lifeforge--movies/client/index.tsx new file mode 100644 index 000000000..cd326ea42 --- /dev/null +++ b/apps/lifeforge--movies/client/index.tsx @@ -0,0 +1,177 @@ +import forgeAPI from '@/utils/forgeAPI' +import { useQuery } from '@tanstack/react-query' +import { + Button, + EmptyStateScreen, + FAB, + ModuleHeader, + Scrollbar, + SearchInput, + Tabs, + ViewModeSelector, + WithQuery +} from 'lifeforge-ui' +import { useModalStore } from 'lifeforge-ui' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSearchParams } from 'shared' +import type { InferOutput } from 'shared' + +import MovieGrid from './components/MovieGrid' +import MovieList from './components/MovieList' +import SearchTMDBModal from './modals/SearchTMDBModal' +import ShowTicketModal from './modals/ShowTicketModal' + +export type MovieEntry = InferOutput< + typeof forgeAPI.movies.entries.list +>['entries'][number] + +function Movies() { + const open = useModalStore(state => state.open) + + const { t } = useTranslation('apps.movies') + + const [searchParams, setSearchParams] = useSearchParams() + + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + + const [searchQuery, setSearchQuery] = useState('') + + const [currentTab, setCurrentTab] = useState<'unwatched' | 'watched'>( + 'unwatched' + ) + + const entriesQuery = useQuery( + forgeAPI.movies.entries.list + .input({ + watched: currentTab === 'watched' ? 'true' : 'false' + }) + .queryOptions() + ) + + useEffect(() => { + if (!entriesQuery.data) return + + if (searchParams.get('show-ticket')) { + const target = entriesQuery.data.entries.find( + entry => entry.id === searchParams.get('show-ticket') + ) + + if (!target) return + + open(ShowTicketModal, { + entry: target + }) + setSearchParams({}, { replace: true }) + } + }, [searchParams, setSearchParams, entriesQuery.data]) + + const handleOpenTMDBModal = useCallback(() => { + open(SearchTMDBModal, {}) + }, [entriesQuery.data]) + + return ( + <> + + new + + } + /> +
+ + +
+ + {data => { + const FinalComponent = viewMode === 'grid' ? MovieGrid : MovieList + + return ( +
+ + {data.entries.length === 0 ? ( + + ) : ( + + { + const matchesSearch = entry.title + .toLowerCase() + .includes(searchQuery.toLowerCase()) + + const matchesTab = + currentTab === 'unwatched' + ? !entry.is_watched + : entry.is_watched + + return matchesSearch && matchesTab + })} + /> + + )} +
+ ) + }} +
+ + + ) +} + +export default Movies diff --git a/apps/lifeforge--movies/client/modals/ModifyTicketModal.tsx b/apps/lifeforge--movies/client/modals/ModifyTicketModal.tsx new file mode 100644 index 000000000..629d2a4c9 --- /dev/null +++ b/apps/lifeforge--movies/client/modals/ModifyTicketModal.tsx @@ -0,0 +1,138 @@ +import forgeAPI from '@/utils/forgeAPI' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { ConfirmationModal, FormModal, defineForm } from 'lifeforge-ui' +import { useModalStore } from 'lifeforge-ui' +import { toast } from 'react-toastify' +import type { InferInput } from 'shared' + +import type { MovieEntry } from '..' + +function ModifyTicketModal({ + data: { type, initialData }, + onClose +}: { + data: { + type: 'create' | 'update' + initialData: MovieEntry + } + onClose: () => void +}) { + const queryClient = useQueryClient() + + const open = useModalStore(state => state.open) + + const modifyTicketMutation = useMutation( + forgeAPI.movies.ticket.update + .input({ + id: initialData.id + }) + .mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['movies', 'entries'] + }) + } + }) + ) + + const deleteMutation = useMutation( + forgeAPI.movies.ticket.clear + .input({ + id: initialData.id + }) + .mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['movies', 'entries'] + }) + toast.success('Ticket deleted successfully!') + onClose() + } + }) + ) + + const handleDeleteTicket = () => + open(ConfirmationModal, { + title: 'Delete Ticket', + description: 'Are you sure you want to delete this ticket?', + confirmationButton: 'delete', + onConfirm: async () => { + await deleteMutation.mutateAsync({}) + } + }) + + const { formProps } = defineForm< + InferInput['body'] + >({ + icon: type === 'create' ? 'tabler:plus' : 'tabler:pencil', + title: `ticket.${type}`, + namespace: 'apps.movies', + onClose, + submitButton: type, + actionButton: { + dangerous: true, + icon: 'tabler:trash', + onClick: handleDeleteTicket + } + }) + .typesMap({ + ticket_number: 'text', + theatre_seat: 'text', + theatre_location: 'location', + theatre_showtime: 'datetime', + theatre_number: 'text' + }) + .setupFields({ + ticket_number: { + required: true, + label: 'Ticket number', + icon: 'tabler:ticket', + placeholder: '123456789', + qrScanner: true + }, + theatre_seat: { + label: 'Theatre seat', + icon: 'mdi:love-seat-outline', + placeholder: 'A1' + }, + theatre_location: { + label: 'Theatre location', + type: 'location' + }, + theatre_showtime: { + label: 'Theatre showtime', + icon: 'tabler:clock', + type: 'datetime', + hasTime: true + }, + theatre_number: { + label: 'Theatre number', + icon: 'tabler:hash', + placeholder: '1' + } + }) + .initialData({ + ticket_number: initialData.ticket_number || '', + theatre_location: { + name: initialData.theatre_location || '', + location: { + latitude: initialData.theatre_location_coords?.lat || 0, + longitude: initialData.theatre_location_coords?.lon || 0 + }, + formattedAddress: initialData.theatre_location || '' + }, + theatre_number: initialData.theatre_number || '', + theatre_seat: initialData.theatre_seat || '', + theatre_showtime: initialData.theatre_showtime + ? new Date(initialData.theatre_showtime) + : undefined + }) + .onSubmit(async data => { + await modifyTicketMutation.mutateAsync(data) + }) + .build() + + return +} + +export default ModifyTicketModal diff --git a/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBLogo.tsx b/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBLogo.tsx new file mode 100644 index 000000000..bd7834fcc --- /dev/null +++ b/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBLogo.tsx @@ -0,0 +1,36 @@ +function TMDBLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + Asset 2 + + + + + + + ) +} + +export default TMDBLogo diff --git a/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBResultItem.tsx b/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBResultItem.tsx new file mode 100644 index 000000000..718bc8759 --- /dev/null +++ b/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBResultItem.tsx @@ -0,0 +1,79 @@ +import forgeAPI from '@/utils/forgeAPI' +import { Icon } from '@iconify/react' +import { useMutation } from '@tanstack/react-query' +import dayjs from 'dayjs' +import { Button } from 'lifeforge-ui' +import { toast } from 'react-toastify' +import { usePromiseLoading } from 'shared' + +import type { TMDBSearchResults } from '..' + +function TMDBResultItem({ + data, + isAdded, + onAddToLibrary +}: { + data: TMDBSearchResults['results'][number] + isAdded: boolean + onAddToLibrary: () => Promise +}) { + const addToLibraryMutation = useMutation( + forgeAPI.movies.entries.create + .input({ id: data.id.toString() }) + .mutationOptions({ + onSuccess: async () => { + await onAddToLibrary() + }, + onError: (error: any) => { + toast.error( + `Failed to add movie: ${error.message || 'Unknown error'}` + ) + } + }) + ) + + const [loading, onSubmit] = usePromiseLoading(() => + addToLibraryMutation.mutateAsync({}) + ) + + return ( +
+
+ + +
+
+

+ {dayjs(data.release_date).year()} +

+

+ {data.title}{' '} + + ({data.original_title}) + +

+

{data.overview}

+ +
+
+ ) +} + +export default TMDBResultItem diff --git a/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBResultsList.tsx b/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBResultsList.tsx new file mode 100644 index 000000000..258320026 --- /dev/null +++ b/apps/lifeforge--movies/client/modals/SearchTMDBModal/components/TMDBResultsList.tsx @@ -0,0 +1,63 @@ +import { EmptyStateScreen, Pagination } from 'lifeforge-ui' + +import type { TMDBSearchResults } from '..' +import TMDBResultItem from './TMDBResultItem' + +function TMDBResultsList({ + results, + page, + setPage, + onAddToLibrary +}: { + results: TMDBSearchResults + page: number + setPage: React.Dispatch> + onAddToLibrary: () => Promise +}) { + if (results === null) { + return <> + } + + if (results.total_results === 0) { + return ( +
+ +
+ ) + } + + return ( + <> + +
+ {results.results.map(entry => ( + + ))} +
+ + + ) +} + +export default TMDBResultsList diff --git a/apps/lifeforge--movies/client/modals/SearchTMDBModal/index.tsx b/apps/lifeforge--movies/client/modals/SearchTMDBModal/index.tsx new file mode 100644 index 000000000..54e8b25bd --- /dev/null +++ b/apps/lifeforge--movies/client/modals/SearchTMDBModal/index.tsx @@ -0,0 +1,133 @@ +import forgeAPI from '@/utils/forgeAPI' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + EmptyStateScreen, + ModalHeader, + SearchInput, + WithQuery +} from 'lifeforge-ui' +import { useState } from 'react' +import { toast } from 'react-toastify' +import type { InferOutput } from 'shared' + +import TMDBLogo from './components/TMDBLogo' +import TMDBResultsList from './components/TMDBResultsList' + +export type TMDBSearchResults = InferOutput + +function SearchTMDBModal({ onClose }: { onClose: () => void }) { + const queryClient = useQueryClient() + + const [searchQuery, setSearchQuery] = useState('') + + const [queryToSearch, setQueryToSearch] = useState('') + + const [page, setPage] = useState(1) + + const searchResultsQuery = useQuery( + forgeAPI.movies.tmdb.search + .input({ + q: queryToSearch, + page: page.toString() + }) + .queryOptions({ + enabled: !!queryToSearch + }) + ) + + const onAddToLibrary = async () => { + await queryClient.invalidateQueries({ + queryKey: ['movies', 'entries'] + }) + + await queryClient.invalidateQueries({ + queryKey: forgeAPI.movies.tmdb.search.input({ + q: queryToSearch, + page: page.toString() + }).key + }) + + toast.success('Movie added to your library!') + } + + return ( +
+ + powered by  + + + +

+ } + icon="tabler:movie" + namespace="apps.movies" + title="Search TMDB" + onClose={onClose} + /> +
+ { + if (e.key === 'Enter') { + if (searchQuery.trim() !== '') { + setPage(1) + setQueryToSearch(searchQuery.trim()) + } + } + }} + /> + +
+
+ {queryToSearch ? ( + + {searchResults => ( + + )} + + ) : ( +
+ } + message={{ + id: 'tmdb', + namespace: 'apps.movies' + }} + /> +
+ )} +
+
+ ) +} + +export default SearchTMDBModal diff --git a/apps/lifeforge--movies/client/modals/ShowTicketModal.tsx b/apps/lifeforge--movies/client/modals/ShowTicketModal.tsx new file mode 100644 index 000000000..a451f4dbe --- /dev/null +++ b/apps/lifeforge--movies/client/modals/ShowTicketModal.tsx @@ -0,0 +1,63 @@ +import { Icon } from '@iconify/react' +import dayjs from 'dayjs' +import { ModalHeader } from 'lifeforge-ui' +import { QRCodeSVG } from 'qrcode.react' + +import type { MovieEntry } from '..' + +function ShowTicketModal({ + data: { entry }, + onClose +}: { + data: { + entry: MovieEntry | null + } + onClose: () => void +}) { + return ( +
+ + {entry && ( + <> +
+
+ + , +
+
+

{entry.title}

+
+
+ + {entry.theatre_location || 'N/A'} +
+
+ + {entry.theatre_showtime + ? dayjs(entry.theatre_showtime).format('DD MMM YYYY, h:mm a') + : 'N/A'} +
+
+ + Theatre No. {entry.theatre_number || 'N/A'} +
+
+ + {entry.theatre_seat || 'N/A'} +
+
+ + )} +
+ ) +} + +export default ShowTicketModal diff --git a/apps/lifeforge--movies/client/tsconfig.json b/apps/lifeforge--movies/client/tsconfig.json new file mode 100644 index 000000000..b2f7bfac0 --- /dev/null +++ b/apps/lifeforge--movies/client/tsconfig.json @@ -0,0 +1,49 @@ +{ + "compilerOptions": { + // JSX and Language Settings + "jsx": "react-jsx", + "target": "ES2020", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable", + "ES2024.Object" + ], + // Module Resolution + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + // Build and Output + "composite": true, + "noEmit": true, + "skipLibCheck": true, + // Type Checking + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useDefineForClassFields": true, + "noFallthroughCasesInSwitch": true, + // Path Mapping + "paths": { + "@": [ + "./src/index" + ], + "@/*": [ + "./*" + ], + "@server/*": [ + "../../../server/src/*" + ] + } + }, + "include": [ + "./**/*" + ], + "references": [ + { + "path": "../../../server/tsconfig.json" + } + ] +} \ No newline at end of file diff --git a/apps/lifeforge--movies/client/utils/forgeAPI.ts b/apps/lifeforge--movies/client/utils/forgeAPI.ts new file mode 100644 index 000000000..ed1b1c212 --- /dev/null +++ b/apps/lifeforge--movies/client/utils/forgeAPI.ts @@ -0,0 +1,10 @@ +import { type AppRoutes } from '@server/core/routes/routes.type' +import { createForgeAPIClient } from 'shared' + +if (!import.meta.env.VITE_API_HOST) { + throw new Error('VITE_API_HOST is not defined') +} + +const forgeAPI = createForgeAPIClient(import.meta.env.VITE_API_HOST) + +export default forgeAPI diff --git a/apps/lifeforge--movies/client/vite-env.d.ts b/apps/lifeforge--movies/client/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/lifeforge--movies/client/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/lifeforge--movies/locales/en.json b/apps/lifeforge--movies/locales/en.json new file mode 100644 index 000000000..0c4a7222c --- /dev/null +++ b/apps/lifeforge--movies/locales/en.json @@ -0,0 +1,59 @@ +{ + "title": "Movies", + "description": "Your personal log for every movie you’ve watched.", + "items": { + "movie": "Movie" + }, + "modals": { + "searchTmdb": { + "title": "Search Movie" + }, + "ticket": { + "create": "Add Ticket", + "update": "Update Ticket", + "view": "View Ticket" + } + }, + "empty": { + "tmdb": { + "title": "Powered by The Movie DB", + "description": "Millions of movies to discover." + }, + "search": { + "title": "Movies Not Found", + "description": "Please try entering a different search keyword." + }, + "library": { + "title": "No Movies in Library", + "description": "Add movies to your library by clicking the button below." + } + }, + "buttons": { + "addToLibrary": "Add to Library", + "alreadyInLibrary": "Already in Library", + "addTicket": "Add Ticket", + "updateTicket": "Update Ticket", + "markAsWatched": "Mark as Watched", + "markAsUnwatched": "Mark as Unwatched", + "watched": "Watched", + "showTicket": "Show Ticket", + "addToCalendar": "Add to Calendar", + "addedToCalendar": "Added to Calendar", + "updateMovieData": "Update Movie Data" + }, + "inputs": { + "ticketNumber": "Ticket Number", + "theatreSeat": "Theatre Seat", + "theatreLocation": "Theatre Location", + "theatreShowtime": "Theatre Showtime", + "theatreNumber": "Theatre Number", + "category": "Category" + }, + "tabs": { + "watched": "Watched", + "unwatched": "Unwatched" + }, + "misc": { + "watched": "Watched on {{date}}" + } +} diff --git a/apps/lifeforge--movies/locales/ms.json b/apps/lifeforge--movies/locales/ms.json new file mode 100644 index 000000000..ad79f4862 --- /dev/null +++ b/apps/lifeforge--movies/locales/ms.json @@ -0,0 +1,59 @@ +{ + "title": "Filem", + "description": "Setiap filem, sebuah cerita untuk diingati.", + "items": { + "movie": "Filem" + }, + "modals": { + "searchTmdb": { + "title": "Cari Filem" + }, + "ticket": { + "create": "Tambah Tiket", + "update": "Kemaskini Tiket", + "view": "Lihat Tiket" + } + }, + "empty": { + "tmdb": { + "title": "Dikuasakan oleh Pangkalan Data Filem", + "description": "Berjuta-juta filem untuk ditemui." + }, + "search": { + "title": "Filem Tidak Ditemui", + "description": "Sila cuba masukkan kata kunci carian yang berbeza." + }, + "library": { + "title": "Tiada Filem dalam Pustaka", + "description": "Tambah filem ke dalam pustaka anda dengan menekan butang di bawah." + } + }, + "buttons": { + "addToLibrary": "Tambah ke Pustaka", + "alreadyInLibrary": "Sudah di dalam Pustaka", + "addTicket": "Tambah Tiket", + "updateTicket": "Kemaskini Tiket", + "markAsWatched": "Tandakan sebagai Ditonton", + "markAsUnwatched": "Tandakan sebagai Tidak Ditonton", + "watched": "Telah Ditonton", + "showTicket": "Tunjukkan Tiket", + "addToCalendar": "Tambah ke Kalendar", + "addedToCalendar": "Ditambahkan ke Kalendar", + "updateMovieData": "Kemas Kini Data Filem" + }, + "inputs": { + "ticketNumber": "Nombor Tiket", + "theatreSeat": "Tempat Duduk Pawagam", + "theatreLocation": "Lokasi Pawagam", + "theatreShowtime": "Masa Tayangan Pawagam", + "theatreNumber": "Nombor Pawagam", + "category": "Kategori" + }, + "tabs": { + "watched": "Ditonton", + "unwatched": "Belum ditonton" + }, + "misc": { + "watched": "Ditonton pada {{date}}" + } +} diff --git a/apps/lifeforge--movies/locales/zh-CN.json b/apps/lifeforge--movies/locales/zh-CN.json new file mode 100644 index 000000000..57ba4c4c4 --- /dev/null +++ b/apps/lifeforge--movies/locales/zh-CN.json @@ -0,0 +1,59 @@ +{ + "title": "電影", + "description": "每一部电影,是一个值得铭记的故事。", + "items": { + "movie": "电影" + }, + "modals": { + "searchTmdb": { + "title": "搜索电影" + }, + "ticket": { + "create": "新增票券", + "update": "更新票券", + "view": "查看票券" + } + }, + "empty": { + "tmdb": { + "title": "由 The Movie DB 提供支援", + "description": "数百万部电影等待您发现。" + }, + "search": { + "title": "找不到电影", + "description": "请尝试输入不同的搜索关键字。" + }, + "library": { + "title": "没有电影在库", + "description": "点击下面的按钮将电影添加到您的库中。" + } + }, + "buttons": { + "addToLibrary": "加入影片库", + "alreadyInLibrary": "已在影片库", + "addTicket": "新增票券", + "updateTicket": "更新票券", + "markAsWatched": "标记为已观看", + "markAsUnwatched": "标记为未观看", + "watched": "已观看", + "showTicket": "显示票据", + "addToCalendar": "添加到日历", + "addedToCalendar": "已添加到日历", + "updateMovieData": "更新电影数据" + }, + "inputs": { + "ticketNumber": "票券号码", + "theatreSeat": "影院座位", + "theatreLocation": "影院地点", + "theatreShowtime": "影院放映时间", + "theatreNumber": "影院号码", + "category": "类别" + }, + "tabs": { + "watched": "已观看", + "unwatched": "未观看" + }, + "misc": { + "watched": "观看于{{date}}" + } +} diff --git a/apps/lifeforge--movies/locales/zh-TW.json b/apps/lifeforge--movies/locales/zh-TW.json new file mode 100644 index 000000000..2e4d21826 --- /dev/null +++ b/apps/lifeforge--movies/locales/zh-TW.json @@ -0,0 +1,59 @@ +{ + "title": "電影", + "description": "每一部電影,是一個值得銘記的故事。", + "items": { + "movie": "電影" + }, + "modals": { + "searchTmdb": { + "title": "搜尋電影" + }, + "ticket": { + "create": "新增票券", + "update": "更新票券", + "view": "查看票券" + } + }, + "empty": { + "tmdb": { + "title": "由 The Movie DB 提供支援", + "description": "數百萬部電影等待您發現。" + }, + "search": { + "title": "找不到電影", + "description": "請嘗試輸入不同的搜尋關鍵字。" + }, + "library": { + "title": "沒有電影在庫", + "description": "點擊下面的按鈕將電影添加到您的庫中。" + } + }, + "buttons": { + "addToLibrary": "加入影片庫", + "alreadyInLibrary": "已在影片庫", + "addTicket": "新增票券", + "updateTicket": "更新票券", + "markAsWatched": "標記為已觀看", + "markAsUnwatched": "標記為未觀看", + "watched": "已觀看", + "showTicket": "顯示票據", + "addToCalendar": "添加到日曆", + "addedToCalendar": "已加入日曆", + "updateMovieData": "更新電影資料" + }, + "inputs": { + "ticketNumber": "票券號碼", + "theatreSeat": "影院座位", + "theatreLocation": "影院地點", + "theatreShowtime": "影院放映時間", + "theatreNumber": "影院號碼", + "category": "類別" + }, + "tabs": { + "watched": "已觀看", + "unwatched": "未觀看" + }, + "misc": { + "watched": "觀看於{{date}}" + } +} diff --git a/apps/lifeforge--movies/manifest.d.ts b/apps/lifeforge--movies/manifest.d.ts new file mode 100644 index 000000000..40fdeb052 --- /dev/null +++ b/apps/lifeforge--movies/manifest.d.ts @@ -0,0 +1,7 @@ +// AUTO-GENERATED - DO NOT EDIT +// This declaration file allows TypeScript to type-check module imports +// without resolving internal module aliases like @ +import type { ModuleConfig } from 'shared' + +declare const manifest: ModuleConfig +export default manifest diff --git a/apps/lifeforge--movies/manifest.ts b/apps/lifeforge--movies/manifest.ts new file mode 100644 index 000000000..92d8d2fab --- /dev/null +++ b/apps/lifeforge--movies/manifest.ts @@ -0,0 +1,9 @@ +import { lazy } from 'react' +import type { ModuleConfig } from 'shared' + +export default { + routes: { + '/': lazy(() => import('@')) + }, + +} satisfies ModuleConfig diff --git a/apps/lifeforge--movies/package.json b/apps/lifeforge--movies/package.json new file mode 100644 index 000000000..c4e1fbf71 --- /dev/null +++ b/apps/lifeforge--movies/package.json @@ -0,0 +1,47 @@ +{ + "name": "@lifeforge/lifeforge--movies", + "displayName": "Movies Library", + "version": "0.1.0", + "description": "Your personal log for every movie you’ve watched.", + "scripts": { + "types": "cd client && bun tsc" + }, + "dependencies": { + "@iconify/react": "^6.0.2", + "@tanstack/react-query": "^5.90.11", + "@uidotdev/usehooks": "^2.4.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.18", + "lifeforge-ui": "workspace:*", + "moment": "^2.30.1", + "qrcode.react": "^4.2.0", + "react-i18next": "^15.1.1", + "react-toastify": "^11.0.5", + "shared": "workspace:*", + "vite": "^7.1.9", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.1" + }, + "author": "Melvin Chia ", + "exports": { + "./server": "./server/index.ts", + "./manifest": { + "types": "./manifest.d.ts", + "default": "./manifest.ts" + }, + "./server/schema": "./server/schema.ts" + }, + "lifeforge": { + "icon": "tabler:movie", + "APIKeyAccess": { + "tmdb": { + "required": true, + "usage": "Fetch movie data from TMDB" + } + }, + "category": "Lifestyle" + } +} \ No newline at end of file diff --git a/apps/lifeforge--movies/server/events.ts b/apps/lifeforge--movies/server/events.ts new file mode 100644 index 000000000..4382ad207 --- /dev/null +++ b/apps/lifeforge--movies/server/events.ts @@ -0,0 +1,52 @@ +import { PBService } from '@functions/database' +import moment from 'moment' + +export default async function getEvents({ + pb, + start, + end +}: { + pb: PBService + start: string + end: string +}) { + return ( + await pb.getFullList + .collection('movies__entries') + .filter([ + { + field: 'theatre_showtime', + operator: '>=', + value: start + }, + { field: 'theatre_showtime', operator: '<=', value: end } + ]) + .execute() + .catch(() => []) + ).map(entry => ({ + id: entry.id, + type: 'single' as const, + title: entry.title, + start: entry.theatre_showtime, + end: moment(entry.theatre_showtime) + .add(entry.duration, 'minutes') + .toISOString(), + category: '_movie', + calendar: '', + location: entry.theatre_location ?? '', + location_coords: entry.theatre_location_coords, + description: ` + ![${entry.title}](http://image.tmdb.org/t/p/w300/${entry.poster}) + + ### Movie Description: + ${entry.overview} + + ### Theatre Number: + ${entry.theatre_number} + + ### Seat Number: + ${entry.theatre_seat} + `, + reference_link: `/movies?show-ticket=${entry.id}` + })) +} diff --git a/apps/lifeforge--movies/server/index.ts b/apps/lifeforge--movies/server/index.ts new file mode 100644 index 000000000..fc261aab0 --- /dev/null +++ b/apps/lifeforge--movies/server/index.ts @@ -0,0 +1,11 @@ +import { forgeRouter } from '@functions/routes' + +import entriesRouter from './routes/entries' +import ticketRouter from './routes/ticket' +import tmdbRouter from './routes/tmdb' + +export default forgeRouter({ + entries: entriesRouter, + ticket: ticketRouter, + tmdb: tmdbRouter +}) diff --git a/apps/lifeforge--movies/server/package.json b/apps/lifeforge--movies/server/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/apps/lifeforge--movies/server/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/apps/lifeforge--movies/server/routes/entries.ts b/apps/lifeforge--movies/server/routes/entries.ts new file mode 100644 index 000000000..8cc904e69 --- /dev/null +++ b/apps/lifeforge--movies/server/routes/entries.ts @@ -0,0 +1,253 @@ +import { getAPIKey } from '@functions/database' +import { forgeController, forgeRouter } from '@functions/routes' +import z from 'zod' + +const list = forgeController + .query() + .description({ + en: 'Get all movie entries', + ms: 'Dapatkan semua catatan filem', + 'zh-CN': '获取所有电影条目', + 'zh-TW': '獲取所有電影條目' + }) + .input({ + query: z.object({ + watched: z + .enum(['true', 'false']) + .optional() + .default('false') + .transform(val => (val === 'true' ? true : false)) + }) + }) + .callback(async ({ pb, query: { watched } }) => { + const entries = await pb.getFullList + .collection('movies__entries') + .filter([ + { + field: 'is_watched', + operator: '=', + value: watched + } + ]) + .execute() + + const total = ( + await pb.getList + .collection('movies__entries') + .page(1) + .perPage(1) + .execute() + ).totalItems + + return { + total, + entries: entries.sort((a, b) => { + if (a.is_watched !== b.is_watched) { + return a.is_watched ? 1 : -1 // Unwatched entries come first + } + + if ( + (a.ticket_number && !b.ticket_number) || + (!a.ticket_number && b.ticket_number) + ) { + return a.ticket_number ? -1 : 1 // Entries with tickets come first + } + + if (a.theatre_showtime && b.theatre_showtime) { + return ( + new Date(a.theatre_showtime).getTime() - + new Date(b.theatre_showtime).getTime() // Earlier showtimes come first + ) + } + + return a.title.localeCompare(b.title) + }) + } + }) + +const create = forgeController + .mutation() + .description({ + en: 'Create a movie entry from TMDB', + ms: 'Cipta catatan filem daripada TMDB', + 'zh-CN': '从 TMDB 创建电影条目', + 'zh-TW': '從 TMDB 創建電影條目' + }) + .input({ + query: z.object({ + id: z.string().transform(val => parseInt(val, 10)) + }) + }) + .statusCode(201) + .callback(async ({ pb, query: { id } }) => { + const apiKey = await getAPIKey('tmdb', pb) + + if (!apiKey) { + throw new Error('API key not found') + } + + const initialData = await pb.getFirstListItem + .collection('movies__entries') + .filter([ + { + field: 'tmdb_id', + operator: '=', + value: id + } + ]) + .execute() + .catch(() => null) + + if (initialData) { + throw new Error('Entry already exists') + } + + const response = await fetch(`https://api.themoviedb.org/3/movie/${id}`, { + headers: { + Authorization: `Bearer ${apiKey}` + } + }) + .then(res => res.json()) + .catch(err => { + throw new Error(`Failed to fetch data from TMDB: ${err.message}`) + }) + + const entryData = { + tmdb_id: response.id, + title: response.title, + original_title: response.original_title, + poster: response.poster_path, + genres: response.genres.map((genre: { name: string }) => genre.name), + duration: response.runtime, + overview: response.overview, + release_date: response.release_date, + countries: response.origin_country, + language: response.original_language + } + + return await pb.create + .collection('movies__entries') + .data(entryData) + .execute() + }) + +const update = forgeController + .mutation() + .description({ + en: 'Update movie entry with the latest data from TMDB', + ms: 'Kemas kini catatan filem dengan data terkini daripada TMDB', + 'zh-CN': '使用 TMDB 的最新数据更新电影条目', + 'zh-TW': '使用 TMDB 的最新資料更新電影條目' + }) + .input({ + query: z.object({ + id: z.string() + }) + }) + .existenceCheck('query', { + id: 'movies__entries' + }) + .callback(async ({ pb, query: { id } }) => { + const apiKey = await getAPIKey('tmdb', pb) + + if (!apiKey) { + throw new Error('API key not found') + } + + const movieEntry = await pb.getOne + .collection('movies__entries') + .id(id) + .execute() + + const response = await fetch( + `https://api.themoviedb.org/3/movie/${movieEntry.tmdb_id}`, + { + headers: { + Authorization: `Bearer ${apiKey}` + } + } + ) + .then(res => res.json()) + .catch(err => { + throw new Error(`Failed to fetch data from TMDB: ${err.message}`) + }) + + const entryData = { + tmdb_id: response.id, + title: response.title, + original_title: response.original_title, + poster: response.poster_path, + genres: response.genres.map((genre: { name: string }) => genre.name), + duration: response.runtime, + overview: response.overview, + release_date: response.release_date, + countries: response.origin_country, + language: response.original_language + } + + return await pb.update + .collection('movies__entries') + .id(id) + .data(entryData) + .execute() + }) + +const remove = forgeController + .mutation() + .description({ + en: 'Delete a movie entry', + ms: 'Padam catatan filem', + 'zh-CN': '删除电影条目', + 'zh-TW': '刪除電影條目' + }) + .input({ + query: z.object({ + id: z.string() + }) + }) + .existenceCheck('query', { + id: 'movies__entries' + }) + .statusCode(204) + .callback(({ pb, query: { id } }) => + pb.delete.collection('movies__entries').id(id).execute() + ) + +const toggleWatchStatus = forgeController + .mutation() + .description({ + en: 'Toggle watch status of a movie entry', + ms: 'Togol status tontonan catatan filem', + 'zh-CN': '切换电影条目的观看状态', + 'zh-TW': '切換電影條目的觀看狀態' + }) + .input({ + query: z.object({ + id: z.string() + }) + }) + .existenceCheck('query', { + id: 'movies__entries' + }) + .callback(async ({ pb, query: { id } }) => { + const entry = await pb.getOne.collection('movies__entries').id(id).execute() + + return await pb.update + .collection('movies__entries') + .id(id) + .data({ + is_watched: !entry.is_watched, + watch_date: !entry.is_watched + ? entry.theatre_showtime || new Date().toISOString() + : null + }) + .execute() + }) + +export default forgeRouter({ + list, + create, + update, + remove, + toggleWatchStatus +}) diff --git a/apps/lifeforge--movies/server/routes/ticket.ts b/apps/lifeforge--movies/server/routes/ticket.ts new file mode 100644 index 000000000..5ec4a00bd --- /dev/null +++ b/apps/lifeforge--movies/server/routes/ticket.ts @@ -0,0 +1,80 @@ +import { forgeController, forgeRouter } from '@functions/routes' +import { Location } from '@lib/locations/typescript/location.types' +import { SCHEMAS } from '@schema' +import z from 'zod' + +const update = forgeController + .mutation() + .description({ + en: 'Update ticket information for a movie entry', + ms: 'Kemas kini maklumat tiket untuk catatan filem', + 'zh-CN': '更新电影条目的票务信息', + 'zh-TW': '更新電影條目的票務資訊' + }) + .input({ + query: z.object({ + id: z.string() + }), + body: SCHEMAS.movies.entries.schema + .pick({ + ticket_number: true, + theatre_number: true, + theatre_seat: true + }) + .extend({ + theatre_showtime: z.string().optional(), + theatre_location: Location.optional() + }) + }) + .existenceCheck('query', { + id: 'movies__entries' + }) + .callback(({ pb, query: { id }, body }) => { + const finalData = { + ...body, + theatre_location: body.theatre_location?.name, + theatre_location_coords: { + lat: body.theatre_location?.location.latitude || 0, + lon: body.theatre_location?.location.longitude || 0 + } + } + + return pb.update + .collection('movies__entries') + .id(id) + .data(finalData) + .execute() + }) + +const clear = forgeController + .mutation() + .description({ + en: 'Clear ticket information for a movie entry', + ms: 'Kosongkan maklumat tiket untuk catatan filem', + 'zh-CN': '清除电影条目的票务信息', + 'zh-TW': '清除電影條目的票務資訊' + }) + .input({ + query: z.object({ + id: z.string() + }) + }) + .existenceCheck('query', { + id: 'movies__entries' + }) + .statusCode(204) + .callback(({ pb, query: { id } }) => + pb.update + .collection('movies__entries') + .id(id) + .data({ + ticket_number: '', + theatre_location: '', + theatre_number: '', + theatre_seat: '', + theatre_showtime: '' + }) + .execute() + ) + +export default forgeRouter({ update, clear }) diff --git a/apps/lifeforge--movies/server/routes/tmdb.ts b/apps/lifeforge--movies/server/routes/tmdb.ts new file mode 100644 index 000000000..3e50a81e7 --- /dev/null +++ b/apps/lifeforge--movies/server/routes/tmdb.ts @@ -0,0 +1,84 @@ +import { getAPIKey } from '@functions/database' +import { forgeController, forgeRouter } from '@functions/routes' +import z from 'zod' + +export interface TMDBSearchResult { + adult: boolean + backdrop_path: string + genre_ids: number[] + existed: boolean + id: number + original_language: string + original_title: string + overview: string + popularity: number + poster_path: string + release_date: string + title: string + video: boolean + vote_average: number + vote_count: number +} + +const search = forgeController + .query() + .description({ + en: 'Search movies using TMDB API', + ms: 'Cari filem menggunakan API TMDB', + 'zh-CN': '使用 TMDB API 搜索电影', + 'zh-TW': '使用 TMDB API 搜尋電影' + }) + .input({ + query: z.object({ + q: z.string().min(1, 'Query must not be empty'), + page: z + .string() + .optional() + .default('1') + .transform(val => parseInt(val) || 1) + }) + }) + .callback(async ({ pb, query: { q, page } }) => { + const apiKey = await getAPIKey('tmdb', pb) + + if (!apiKey) { + throw new Error('API key not found') + } + + const url = `https://api.themoviedb.org/3/search/movie?query=${decodeURIComponent( + q + )}&page=${page}` + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}` + } + }).then(res => res.json()) + + const allIds = await pb.getFullList + .collection('movies__entries') + .filter([ + { + combination: '||', + filters: response.results.map((entry: { id: number }) => ({ + field: 'tmdb_id', + operator: '=', + value: entry.id + })) + } + ]) + .execute() + + response.results.forEach((entry: any) => { + entry.existed = allIds.some(e => e.tmdb_id === entry.id) + }) + + return response as { + page: number + results: TMDBSearchResult[] + total_pages: number + total_results: number + } + }) + +export default forgeRouter({ search }) diff --git a/apps/lifeforge--movies/server/schema.ts b/apps/lifeforge--movies/server/schema.ts new file mode 100644 index 000000000..64cdd41ee --- /dev/null +++ b/apps/lifeforge--movies/server/schema.ts @@ -0,0 +1,257 @@ +import z from 'zod' + +const moviesSchemas = { + entries: { + schema: z.object({ + tmdb_id: z.number(), + title: z.string(), + original_title: z.string(), + poster: z.string(), + genres: z.any(), + duration: z.number(), + overview: z.string(), + countries: z.any(), + language: z.string(), + release_date: z.string(), + watch_date: z.string(), + ticket_number: z.string(), + theatre_seat: z.string(), + theatre_showtime: z.string(), + theatre_location: z.string(), + theatre_location_coords: z.object({ lat: z.number(), lon: z.number() }), + theatre_number: z.string(), + is_watched: z.boolean() + }), + raw: { + listRule: '@request.auth.id != ""', + viewRule: '@request.auth.id != ""', + createRule: '@request.auth.id != ""', + updateRule: '@request.auth.id != ""', + deleteRule: '@request.auth.id != ""', + name: 'movies__entries', + type: 'base', + fields: [ + { + autogeneratePattern: '[a-z0-9]{15}', + hidden: false, + max: 15, + min: 15, + name: 'id', + pattern: '^[a-z0-9]+$', + presentable: false, + primaryKey: true, + required: true, + system: true, + type: 'text' + }, + { + hidden: false, + max: null, + min: null, + name: 'tmdb_id', + onlyInt: false, + presentable: false, + required: false, + system: false, + type: 'number' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'title', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'original_title', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'poster', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + hidden: false, + maxSize: 0, + name: 'genres', + presentable: false, + required: false, + system: false, + type: 'json' + }, + { + hidden: false, + max: null, + min: null, + name: 'duration', + onlyInt: false, + presentable: false, + required: false, + system: false, + type: 'number' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'overview', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + hidden: false, + maxSize: 0, + name: 'countries', + presentable: false, + required: false, + system: false, + type: 'json' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'language', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + hidden: false, + max: '', + min: '', + name: 'release_date', + presentable: false, + required: false, + system: false, + type: 'date' + }, + { + hidden: false, + max: '', + min: '', + name: 'watch_date', + presentable: false, + required: false, + system: false, + type: 'date' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'ticket_number', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'theatre_seat', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + hidden: false, + max: '', + min: '', + name: 'theatre_showtime', + presentable: false, + required: false, + system: false, + type: 'date' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'theatre_location', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + hidden: false, + name: 'theatre_location_coords', + presentable: false, + required: false, + system: false, + type: 'geoPoint' + }, + { + autogeneratePattern: '', + hidden: false, + max: 0, + min: 0, + name: 'theatre_number', + pattern: '', + presentable: false, + primaryKey: false, + required: false, + system: false, + type: 'text' + }, + { + hidden: false, + name: 'is_watched', + presentable: false, + required: false, + system: false, + type: 'bool' + } + ], + indexes: [], + system: false + } + } +} + +export default moviesSchemas diff --git a/apps/lifeforge--movies/server/tsconfig.json b/apps/lifeforge--movies/server/tsconfig.json new file mode 100644 index 000000000..c6daadc6d --- /dev/null +++ b/apps/lifeforge--movies/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../server/tsconfig.json", + "include": [ + "./**/*.ts" + ], + "references": [ + { + "path": "../../../server/tsconfig.json" + } + ] +} \ No newline at end of file diff --git a/apps/lifeforge--movies/tsconfig.json b/apps/lifeforge--movies/tsconfig.json new file mode 100644 index 000000000..ba9e1da07 --- /dev/null +++ b/apps/lifeforge--movies/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./client/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "composite": false, + "paths": { + "@": [ + "./client/index" + ], + "@/*": [ + "./client/*" + ] + } + }, + "include": [ + "./manifest.ts" + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 19fd37558..9aaa771a7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "./packages/*", "./apps/*", "./locales/*", - "./tools/*" + "./tools" ], "scripts": { "forge": "bun run tools/src/index.ts" @@ -64,6 +64,7 @@ "@lifeforge/lifeforge--achievements": "workspace:*", "@lifeforge/lifeforge--api-endpoint-explorer": "workspace:*", "@lifeforge/lifeforge--calendar": "workspace:*", + "@lifeforge/lifeforge--movies": "workspace:*", "@lifeforge/melvinchia3636--invoice-maker": "workspace:*" } } \ No newline at end of file diff --git a/shared/package.json b/shared/package.json index 61d0b9d8a..7dd02239d 100644 --- a/shared/package.json +++ b/shared/package.json @@ -35,6 +35,6 @@ "react-toastify": "^11.0.5", "socket.io-client": "^4.8.1", "tinycolor2": "^1.6.0", - "zod": "^4.1.12" + "zod": "^4.3.4" } } diff --git a/shared/src/providers/PersonalizationProvider/index.tsx b/shared/src/providers/PersonalizationProvider/index.tsx index 4b8e25414..1d2c7536c 100644 --- a/shared/src/providers/PersonalizationProvider/index.tsx +++ b/shared/src/providers/PersonalizationProvider/index.tsx @@ -20,7 +20,7 @@ import type { import { getColorPalette } from './utils/themeColors' const DEFAULT_VALUE: IPersonalizationData = { - rootElement: document.body, + rootElement: typeof document !== 'undefined' ? document.body : null, fontFamily: 'Onest', fontScale: 1, borderRadiusMultiplier: 1, diff --git a/tools/package.json b/tools/package.json index 2859899b9..668069834 100644 --- a/tools/package.json +++ b/tools/package.json @@ -21,10 +21,13 @@ "pocketbase": "^0.26.3", "prettier": "^3.7.4", "prompts": "^2.4.2", - "zod": "^4.1.12" + "semver": "^7.7.3", + "shared": "workspace:*", + "zod": "^4.3.4" }, "devDependencies": { "@types/crypto-js": "^4.2.2", - "@types/lodash": "^4.17.21" + "@types/lodash": "^4.17.21", + "@types/semver": "^7.7.1" } } \ No newline at end of file diff --git a/tools/src/cli/setup.ts b/tools/src/cli/setup.ts index 6a1dccfa0..59299e3ee 100644 --- a/tools/src/cli/setup.ts +++ b/tools/src/cli/setup.ts @@ -4,7 +4,7 @@ import path from 'path' import { LOG_LEVELS } from '@/constants/constants' -import CLILoggingService from '../utils/logging' +import Logging from '../utils/logging' import { configureHelp } from './help' function getVersion(): string { @@ -52,7 +52,7 @@ export async function setupCLI(): Promise { const level = thisCommand.opts().logLevel as (typeof LOG_LEVELS)[number] if (LOG_LEVELS.includes(level)) { - CLILoggingService.setLevel(level) + Logging.setLevel(level) } }) diff --git a/tools/src/commands/changelog/handlers/createChangelogHandler.ts b/tools/src/commands/changelog/handlers/createChangelogHandler.ts index 744818a8b..04ce02c36 100644 --- a/tools/src/commands/changelog/handlers/createChangelogHandler.ts +++ b/tools/src/commands/changelog/handlers/createChangelogHandler.ts @@ -3,7 +3,7 @@ import weekOfYear from 'dayjs/plugin/weekOfYear' import fs from 'fs' import path from 'path' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' dayjs.extend(weekOfYear) @@ -30,9 +30,7 @@ export default function createChangelogHandler(year?: string, week?: string) { const currentWeek = Number(week) || dayjs().week() if (!fs.existsSync(CHANGELOG_PATH)) { - CLILoggingService.error( - `Changelog directory not found at path: ${CHANGELOG_PATH}` - ) + Logging.error(`Changelog directory not found at path: ${CHANGELOG_PATH}`) process.exit(1) } @@ -45,7 +43,7 @@ export default function createChangelogHandler(year?: string, week?: string) { const filePath = `${yearPath}/${String(currentWeek).padStart(2, '0')}.mdx` if (fs.existsSync(filePath)) { - CLILoggingService.error( + Logging.error( `Changelog file for year ${targetYear} week ${currentWeek} already exists at path: ${filePath}` ) process.exit(1) @@ -53,5 +51,5 @@ export default function createChangelogHandler(year?: string, week?: string) { fs.writeFileSync(filePath, boilerPlate) - CLILoggingService.success(`Changelog file created at path: ${filePath}`) + Logging.success(`Changelog file created at path: ${filePath}`) } diff --git a/tools/src/commands/db/functions/database-initialization/download-pocketbase.ts b/tools/src/commands/db/functions/database-initialization/download-pocketbase.ts index b2f259ea5..7b9cf0112 100644 --- a/tools/src/commands/db/functions/database-initialization/download-pocketbase.ts +++ b/tools/src/commands/db/functions/database-initialization/download-pocketbase.ts @@ -5,7 +5,7 @@ import path from 'path' import { PB_BINARY_PATH, PB_DIR } from '@/constants/db' import { executeCommand, isDockerMode } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' const PB_VERSION = '0.35.0' @@ -16,20 +16,18 @@ const PB_VERSION = '0.35.0' export async function downloadPocketBaseBinary(): Promise { // Skip in Docker mode - binary is provided by the container if (isDockerMode()) { - CLILoggingService.debug('Docker mode detected, skipping binary download') + Logging.debug('Docker mode detected, skipping binary download') return } if (fs.existsSync(PB_BINARY_PATH)) { - CLILoggingService.debug( - 'PocketBase binary already exists, skipping download' - ) + Logging.debug('PocketBase binary already exists, skipping download') return } - CLILoggingService.step('PocketBase binary not found, downloading...') + Logging.step('PocketBase binary not found, downloading...') // Detect OS const platform = os.platform() @@ -47,7 +45,7 @@ export async function downloadPocketBaseBinary(): Promise { osName = 'windows' break default: - CLILoggingService.error(`Unsupported platform: ${platform}`) + Logging.error(`Unsupported platform: ${platform}`) process.exit(1) } @@ -64,13 +62,13 @@ export async function downloadPocketBaseBinary(): Promise { archName = 'amd64' break default: - CLILoggingService.error(`Unsupported architecture: ${arch}`) + Logging.error(`Unsupported architecture: ${arch}`) process.exit(1) } const downloadUrl = `https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_${osName}_${archName}.zip` - CLILoggingService.info( + Logging.info( `Downloading PocketBase v${PB_VERSION} for ${osName}/${archName}...` ) @@ -90,7 +88,7 @@ export async function downloadPocketBaseBinary(): Promise { fs.writeFileSync(zipPath, Buffer.from(arrayBuffer)) - CLILoggingService.debug('Download complete, extracting...') + Logging.debug('Download complete, extracting...') // Extract using unzip command executeCommand(`unzip -o "${zipPath}" -d "${PB_DIR}"`, { @@ -112,11 +110,11 @@ export async function downloadPocketBaseBinary(): Promise { fs.chmodSync(PB_BINARY_PATH, 0o755) } - CLILoggingService.success( + Logging.success( `PocketBase v${PB_VERSION} downloaded to ${chalk.bold.blue(PB_BINARY_PATH)}` ) } catch (error) { - CLILoggingService.error( + Logging.error( `Failed to download PocketBase: ${error instanceof Error ? error.message : 'Unknown error'}` ) process.exit(1) diff --git a/tools/src/commands/db/functions/database-initialization/migrations.ts b/tools/src/commands/db/functions/database-initialization/migrations.ts index 51cac5963..d5a15324b 100644 --- a/tools/src/commands/db/functions/database-initialization/migrations.ts +++ b/tools/src/commands/db/functions/database-initialization/migrations.ts @@ -1,23 +1,23 @@ import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db' import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' /** * Runs database migrations */ export function runDatabaseMigrations(): void { try { - CLILoggingService.step('Migrating database schema to latest state...') + Logging.step('Migrating database schema to latest state...') executeCommand(`bun forge db push`, { stdio: ['pipe', 'pipe', 'pipe'] }) - CLILoggingService.success('Initial migration generated successfully.') + Logging.success('Initial migration generated successfully.') executeCommand(`${PB_BINARY_PATH} migrate up ${PB_KWARGS.join(' ')}`, { stdio: ['pipe', 'pipe', 'pipe'] }) - CLILoggingService.success('Database schema migrated successfully.') + Logging.success('Database schema migrated successfully.') } catch (error) { - CLILoggingService.error( + Logging.error( `Failed to generate initial migration: ${ error instanceof Error ? error.message : 'Unknown error' }` diff --git a/tools/src/commands/db/functions/database-initialization/superuser.ts b/tools/src/commands/db/functions/database-initialization/superuser.ts index 684404c59..246b7848c 100644 --- a/tools/src/commands/db/functions/database-initialization/superuser.ts +++ b/tools/src/commands/db/functions/database-initialization/superuser.ts @@ -3,7 +3,7 @@ import fs from 'fs' import { PB_BINARY_PATH, PB_DATA_DIR, PB_KWARGS } from '@/constants/db' import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' /** * Validates that PocketBase data directory doesn't already exist @@ -14,7 +14,7 @@ export function validatePocketBaseNotInitialized( const pbInitialized = fs.existsSync(PB_DATA_DIR) if (pbInitialized && exitOnFailure) { - CLILoggingService.actionableError( + Logging.actionableError( `PocketBase is already initialized in ${PB_DATA_DIR}, aborting.`, 'If you want to re-initialize, please remove the existing pb_data folder in the database directory.' ) @@ -32,7 +32,7 @@ export function createPocketBaseSuperuser( password: string ): void { try { - CLILoggingService.step( + Logging.step( `Initializing PocketBase database for ${chalk.bold.blue(email)}` ) @@ -48,11 +48,11 @@ export function createPocketBaseSuperuser( throw new Error(result.replace(/^Error:\s*/, '')) } - CLILoggingService.success( + Logging.success( 'PocketBase initialized and superuser created successfully.' ) } catch (error) { - CLILoggingService.error( + Logging.error( `Failed to create superuser: ${ error instanceof Error ? error.message : 'Unknown error' }` diff --git a/tools/src/commands/db/functions/migration-generation/migration-file.ts b/tools/src/commands/db/functions/migration-generation/migration-file.ts index a401038f2..61a9ad62b 100644 --- a/tools/src/commands/db/functions/migration-generation/migration-file.ts +++ b/tools/src/commands/db/functions/migration-generation/migration-file.ts @@ -4,7 +4,7 @@ import fs from 'fs' import prettier from 'prettier' import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { PRETTIER_OPTIONS } from '../../utils' import { @@ -84,7 +84,7 @@ export async function createSkeletonMigration( throw new Error(`Skeleton migration failed: ${result.error}`) } - CLILoggingService.debug( + Logging.debug( `Created skeleton migration for ${chalk.bold.blue(moduleName)}` ) @@ -92,7 +92,7 @@ export async function createSkeletonMigration( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - CLILoggingService.error( + Logging.error( `Failed to create skeleton migration for ${chalk.bold.blue(moduleName)}: ${errorMessage}` ) @@ -121,7 +121,7 @@ export async function createStructureMigration( throw new Error(`Structure migration failed: ${result.error}`) } - CLILoggingService.debug( + Logging.debug( `Created structure migration for ${chalk.bold.blue(moduleName)}` ) @@ -129,7 +129,7 @@ export async function createStructureMigration( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - CLILoggingService.error( + Logging.error( `Failed to create structure migration for ${chalk.bold.blue(moduleName)}: ${errorMessage}` ) @@ -141,13 +141,13 @@ export async function createStructureMigration( * Runs migrate up to apply pending migrations */ export function runMigrateUp(): void { - CLILoggingService.debug('Applying pending migrations...') + Logging.debug('Applying pending migrations...') execSync(`${PB_BINARY_PATH} migrate up ${PB_KWARGS.join(' ')}`, { stdio: ['pipe', 'pipe', 'pipe'] }) - CLILoggingService.debug('Migrations applied successfully') + Logging.debug('Migrations applied successfully') } /** @@ -174,7 +174,7 @@ export async function createViewQueryMigration( throw new Error(`View query migration failed: ${result.error}`) } - CLILoggingService.debug( + Logging.debug( `Created view query migration for ${chalk.bold.blue(moduleName)}` ) @@ -182,7 +182,7 @@ export async function createViewQueryMigration( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - CLILoggingService.error( + Logging.error( `Failed to create view query migration for ${chalk.bold.blue(moduleName)}: ${errorMessage}` ) diff --git a/tools/src/commands/db/functions/schema-generation/content-generator.ts b/tools/src/commands/db/functions/schema-generation/content-generator.ts index 65b8daa4d..8b2367d00 100644 --- a/tools/src/commands/db/functions/schema-generation/content-generator.ts +++ b/tools/src/commands/db/functions/schema-generation/content-generator.ts @@ -2,8 +2,8 @@ import chalk from 'chalk' import _ from 'lodash' import path from 'path' -import { parseCollectionName } from '@/commands/modules/functions/registry/namespace-utils' -import CLILoggingService from '@/utils/logging' +import { parseCollectionName } from '@/commands/modules/functions/registry/namespaceUtils' +import Logging from '@/utils/logging' import { generateCollectionSchema, stripCollectionIds } from './field-converter' @@ -25,7 +25,7 @@ export function generateModuleSchemaContent( const fields = collection.fields as Array> - CLILoggingService.debug( + Logging.debug( `Processing collection ${chalk.bold(collectionName)} with ${fields.length} fields` ) @@ -48,7 +48,7 @@ export function generateModuleSchemaContent( raw: ${JSON.stringify(cleanedCollection, null, 2)} },`) - CLILoggingService.info( + Logging.info( `Generated schema for collection ${chalk.bold(collectionName)}` ) } diff --git a/tools/src/commands/db/functions/schema-generation/field-converter.ts b/tools/src/commands/db/functions/schema-generation/field-converter.ts index adfdce4da..f62d6e7ca 100644 --- a/tools/src/commands/db/functions/schema-generation/field-converter.ts +++ b/tools/src/commands/db/functions/schema-generation/field-converter.ts @@ -1,4 +1,4 @@ -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { FIELD_TYPE_MAPPING } from '../../utils' import type { PocketBaseField } from '../../utils/constants' @@ -14,7 +14,7 @@ export function convertFieldToZodSchema(field: PocketBaseField): string | null { const converter = FIELD_TYPE_MAPPING[field.type] if (!converter) { - CLILoggingService.warn( + Logging.warn( `Unknown field type '${field.type}' for field '${field.name}'. Skipping.` ) diff --git a/tools/src/commands/db/functions/schema-generation/module-mapper.ts b/tools/src/commands/db/functions/schema-generation/module-mapper.ts index 9a0896f1e..24b363793 100644 --- a/tools/src/commands/db/functions/schema-generation/module-mapper.ts +++ b/tools/src/commands/db/functions/schema-generation/module-mapper.ts @@ -4,8 +4,8 @@ import _ from 'lodash' import { parseCollectionName, parsePackageName -} from '@/commands/modules/functions/registry/namespace-utils' -import CLILoggingService from '@/utils/logging' +} from '@/commands/modules/functions/registry/namespaceUtils' +import Logging from '@/utils/logging' /** * Builds mapping of modules to their collections @@ -28,7 +28,7 @@ export async function buildModuleCollectionsMap( .flat() .map(entry => entry.split('/').slice(0, -1).join('/')) } catch (error) { - CLILoggingService.error(`Failed to read modules directory: ${error}`) + Logging.error(`Failed to read modules directory: ${error}`) process.exit(1) } @@ -73,7 +73,7 @@ export async function buildModuleCollectionsMap( ) if (foundModulePath.length > 0) { - CLILoggingService.debug( + Logging.debug( `Inferred module path for collection '${collectionName}': ${foundModulePath[0]}` ) @@ -87,9 +87,7 @@ export async function buildModuleCollectionsMap( continue } - CLILoggingService.warn( - `Collection '${collectionName}' has no corresponding module` - ) + Logging.warn(`Collection '${collectionName}' has no corresponding module`) continue } @@ -111,7 +109,7 @@ export async function buildModuleCollectionsMap( const moduleCount = Object.keys(moduleCollectionsMap).length - CLILoggingService.info( + Logging.info( `Found ${totalCollections} collections across ${moduleCount} modules` ) diff --git a/tools/src/commands/db/functions/schema-generation/schema-processor.ts b/tools/src/commands/db/functions/schema-generation/schema-processor.ts index 66c1d6176..3127f35a7 100644 --- a/tools/src/commands/db/functions/schema-generation/schema-processor.ts +++ b/tools/src/commands/db/functions/schema-generation/schema-processor.ts @@ -1,8 +1,8 @@ import chalk from 'chalk' import path from 'path' -import { parseCollectionName } from '@/commands/modules/functions/registry/namespace-utils' -import CLILoggingService from '@/utils/logging' +import { parseCollectionName } from '@/commands/modules/functions/registry/namespaceUtils' +import Logging from '@/utils/logging' import { writeFormattedFile } from '../../utils' import { generateModuleSchemaContent } from './content-generator' @@ -27,9 +27,7 @@ export async function processSchemaGeneration( : moduleCollectionsMap if (targetModule && Object.keys(filteredModuleCollectionsMap).length === 0) { - CLILoggingService.error( - `Module "${targetModule}" not found or has no collections` - ) + Logging.error(`Module "${targetModule}" not found or has no collections`) process.exit(1) } @@ -43,7 +41,7 @@ export async function processSchemaGeneration( const [moduleDirPath, moduleDirName] = moduleDir.split('|') if (!collections.length) { - CLILoggingService.warn( + Logging.warn( `No collections found for module ${chalk.bold(moduleDirName)}` ) continue @@ -73,7 +71,7 @@ export async function processSchemaGeneration( await writeFormattedFile(moduleSchemaPath, moduleSchemaContent) - CLILoggingService.debug( + Logging.debug( `Created schema file for module ${chalk.bold(moduleDirName)} at ${chalk.bold(`lib/${moduleDirName}/schema.ts`)}` ) } diff --git a/tools/src/commands/db/handlers/generateMigrationsHandler.ts b/tools/src/commands/db/handlers/generateMigrationsHandler.ts index 451acfc62..50de4edab 100644 --- a/tools/src/commands/db/handlers/generateMigrationsHandler.ts +++ b/tools/src/commands/db/handlers/generateMigrationsHandler.ts @@ -1,7 +1,7 @@ import chalk from 'chalk' import { isDockerMode } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { checkRunningPBInstances } from '@/utils/pocketbase' import { @@ -51,7 +51,7 @@ export async function generateMigrationsHandler( targetModule?: string ): Promise { try { - CLILoggingService.step('Starting database migration generation') + Logging.step('Starting database migration generation') // Skip running instances check in Docker mode (we control the container) if (!isDockerMode()) { @@ -62,7 +62,7 @@ export async function generateMigrationsHandler( const schemaFiles = getSchemaFiles(targetModule) - CLILoggingService.debug( + Logging.debug( targetModule ? `Processing module: ${chalk.bold.blue(targetModule)}` : `Found ${chalk.bold.blue(schemaFiles.length)} schema files.` @@ -73,18 +73,16 @@ export async function generateMigrationsHandler( // Build ID-to-name map from all schemas for relation resolution const idToNameMap = buildIdToNameMap(importedSchemas) - CLILoggingService.debug( - `Built ID-to-name map with ${idToNameMap.size} collections` - ) + Logging.debug(`Built ID-to-name map with ${idToNameMap.size} collections`) // Phase 1: Generate all skeleton migrations - CLILoggingService.debug('Phase 1: Creating skeleton migrations...') + Logging.debug('Phase 1: Creating skeleton migrations...') for (const { moduleName, schema } of importedSchemas) { const result = await createSkeletonMigration(moduleName, schema) if (!result.success) { - CLILoggingService.actionableError( + Logging.actionableError( `Skeleton migration failed for module ${moduleName}`, 'Check the module schema definition for syntax errors' ) @@ -92,16 +90,14 @@ export async function generateMigrationsHandler( } } - CLILoggingService.debug( - `Created ${importedSchemas.length} skeleton migrations` - ) + Logging.debug(`Created ${importedSchemas.length} skeleton migrations`) // Phase 2: Run migrate up to apply skeleton migrations - CLILoggingService.debug('Phase 2: Applying skeleton migrations...') + Logging.debug('Phase 2: Applying skeleton migrations...') runMigrateUp() // Phase 3: Generate all structure migrations - CLILoggingService.debug('Phase 3: Creating structure migrations...') + Logging.debug('Phase 3: Creating structure migrations...') for (const { moduleName, schema } of importedSchemas) { const result = await createStructureMigration( @@ -111,7 +107,7 @@ export async function generateMigrationsHandler( ) if (!result.success) { - CLILoggingService.actionableError( + Logging.actionableError( `Structure migration failed for module ${moduleName}`, 'Check the module schema definition for syntax errors' ) @@ -119,16 +115,14 @@ export async function generateMigrationsHandler( } } - CLILoggingService.debug( - `Created ${importedSchemas.length} structure migrations` - ) + Logging.debug(`Created ${importedSchemas.length} structure migrations`) // Phase 4: Run migrate up to apply structure migrations - CLILoggingService.debug('Phase 4: Applying structure migrations...') + Logging.debug('Phase 4: Applying structure migrations...') runMigrateUp() // Phase 5: Generate view query migrations (for modules with view collections) - CLILoggingService.debug('Phase 5: Creating view query migrations...') + Logging.debug('Phase 5: Creating view query migrations...') let viewMigrationCount = 0 @@ -136,7 +130,7 @@ export async function generateMigrationsHandler( const result = await createViewQueryMigration(moduleName, schema) if (!result.success) { - CLILoggingService.actionableError( + Logging.actionableError( `View query migration failed for module ${moduleName}`, 'Check the view query definition for syntax errors' ) @@ -149,17 +143,13 @@ export async function generateMigrationsHandler( } if (viewMigrationCount > 0) { - CLILoggingService.debug( - `Created ${viewMigrationCount} view query migrations` - ) + Logging.debug(`Created ${viewMigrationCount} view query migrations`) // Phase 6: Apply view query migrations - CLILoggingService.debug('Phase 6: Applying view query migrations...') + Logging.debug('Phase 6: Applying view query migrations...') runMigrateUp() } else { - CLILoggingService.debug( - 'No view collections found, skipping view migrations' - ) + Logging.debug('No view collections found, skipping view migrations') } // Summary @@ -167,13 +157,13 @@ export async function generateMigrationsHandler( ? `Database migrations applied for ${chalk.bold.blue(targetModule)}` : 'Database migrations applied for all modules' - CLILoggingService.success(message) + Logging.success(message) } catch (error) { - CLILoggingService.actionableError( + Logging.actionableError( 'Migration script failed', 'Check the schema definitions and PocketBase configuration' ) - CLILoggingService.debug( + Logging.debug( `Error details: ${error instanceof Error ? error.message : String(error)}` ) process.exit(1) diff --git a/tools/src/commands/db/handlers/generateSchemasHandler.ts b/tools/src/commands/db/handlers/generateSchemasHandler.ts index d66f386aa..7b353aca7 100644 --- a/tools/src/commands/db/handlers/generateSchemasHandler.ts +++ b/tools/src/commands/db/handlers/generateSchemasHandler.ts @@ -1,7 +1,7 @@ import chalk from 'chalk' import { isDockerMode } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import getPBInstance from '@/utils/pocketbase' import { buildModuleCollectionsMap } from '../functions/schema-generation/module-mapper' @@ -14,11 +14,11 @@ export async function generateSchemaHandler( targetModule?: string ): Promise { try { - CLILoggingService.info('Starting schema generation process...') + Logging.info('Starting schema generation process...') const { pb, killPB } = await getPBInstance(!isDockerMode()) - CLILoggingService.debug('Fetching collections from PocketBase...') + Logging.debug('Fetching collections from PocketBase...') const allCollections = await pb.collections.getFullList() @@ -26,9 +26,7 @@ export async function generateSchemaHandler( collection => !collection.system ) - CLILoggingService.info( - `Found ${userCollections.length} user-defined collections` - ) + Logging.info(`Found ${userCollections.length} user-defined collections`) // Build ID-to-name map for all collections (including system) // This allows relation fields to reference collections by name instead of ID @@ -52,7 +50,7 @@ export async function generateSchemaHandler( const moduleCount = Object.keys(moduleSchemas).length - CLILoggingService.info( + Logging.info( targetModule ? `Schema generation completed for module ${chalk.bold.blue(targetModule)}!` : `Schema generation completed! Created ${moduleCount} module schema files.` @@ -60,7 +58,7 @@ export async function generateSchemaHandler( killPB?.() } catch (error) { - CLILoggingService.error(`Schema generation failed: ${error}`) + Logging.error(`Schema generation failed: ${error}`) process.exit(1) } } diff --git a/tools/src/commands/db/handlers/initializeDatabaseHandler.ts b/tools/src/commands/db/handlers/initializeDatabaseHandler.ts index 70130d914..0769830a7 100644 --- a/tools/src/commands/db/handlers/initializeDatabaseHandler.ts +++ b/tools/src/commands/db/handlers/initializeDatabaseHandler.ts @@ -1,5 +1,5 @@ import { getEnvVars } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { checkRunningPBInstances } from '@/utils/pocketbase' import { downloadPocketBaseBinary } from '../functions/database-initialization/download-pocketbase' @@ -23,7 +23,7 @@ export async function initializeDatabaseHandler() { createPocketBaseSuperuser(PB_EMAIL, PB_PASSWORD) runDatabaseMigrations() - CLILoggingService.success( + Logging.success( 'Setup process complete. You can now start the PocketBase server with `bun forge dev db`' ) } diff --git a/tools/src/commands/db/utils/file-utils.ts b/tools/src/commands/db/utils/file-utils.ts index a159813a1..04a451e0b 100644 --- a/tools/src/commands/db/utils/file-utils.ts +++ b/tools/src/commands/db/utils/file-utils.ts @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import prettier from 'prettier' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { PRETTIER_OPTIONS } from './constants' @@ -18,7 +18,7 @@ export async function writeFormattedFile( fs.writeFileSync(filePath, formattedContent) } catch (error) { - CLILoggingService.error(`Failed to write file ${filePath}: ${error}`) + Logging.error(`Failed to write file ${filePath}: ${error}`) throw error } } @@ -55,7 +55,7 @@ export function getSchemaFiles(targetModule?: string): string[] { .map((schemaPath: string) => getModuleName(schemaPath)) .join(', ') - CLILoggingService.error( + Logging.error( `Module "${targetModule}" not found. Available modules: ${availableModules}` ) process.exit(1) diff --git a/tools/src/commands/db/utils/index.ts b/tools/src/commands/db/utils/index.ts index a69dad151..d20211ae8 100644 --- a/tools/src/commands/db/utils/index.ts +++ b/tools/src/commands/db/utils/index.ts @@ -5,8 +5,6 @@ export { importSchemaModules } from './file-utils' -export { cleanupOldMigrations } from './pocketbase-utils' - export { FIELD_TYPE_MAPPING, PRETTIER_OPTIONS, diff --git a/tools/src/commands/db/utils/pocketbase-utils.ts b/tools/src/commands/db/utils/pocketbase-utils.ts index 61e2a56e6..2a6bbf778 100644 --- a/tools/src/commands/db/utils/pocketbase-utils.ts +++ b/tools/src/commands/db/utils/pocketbase-utils.ts @@ -4,7 +4,7 @@ import fs from 'fs' import path from 'path' import { PB_BINARY_PATH, PB_KWARGS, PB_MIGRATIONS_DIR } from '@/constants/db' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' /** * Cleans up old migrations @@ -13,7 +13,7 @@ export async function cleanupOldMigrations( targetModule?: string ): Promise { try { - CLILoggingService.debug('Cleaning up old migrations directory...') + Logging.debug('Cleaning up old migrations directory...') if (!targetModule) { fs.rmSync(PB_MIGRATIONS_DIR, { recursive: true, force: true }) @@ -39,7 +39,7 @@ export async function cleanupOldMigrations( } ) - CLILoggingService.debug( + Logging.debug( `Removed ${chalk.bold.blue( migrationFiles.filter(file => file.endsWith(`_${targetModule}.js`)) .length diff --git a/tools/src/commands/dev/config/commands.ts b/tools/src/commands/dev/config/commands.ts index f6759adc5..5d3938610 100644 --- a/tools/src/commands/dev/config/commands.ts +++ b/tools/src/commands/dev/config/commands.ts @@ -8,7 +8,7 @@ import { executeCommand, killExistingProcess } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' /** * Service command configurations @@ -29,7 +29,7 @@ export const SERVICE_COMMANDS: Record = { } if (checkPortInUse(8090)) { - CLILoggingService.actionableError( + Logging.actionableError( 'No Pocketbase instance found running, but port 8090 is already in use.', 'Please free up the port. Are you using the port for another application? (e.g., port forwarding, etc.)' ) @@ -37,7 +37,7 @@ export const SERVICE_COMMANDS: Record = { } if (!fs.existsSync(PB_BINARY_PATH)) { - CLILoggingService.actionableError( + Logging.actionableError( `PocketBase binary does not exist: ${PB_BINARY_PATH}`, `Please run "${chalk.bold.blue('bun forge db init')}" to initialize the database` ) @@ -62,7 +62,7 @@ export const SERVICE_COMMANDS: Record = { const PORT = process.env.PORT || '3636' if (checkPortInUse(Number(PORT))) { - CLILoggingService.actionableError( + Logging.actionableError( `Port ${PORT} is already in use.`, 'Please free up the port or set a different PORT environment variable.' ) diff --git a/tools/src/commands/dev/functions/startServices.ts b/tools/src/commands/dev/functions/startServices.ts index dab78f66b..b9c5763e6 100644 --- a/tools/src/commands/dev/functions/startServices.ts +++ b/tools/src/commands/dev/functions/startServices.ts @@ -3,7 +3,7 @@ import concurrently from 'concurrently' import { PROJECTS } from '@/commands/project/constants/projects' import { TOOLS_ALLOWED } from '@/constants/constants' import { executeCommand, getEnvVars } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { SERVICE_COMMANDS } from '../config/commands' import getConcurrentServices from './getConcurrentServices' @@ -51,9 +51,7 @@ export async function startSingleService( * Starts all development services concurrently */ export async function startAllServices(): Promise { - CLILoggingService.progress( - 'Starting all services: database, server, and client' - ) + Logging.progress('Starting all services: database, server, and client') try { const concurrentServices = await getConcurrentServices() @@ -65,11 +63,11 @@ export async function startAllServices(): Promise { prefixColors: ['cyan', 'green', 'magenta'] }) } catch (error) { - CLILoggingService.actionableError( + Logging.actionableError( 'Failed to start all services', 'Ensure PocketBase is properly configured and all dependencies are installed' ) - CLILoggingService.debug(`Error details: ${error}`) + Logging.debug(`Error details: ${error}`) process.exit(1) } } diff --git a/tools/src/commands/dev/functions/validateServices.ts b/tools/src/commands/dev/functions/validateServices.ts index 4f6a4c312..aebbba80d 100644 --- a/tools/src/commands/dev/functions/validateServices.ts +++ b/tools/src/commands/dev/functions/validateServices.ts @@ -1,4 +1,4 @@ -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import SERVICES from '../constants/services' @@ -7,7 +7,7 @@ import SERVICES from '../constants/services' */ export default function validateService(service: string): void { if (service && !SERVICES.includes(service)) { - CLILoggingService.options(`Invalid service: "${service}"`, [...SERVICES]) + Logging.options(`Invalid service: "${service}"`, [...SERVICES]) process.exit(1) } } diff --git a/tools/src/commands/dev/handlers/devHandler.ts b/tools/src/commands/dev/handlers/devHandler.ts index 0e6649bd3..caae81932 100644 --- a/tools/src/commands/dev/handlers/devHandler.ts +++ b/tools/src/commands/dev/handlers/devHandler.ts @@ -1,4 +1,4 @@ -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { startAllServices, @@ -15,20 +15,20 @@ export function devHandler(service: string, extraArgs: string[] = []): void { return } - CLILoggingService.progress(`Starting ${service} service`) + Logging.progress(`Starting ${service} service`) if (extraArgs.length > 0) { - CLILoggingService.debug(`Extra arguments: ${extraArgs.join(' ')}`) + Logging.debug(`Extra arguments: ${extraArgs.join(' ')}`) } try { startSingleService(service, extraArgs) } catch (error) { - CLILoggingService.actionableError( + Logging.actionableError( `Failed to start ${service} service`, 'Check if all required dependencies are installed and environment variables are set' ) - CLILoggingService.debug(`Error details: ${error}`) + Logging.debug(`Error details: ${error}`) process.exit(1) } } diff --git a/tools/src/commands/locales/functions/ensureLocaleNotInUse.ts b/tools/src/commands/locales/functions/ensureLocaleNotInUse.ts index 115f59771..667fab538 100644 --- a/tools/src/commands/locales/functions/ensureLocaleNotInUse.ts +++ b/tools/src/commands/locales/functions/ensureLocaleNotInUse.ts @@ -1,8 +1,8 @@ -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import getPBInstance from '@/utils/pocketbase' async function ensureLocaleNotInUse(shortName: string) { - CLILoggingService.debug('Checking if locale is in use...') + Logging.debug('Checking if locale is in use...') const { pb, killPB } = await getPBInstance() @@ -10,7 +10,7 @@ async function ensureLocaleNotInUse(shortName: string) { const user = await pb.collection('users').getFirstListItem("id != ''") if (user.language === shortName) { - CLILoggingService.actionableError( + Logging.actionableError( `Cannot uninstall locale "${shortName}"`, 'This language is currently selected. Change your language first.' ) diff --git a/tools/src/commands/locales/functions/getPackagesToCheck.ts b/tools/src/commands/locales/functions/getPackagesToCheck.ts index 2ced3d8fd..3b7b37195 100644 --- a/tools/src/commands/locales/functions/getPackagesToCheck.ts +++ b/tools/src/commands/locales/functions/getPackagesToCheck.ts @@ -1,4 +1,4 @@ -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { getInstalledLocalesWithMeta } from './getInstalledLocales' import { normalizeLocalePackageName } from './getLocalesMeta' @@ -7,7 +7,7 @@ function getPackagesToCheck(langCode?: string) { const localePackages = getInstalledLocalesWithMeta() if (!localePackages.length) { - CLILoggingService.info('No locales installed') + Logging.info('No locales installed') process.exit(0) } @@ -19,7 +19,7 @@ function getPackagesToCheck(langCode?: string) { : localePackages if (!packagesToCheck?.length) { - CLILoggingService.actionableError( + Logging.actionableError( `Locale "${langCode}" is not installed`, 'Run "bun forge locales list" to see installed locales' ) diff --git a/tools/src/commands/locales/functions/getUpgrades.ts b/tools/src/commands/locales/functions/getUpgrades.ts index 81856744e..e3f8afc66 100644 --- a/tools/src/commands/locales/functions/getUpgrades.ts +++ b/tools/src/commands/locales/functions/getUpgrades.ts @@ -1,4 +1,4 @@ -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { getRegistryUrl } from '@/utils/registry' interface LocaleUpgrade { @@ -47,15 +47,13 @@ async function getUpgrades( } if (!upgrades.length) { - CLILoggingService.success('All locales are up to date!') + Logging.success('All locales are up to date!') process.exit(0) } - CLILoggingService.info('Available upgrades:') - upgrades.forEach(u => - CLILoggingService.info(` ${u.name}: ${u.current} → ${u.latest}`) - ) + Logging.info('Available upgrades:') + upgrades.forEach(u => Logging.info(` ${u.name}: ${u.current} → ${u.latest}`)) return upgrades } diff --git a/tools/src/commands/locales/functions/installAndMoveLocales.ts b/tools/src/commands/locales/functions/installAndMoveLocales.ts index c3c21e119..ddeb95487 100644 --- a/tools/src/commands/locales/functions/installAndMoveLocales.ts +++ b/tools/src/commands/locales/functions/installAndMoveLocales.ts @@ -1,7 +1,7 @@ import fs from 'fs' import { executeCommand } from '@/utils/helpers' -import { addWorkspaceDependency } from '@/utils/package' +import { addDependency } from '@/utils/packageJson' function installAndMoveLocales(fullPackageName: string, targetDir: string) { if (fs.existsSync(targetDir)) { @@ -21,7 +21,7 @@ function installAndMoveLocales(fullPackageName: string, targetDir: string) { fs.cpSync(installedPath, targetDir, { recursive: true }) - addWorkspaceDependency(fullPackageName) + addDependency(fullPackageName) fs.rmSync(installedPath, { recursive: true, force: true }) executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' }) diff --git a/tools/src/commands/locales/functions/setFirstLangInDB.ts b/tools/src/commands/locales/functions/setFirstLangInDB.ts index 24ede5e29..07ca751cc 100644 --- a/tools/src/commands/locales/functions/setFirstLangInDB.ts +++ b/tools/src/commands/locales/functions/setFirstLangInDB.ts @@ -1,4 +1,4 @@ -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import getPBInstance from '@/utils/pocketbase' import { getInstalledLocales } from './getInstalledLocales' @@ -7,7 +7,7 @@ async function setFirstLangInDB(shortName: string) { const installedLocales = getInstalledLocales() if (installedLocales.length === 1) { - CLILoggingService.step('First language pack - setting as default for user') + Logging.step('First language pack - setting as default for user') const { pb, killPB } = await getPBInstance() @@ -15,7 +15,7 @@ async function setFirstLangInDB(shortName: string) { await pb.collection('users').update(user.id, { language: shortName }) - CLILoggingService.info(`Set ${shortName} as default language`) + Logging.info(`Set ${shortName} as default language`) killPB?.() } } diff --git a/tools/src/commands/locales/functions/validateLocaleStructure.ts b/tools/src/commands/locales/functions/validateLocaleStructure.ts index 6f358fcef..91483a538 100644 --- a/tools/src/commands/locales/functions/validateLocaleStructure.ts +++ b/tools/src/commands/locales/functions/validateLocaleStructure.ts @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' interface LocalePackageJson { name?: string @@ -55,11 +55,11 @@ export function validateLocaleStructure(localePath: string) { } if (errors.length > 0) { - CLILoggingService.error('Locale validation failed:') - errors.forEach(err => CLILoggingService.error(` - ${err}`)) + Logging.error('Locale validation failed:') + errors.forEach(err => Logging.error(` - ${err}`)) process.exit(1) } - warnings.forEach(warn => CLILoggingService.warn(` - ${warn}`)) + warnings.forEach(warn => Logging.warn(` - ${warn}`)) } diff --git a/tools/src/commands/locales/handlers/install-locale.ts b/tools/src/commands/locales/handlers/install-locale.ts index 0bbcc9497..96c1d13f7 100644 --- a/tools/src/commands/locales/handlers/install-locale.ts +++ b/tools/src/commands/locales/handlers/install-locale.ts @@ -1,6 +1,6 @@ import fs from 'fs' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import getLocalesMeta from '../functions/getLocalesMeta' import installAndMoveLocales from '../functions/installAndMoveLocales' @@ -10,7 +10,7 @@ export async function installLocaleHandler(langCode: string): Promise { const { fullPackageName, shortName, targetDir } = getLocalesMeta(langCode) if (fs.existsSync(targetDir)) { - CLILoggingService.actionableError( + Logging.actionableError( `Locale already exists at locales/${shortName}`, `Remove it first with: bun forge locales uninstall ${shortName}` ) @@ -18,18 +18,16 @@ export async function installLocaleHandler(langCode: string): Promise { process.exit(1) } - CLILoggingService.progress('Fetching locale from registry...') + Logging.progress('Fetching locale from registry...') try { installAndMoveLocales(fullPackageName, targetDir) await setFirstLangInDB(shortName) - CLILoggingService.success( - `Locale ${fullPackageName} installed successfully!` - ) + Logging.success(`Locale ${fullPackageName} installed successfully!`) } catch (error) { - CLILoggingService.actionableError( + Logging.actionableError( `Failed to install ${fullPackageName}`, 'Make sure the locale exists in the registry' ) diff --git a/tools/src/commands/locales/handlers/list-locales.ts b/tools/src/commands/locales/handlers/list-locales.ts index b8e85dbd5..3f602d826 100644 --- a/tools/src/commands/locales/handlers/list-locales.ts +++ b/tools/src/commands/locales/handlers/list-locales.ts @@ -1,6 +1,6 @@ import chalk from 'chalk' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { getInstalledLocalesWithMeta } from '../functions/getInstalledLocales' @@ -8,15 +8,15 @@ export function listLocalesHandler(): void { const locales = getInstalledLocalesWithMeta() if (locales.length === 0) { - CLILoggingService.info('No language packs installed') - CLILoggingService.info( + Logging.info('No language packs installed') + Logging.info( 'Use "bun forge locales install " to install a language pack' ) return } - CLILoggingService.info(`Installed language packs (${locales.length}):`) + Logging.info(`Installed language packs (${locales.length}):`) for (const locale of locales.sort((a, b) => a.name.localeCompare(b.name))) { console.log( diff --git a/tools/src/commands/locales/handlers/publish-locale.ts b/tools/src/commands/locales/handlers/publish-locale.ts index 56dfb1545..ba253ec64 100644 --- a/tools/src/commands/locales/handlers/publish-locale.ts +++ b/tools/src/commands/locales/handlers/publish-locale.ts @@ -1,7 +1,7 @@ import fs from 'fs' import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { validateMaintainerAccess } from '../../../utils/github-cli' import { checkAuth, getRegistryUrl } from '../../../utils/registry' @@ -15,7 +15,7 @@ export async function publishLocaleHandler( const { targetDir, shortName } = getLocalesMeta(langCode) if (!fs.existsSync(targetDir)) { - CLILoggingService.actionableError( + Logging.actionableError( `Locale "${langCode}" not found in locales/`, 'Run "bun forge locales list" to see available locales' ) @@ -37,9 +37,9 @@ export async function publishLocaleHandler( stdio: 'inherit' }) - CLILoggingService.success(`Locale "${shortName}" published successfully!`) + Logging.success(`Locale "${shortName}" published successfully!`) } catch (error) { - CLILoggingService.actionableError( + Logging.actionableError( 'Failed to publish locale', 'Check if you are properly authenticated with the registry' ) diff --git a/tools/src/commands/locales/handlers/uninstall-locale.ts b/tools/src/commands/locales/handlers/uninstall-locale.ts index 6cd2191e0..00f5565e9 100644 --- a/tools/src/commands/locales/handlers/uninstall-locale.ts +++ b/tools/src/commands/locales/handlers/uninstall-locale.ts @@ -1,8 +1,8 @@ import fs from 'fs' import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' -import { findPackageName, removeWorkspaceDependency } from '@/utils/package' +import Logging from '@/utils/logging' +import { findPackageName, removeDependency } from '@/utils/packageJson' import ensureLocaleNotInUse from '../functions/ensureLocaleNotInUse' import getLocalesMeta from '../functions/getLocalesMeta' @@ -13,7 +13,7 @@ export async function uninstallLocaleHandler(langCode: string): Promise { const found = findPackageName(fullPackageName) if (!found) { - CLILoggingService.actionableError( + Logging.actionableError( `Locale "${shortName}" is not installed`, 'Run "bun forge locales list" to see installed locales' ) @@ -23,13 +23,13 @@ export async function uninstallLocaleHandler(langCode: string): Promise { await ensureLocaleNotInUse(shortName) - CLILoggingService.info(`Uninstalling locale ${fullPackageName}...`) + Logging.info(`Uninstalling locale ${fullPackageName}...`) fs.rmSync(targetDir, { recursive: true, force: true }) - removeWorkspaceDependency(fullPackageName) + removeDependency(fullPackageName) executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' }) - CLILoggingService.info(`Uninstalled locale ${fullPackageName}`) + Logging.info(`Uninstalled locale ${fullPackageName}`) } diff --git a/tools/src/commands/locales/handlers/upgrade-locale.ts b/tools/src/commands/locales/handlers/upgrade-locale.ts index 7ffed67de..7c04d3199 100644 --- a/tools/src/commands/locales/handlers/upgrade-locale.ts +++ b/tools/src/commands/locales/handlers/upgrade-locale.ts @@ -1,5 +1,5 @@ import { confirmAction, executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { checkAuth } from '../../../utils/registry' import getLocalesMeta from '../functions/getLocalesMeta' @@ -25,15 +25,15 @@ export async function upgradeLocaleHandler(langCode?: string): Promise { getLocalesMeta(upgrade.name).targetDir ) - CLILoggingService.success(`Upgraded ${upgrade.name} to ${upgrade.latest}`) + Logging.success(`Upgraded ${upgrade.name} to ${upgrade.latest}`) upgradedCount++ } catch (error) { - CLILoggingService.error(`Failed to upgrade ${upgrade.name}: ${error}`) + Logging.error(`Failed to upgrade ${upgrade.name}: ${error}`) } } if (upgradedCount > 0) { executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' }) - CLILoggingService.success(`Upgraded ${upgradedCount} locale(s)`) + Logging.success(`Upgraded ${upgradedCount} locale(s)`) } } diff --git a/tools/src/commands/locales/index.ts b/tools/src/commands/locales/index.ts index 5fd7db1a4..daeb55f67 100644 --- a/tools/src/commands/locales/index.ts +++ b/tools/src/commands/locales/index.ts @@ -1,6 +1,5 @@ import type { Command } from 'commander' -import { loginModuleHandler } from '../modules/handlers/login-module' import { installLocaleHandler } from './handlers/install-locale' import { listLocalesHandler } from './handlers/list-locales' import { publishLocaleHandler } from './handlers/publish-locale' @@ -12,11 +11,6 @@ export default function setup(program: Command): void { .command('locales') .description('Manage LifeForge language packs') - command - .command('login') - .description('Login to the locale registry') - .action(loginModuleHandler) - command .command('list') .description('List all installed language packs') diff --git a/tools/src/commands/modules/functions/getFsMetadata.ts b/tools/src/commands/modules/functions/getFsMetadata.ts new file mode 100644 index 000000000..f1cef4081 --- /dev/null +++ b/tools/src/commands/modules/functions/getFsMetadata.ts @@ -0,0 +1,24 @@ +import fs from 'fs' +import path from 'path' + +export default function getFsMetadata(moduleName: string) { + const fullName = moduleName.startsWith('@lifeforge/') + ? moduleName + : `@lifeforge/${moduleName}` + + const shortName = fullName.replace('@lifeforge/', '') + + const appsDir = path.join(process.cwd(), 'apps') + + if (!fs.existsSync(appsDir)) { + fs.mkdirSync(appsDir, { recursive: true }) + } + + const targetDir = path.join(appsDir, shortName) + + return { + fullName, + shortName, + targetDir + } +} diff --git a/tools/src/commands/modules/functions/git-status.ts b/tools/src/commands/modules/functions/git-status.ts deleted file mode 100644 index ad306f7b6..000000000 --- a/tools/src/commands/modules/functions/git-status.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { executeCommand, executeCommandWithOutput } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -export interface CommitInfo { - hash: string - message: string -} - -export function checkGitCleanliness(moduleName: string): void { - const modulePath = `apps/${moduleName}` - - try { - const superprojectDirResult = executeCommand( - 'git rev-parse --show-superproject-working-tree', - { - cwd: modulePath, - stdio: 'pipe' - } - ) - - if (superprojectDirResult.trim() !== '') { - CLILoggingService.actionableError( - `Module ${moduleName} is already published as a Git submodule.`, - `To republish, please remove the submodule first using 'git submodule deinit -f ${modulePath}' and 'git rm -f ${modulePath}', then try publishing again.` - ) - process.exit(1) - } - - const gitStatusOutput = executeCommand('git status --porcelain', { - cwd: modulePath, - stdio: 'pipe' - }) - - if (gitStatusOutput.trim() !== '') { - CLILoggingService.actionableError( - `Module ${moduleName} has uncommitted changes.`, - 'Please commit or stash your changes before publishing the module.' - ) - process.exit(1) - } - - const remoteCheck = executeCommand('git remote', { - cwd: modulePath, - stdio: 'pipe' - }) - - if (remoteCheck.trim().includes('origin')) { - CLILoggingService.actionableError( - `Module ${moduleName} already has a remote named 'origin'.`, - 'Please remove the existing remote before publishing the module.' - ) - process.exit(1) - } - - CLILoggingService.info( - `Git status for module ${moduleName} is clean and ready for publishing.` - ) - } catch (error) { - CLILoggingService.actionableError( - `Failed to check git status for module ${moduleName}: ${error instanceof Error ? error.message : String(error)}`, - 'Ensure you have git installed and are in a git repository.' - ) - process.exit(1) - } -} - -export async function checkForUpdates( - modulePath: string -): Promise { - try { - executeCommandWithOutput(`cd apps/${modulePath} && git fetch origin main`, { - exitOnError: false - }) - - const output = executeCommandWithOutput( - `cd apps/${modulePath} && git log --oneline HEAD..origin/main`, - { exitOnError: false } - ) - - if (!output.trim()) { - return [] - } - - const commits = output - .split('\n') - .filter(line => line.trim()) - .map(line => { - const [hash, ...messageParts] = line.split(' ') - - return { - hash, - message: messageParts.join(' ') - } - }) - - return commits - } catch (error) { - CLILoggingService.warn( - `Failed to check for updates in ${modulePath}: ${error}` - ) - - return [] - } -} diff --git a/tools/src/commands/modules/functions/index.ts b/tools/src/commands/modules/functions/index.ts deleted file mode 100644 index b4ecb4eba..000000000 --- a/tools/src/commands/modules/functions/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Git operations -export * from './git-status' -export * from '../../../utils/github-cli' - -// Module operations -export * from './install-dependencies' -export * from './module-migrations' - -// Interactive prompts -export * from './prompts' - -// Template operations -export * from './templates' - -// Registry generation -export * from './registry' diff --git a/tools/src/commands/modules/functions/install-dependencies.ts b/tools/src/commands/modules/functions/install-dependencies.ts deleted file mode 100644 index 15612b87e..000000000 --- a/tools/src/commands/modules/functions/install-dependencies.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -export function installDependencies(cwd?: string): void { - CLILoggingService.progress('Installing dependencies') - - try { - executeCommand('bun install --linker isolated', { - cwd: cwd ?? process.cwd(), - stdio: ['ignore', 'ignore', 'ignore'], - exitOnError: false - }) - CLILoggingService.success('Dependencies installed successfully') - } catch (error) { - CLILoggingService.actionableError( - 'Failed to install dependencies', - 'Ensure Bun is installed and you have internet connectivity' - ) - throw error - } -} diff --git a/tools/src/commands/modules/functions/installModulePackage.ts b/tools/src/commands/modules/functions/installModulePackage.ts new file mode 100644 index 000000000..2b426fb1a --- /dev/null +++ b/tools/src/commands/modules/functions/installModulePackage.ts @@ -0,0 +1,31 @@ +import fs from 'fs' +import path from 'path' + +import { executeCommand } from '@/utils/helpers' +import Logging from '@/utils/logging' + +/** + * Installs a module package from the registry and copies it to the target directory. + * + * @param fullName The full name of the module + * @param targetDir The target directory to copy the module to + */ +export default function installModulePackage( + fullName: string, + targetDir: string +) { + executeCommand(`bun add ${fullName}@latest`, { + cwd: process.cwd(), + stdio: 'inherit' + }) + + const installedPath = path.join(process.cwd(), 'node_modules', fullName) + + if (!fs.existsSync(installedPath)) { + Logging.error(`Failed to install ${fullName}`) + + process.exit(1) + } + + fs.cpSync(installedPath, targetDir, { recursive: true }) +} diff --git a/tools/src/commands/modules/functions/linkModuleToWorkspace.ts b/tools/src/commands/modules/functions/linkModuleToWorkspace.ts new file mode 100644 index 000000000..99d47fb2d --- /dev/null +++ b/tools/src/commands/modules/functions/linkModuleToWorkspace.ts @@ -0,0 +1,27 @@ +import fs from 'fs' +import path from 'path' + +import { executeCommand } from '@/utils/helpers' +import { addDependency } from '@/utils/packageJson' + +/** + * Links a module to the workspace. It replace the module's version in the root package.json + * with "workspace:*", and remove the copy of the module in node_modules, then run bun install + * to symlink the module. + * + * @param fullName The full name of the module + */ +export default function linkModuleToWorkspace(fullName: string) { + addDependency(fullName) + + const nodeModulesPath = path.join(process.cwd(), 'node_modules', fullName) + + if (fs.existsSync(nodeModulesPath)) { + fs.rmSync(nodeModulesPath, { recursive: true, force: true }) + } + + executeCommand('bun install', { + cwd: process.cwd(), + stdio: 'inherit' + }) +} diff --git a/tools/src/commands/modules/functions/listModules.ts b/tools/src/commands/modules/functions/listModules.ts new file mode 100644 index 000000000..64d5590e4 --- /dev/null +++ b/tools/src/commands/modules/functions/listModules.ts @@ -0,0 +1,55 @@ +import fs from 'fs' +import path from 'path' + +import Logging from '@/utils/logging' +import { readRootPackageJson } from '@/utils/packageJson' + +import getFsMetadata from './getFsMetadata' + +interface ModuleBasicInfo { + name: string + displayName: string + version: string +} + +/** + * Lists all installed modules with their basic info. + * + * Parses the root package.json and lists all dependencies that start with `@lifeforge/` (except `@lifeforge/lang-*`), + * then reads the package.json of each module to get the basic info. + * + * @param exitIfNoModule - Whether to exit the process if no modules are found + * @returns Record of module name to module basic info + */ +export default function listModules( + exitIfNoModule = false +): Record { + const rootPackageJson = readRootPackageJson() + + const allModules = Object.keys(rootPackageJson.dependencies ?? {}) + .filter(dep => dep.startsWith('@lifeforge/')) + .filter(dep => !dep.replace('@lifeforge/', '').startsWith('lang-')) + + const modules: Record = {} + + for (const module of allModules) { + const { targetDir } = getFsMetadata(module) + + const packageJson = JSON.parse( + fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8') + ) + + modules[module] = { + name: packageJson.name, + displayName: packageJson.displayName, + version: packageJson.version + } + } + + if (exitIfNoModule && Object.keys(modules).length === 0) { + Logging.info('No @lifeforge/* modules found. Exiting...') + process.exit(0) + } + + return modules +} diff --git a/tools/src/commands/modules/functions/module-migrations.ts b/tools/src/commands/modules/functions/module-migrations.ts deleted file mode 100644 index 79d69a054..000000000 --- a/tools/src/commands/modules/functions/module-migrations.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { generateMigrationsHandler } from '@/commands/db/handlers/generateMigrationsHandler' -import { cleanupOldMigrations } from '@/commands/db/utils/pocketbase-utils' -import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -export function generateSchemaMigrations(moduleName: string): void { - CLILoggingService.progress(`Generating schema migrations for ${moduleName}`) - - try { - generateMigrationsHandler(moduleName) - CLILoggingService.success('Schema migrations generated successfully') - } catch { - CLILoggingService.warn( - 'No database schema found - skipping migrations (this is normal for UI-only modules)' - ) - } -} - -export async function removeModuleMigrations( - moduleName: string -): Promise { - CLILoggingService.progress(`Removing migrations for module: ${moduleName}`) - - try { - await cleanupOldMigrations(moduleName) - CLILoggingService.success(`Migrations for module "${moduleName}" removed`) - } catch (error) { - CLILoggingService.warn( - `Failed to remove migrations for ${moduleName}: ${error}` - ) - } -} - -export function generateDatabaseSchemas(): void { - CLILoggingService.step('Generating database schemas for the new module...') - - executeCommand('bun run forge db pull', { - cwd: process.cwd(), - stdio: 'ignore' - }) - - CLILoggingService.success('Database schemas generated successfully.') -} diff --git a/tools/src/commands/modules/functions/prompts/index.ts b/tools/src/commands/modules/functions/prompts/index.ts index 55490f908..099591e8f 100644 --- a/tools/src/commands/modules/functions/prompts/index.ts +++ b/tools/src/commands/modules/functions/prompts/index.ts @@ -1,7 +1,5 @@ export { default as selectIcon } from './select-icon' -export { selectModuleToRemove } from './select-module' - export { promptForModuleName } from './module-name' export { checkModuleTypeAvailability, promptModuleType } from './module-type' diff --git a/tools/src/commands/modules/functions/prompts/module-category.ts b/tools/src/commands/modules/functions/prompts/module-category.ts index f064b022c..b39b8a446 100644 --- a/tools/src/commands/modules/functions/prompts/module-category.ts +++ b/tools/src/commands/modules/functions/prompts/module-category.ts @@ -1,6 +1,6 @@ import prompts from 'prompts' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' export async function promptModuleCategory(): Promise { const response = await prompts( @@ -19,7 +19,7 @@ export async function promptModuleCategory(): Promise { }, { onCancel: () => { - CLILoggingService.error('Module creation cancelled by user.') + Logging.error('Module creation cancelled by user.') process.exit(0) } } diff --git a/tools/src/commands/modules/functions/prompts/module-description.ts b/tools/src/commands/modules/functions/prompts/module-description.ts index 765b34474..1044f731b 100644 --- a/tools/src/commands/modules/functions/prompts/module-description.ts +++ b/tools/src/commands/modules/functions/prompts/module-description.ts @@ -3,7 +3,7 @@ import prompts from 'prompts' import z from 'zod' import { fetchAI } from '@/utils/ai' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' export async function promptModuleDescription(): Promise<{ en: string @@ -26,7 +26,7 @@ export async function promptModuleDescription(): Promise<{ }, { onCancel: () => { - CLILoggingService.error('Module creation cancelled by user.') + Logging.error('Module creation cancelled by user.') process.exit(0) } } @@ -53,7 +53,7 @@ export async function promptModuleDescription(): Promise<{ }) if (!translationResponse) { - CLILoggingService.warn( + Logging.warn( "Failed to translate description. Please edit it manually in the module's localization files." ) @@ -66,7 +66,7 @@ export async function promptModuleDescription(): Promise<{ } for (const [key, value] of Object.entries(translationResponse)) { - CLILoggingService.debug(`Translated module description [${key}]: ${value}`) + Logging.debug(`Translated module description [${key}]: ${value}`) } return { diff --git a/tools/src/commands/modules/functions/prompts/module-name.ts b/tools/src/commands/modules/functions/prompts/module-name.ts index e2d12e44d..5d4a69db7 100644 --- a/tools/src/commands/modules/functions/prompts/module-name.ts +++ b/tools/src/commands/modules/functions/prompts/module-name.ts @@ -2,9 +2,9 @@ import prompts from 'prompts' import z from 'zod' import { fetchAI } from '@/utils/ai' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' -import { getInstalledModules } from '../../utils/file-system' +import listModules from '../listModules' export async function promptForModuleName(moduleName?: string): Promise<{ en: string @@ -13,7 +13,7 @@ export async function promptForModuleName(moduleName?: string): Promise<{ zhTW: string }> { if (!moduleName) { - const availableModules = getInstalledModules() + const availableModules = Object.keys(listModules()) const response = await prompts( { @@ -34,7 +34,7 @@ export async function promptForModuleName(moduleName?: string): Promise<{ }, { onCancel: () => { - CLILoggingService.error('Module creation cancelled by user.') + Logging.error('Module creation cancelled by user.') process.exit(0) } } @@ -64,7 +64,7 @@ export async function promptForModuleName(moduleName?: string): Promise<{ }) if (!translationResponse) { - CLILoggingService.warn( + Logging.warn( "Failed to translate module name. Please edit it manually in the module's localization files." ) @@ -77,7 +77,7 @@ export async function promptForModuleName(moduleName?: string): Promise<{ } for (const [key, value] of Object.entries(translationResponse)) { - CLILoggingService.debug(`Translated module name [${key}]: ${value}`) + Logging.debug(`Translated module name [${key}]: ${value}`) } return { diff --git a/tools/src/commands/modules/functions/prompts/module-type.ts b/tools/src/commands/modules/functions/prompts/module-type.ts index ffcb11f7c..9efcff6e4 100644 --- a/tools/src/commands/modules/functions/prompts/module-type.ts +++ b/tools/src/commands/modules/functions/prompts/module-type.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import fs from 'fs' import prompts from 'prompts' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { AVAILABLE_TEMPLATE_MODULE_TYPES } from '../../../../constants/constants' @@ -23,7 +23,7 @@ export async function promptModuleType(): Promise< }, { onCancel: () => { - CLILoggingService.error('Module creation cancelled by user.') + Logging.error('Module creation cancelled by user.') process.exit(0) } } @@ -38,26 +38,26 @@ export function checkModuleTypeAvailability( const templateDir = `${process.cwd()}/tools/src/templates/${moduleType}` if (!fs.existsSync(templateDir)) { - CLILoggingService.error( + Logging.error( `Template for module type "${moduleType}" does not exist at path: ${templateDir}` ) process.exit(1) } - CLILoggingService.debug( + Logging.debug( `Template for module type "${moduleType}" found at path: ${templateDir}` ) const files = fs.readdirSync(templateDir) if (files.length === 0) { - CLILoggingService.error( + Logging.error( `Template directory for module type "${moduleType}" is empty at path: ${templateDir}` ) process.exit(1) } - CLILoggingService.debug( + Logging.debug( `Template for module type "${moduleType}" is available and ready to use.` ) } diff --git a/tools/src/commands/modules/functions/prompts/select-icon.ts b/tools/src/commands/modules/functions/prompts/select-icon.ts index 93f43d198..d78e8164e 100644 --- a/tools/src/commands/modules/functions/prompts/select-icon.ts +++ b/tools/src/commands/modules/functions/prompts/select-icon.ts @@ -2,7 +2,7 @@ import axios from 'axios' import ora from 'ora' import prompts from 'prompts' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' export default async function selectIcon(): Promise { const iconCollections = ( @@ -20,7 +20,7 @@ export default async function selectIcon(): Promise { ).data if (!iconCollections) { - CLILoggingService.error('Failed to fetch icon collections from Iconify.') + Logging.error('Failed to fetch icon collections from Iconify.') process.exit(1) } @@ -55,12 +55,12 @@ export default async function selectIcon(): Promise { ) if (!moduleIconCollection.iconCollection) { - CLILoggingService.error('Please select a valid icon set.') + Logging.error('Please select a valid icon set.') continue } if (cancelled) { - CLILoggingService.error('Icon selection cancelled by user.') + Logging.error('Icon selection cancelled by user.') process.exit(1) } @@ -78,7 +78,7 @@ export default async function selectIcon(): Promise { ).data } catch (error) { spinner2.fail('Failed to fetch icons from Iconify.') - CLILoggingService.error( + Logging.error( `Error fetching icons for collection ${moduleIconCollection.iconCollection}: ${error}` ) continue @@ -87,7 +87,7 @@ export default async function selectIcon(): Promise { spinner2.stop() if (!icons) { - CLILoggingService.error('Failed to fetch icons from Iconify.') + Logging.error('Failed to fetch icons from Iconify.') process.exit(1) } @@ -167,7 +167,7 @@ export default async function selectIcon(): Promise { ) if (cancelled2) { - CLILoggingService.error('Icon selection cancelled by user.') + Logging.error('Icon selection cancelled by user.') process.exit(1) } diff --git a/tools/src/commands/modules/functions/prompts/select-module.ts b/tools/src/commands/modules/functions/prompts/select-module.ts deleted file mode 100644 index 6d9cac5ab..000000000 --- a/tools/src/commands/modules/functions/prompts/select-module.ts +++ /dev/null @@ -1,91 +0,0 @@ -import chalk from 'chalk' -import fs from 'fs' -import path from 'path' -import prompts from 'prompts' - -import CLILoggingService from '@/utils/logging' - -import { - getInstalledModules, - hasServerComponents -} from '../../utils/file-system' - -export async function selectModuleToRemove(): Promise { - const installedModules = getInstalledModules() - - if (installedModules.length === 0) { - CLILoggingService.info('No modules found to remove') - process.exit(0) - } - - const choices = installedModules.map(module => { - const modulePath = `apps/${module}` - - const packageJsonPath = path.join(modulePath, 'package.json') - - let description = 'No description' - - if (fs.existsSync(packageJsonPath)) { - try { - const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) - - description = packageData.description || 'No description' - } catch { - // If we can't read package.json, use defaults - } - } - - const { hasServerDir, hasServerIndex } = hasServerComponents(module) - - const serverStatus = - hasServerDir && hasServerIndex - ? chalk.green('[Server]') - : chalk.blue('[Client only]') - - return { - title: `${chalk.cyan.bold(module)} - ${chalk.gray(description)} ${serverStatus}`, - value: module - } - }) - - choices.push({ - title: chalk.red('Cancel (do not remove any module)'), - value: '__cancel__' - }) - - const response = await prompts({ - type: 'autocomplete', - name: 'selectedModule', - message: 'Which module would you like to remove?', - choices, - initial: 0, - suggest: (input: string, choices: { value?: string; title?: string }[]) => { - return Promise.resolve( - choices.filter( - choice => - choice.value?.toLowerCase().includes(input.toLowerCase()) || - choice.title?.toLowerCase().includes(input.toLowerCase()) - ) - ) - } - }) - - if (!response.selectedModule || response.selectedModule === '__cancel__') { - CLILoggingService.info('Module removal cancelled') - process.exit(0) - } - - const confirmResponse = await prompts({ - type: 'confirm', - name: 'confirmRemoval', - message: `Are you sure you want to PERMANENTLY REMOVE the "${response.selectedModule}" module?\n This action cannot be undone and will delete all module files and migrations.`, - initial: false - }) - - if (!confirmResponse.confirmRemoval) { - CLILoggingService.info('Module removal cancelled') - process.exit(0) - } - - return response.selectedModule -} diff --git a/tools/src/commands/modules/functions/registry/generateSchemaRegistry.ts b/tools/src/commands/modules/functions/registry/generateSchemaRegistry.ts new file mode 100644 index 000000000..fe5f0eb1c --- /dev/null +++ b/tools/src/commands/modules/functions/registry/generateSchemaRegistry.ts @@ -0,0 +1,38 @@ +import fs from 'fs' +import path from 'path' + +import getFsMetadata from '../getFsMetadata' +import listModules from '../listModules' +import { parsePackageName } from './namespaceUtils' + +export default function generateSchemaRegistry(): string { + const modules = Object.keys(listModules()) + + const modulesWithSchema = modules.filter(mod => + fs.existsSync(path.join(getFsMetadata(mod).targetDir, 'server/schema.ts')) + ) + + const moduleSchemas = modulesWithSchema + .map(mod => { + const { username, moduleName } = parsePackageName(mod) + + const key = username ? `${username}$${moduleName}` : moduleName + + return ` ${key}: (await import('${mod}/server/schema')).default,` + }) + .join('\n') + + return `// AUTO-GENERATED - DO NOT EDIT +import flattenSchemas from '@functions/utils/flattenSchema' + +export const SCHEMAS = { + user: (await import('@lib/user/schema')).default, + api_keys: (await import('@lib/apiKeys/schema')).default, +${moduleSchemas} +} + +const COLLECTION_SCHEMAS = flattenSchemas(SCHEMAS) + +export default COLLECTION_SCHEMAS +` +} diff --git a/tools/src/commands/modules/functions/registry/generateServerRegistry.ts b/tools/src/commands/modules/functions/registry/generateServerRegistry.ts new file mode 100644 index 000000000..199332b51 --- /dev/null +++ b/tools/src/commands/modules/functions/registry/generateServerRegistry.ts @@ -0,0 +1,44 @@ +import fs from 'fs' +import path from 'path' + +import getFsMetadata from '../getFsMetadata' +import listModules from '../listModules' +import { parsePackageName } from './namespaceUtils' + +export default function generateServerRegistry(): string { + const modules = Object.keys(listModules(true)) + + const modulesWithServer = modules.filter(mod => + fs.existsSync(path.join(getFsMetadata(mod).targetDir, 'server/index.ts')) + ) + + if (modulesWithServer.length === 0) { + return `// AUTO-GENERATED - DO NOT EDIT +import { forgeRouter } from '@functions/routes' + +const appRoutes = forgeRouter({}) + +export default appRoutes +` + } + + const imports = modulesWithServer + .map(mod => { + const { username, moduleName } = parsePackageName(mod) + + const key = username ? `${username}$${moduleName}` : moduleName + + return ` ${key}: (await import('${mod}/server')).default,` + }) + .join('\n') + + return `// AUTO-GENERATED - DO NOT EDIT +import { forgeRouter } from '@functions/routes' + +const appRoutes = forgeRouter({ +${imports} +}) + +export default appRoutes +` +} diff --git a/tools/src/commands/modules/functions/registry/generator.ts b/tools/src/commands/modules/functions/registry/generator.ts deleted file mode 100644 index 6a1886365..000000000 --- a/tools/src/commands/modules/functions/registry/generator.ts +++ /dev/null @@ -1,137 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import CLILoggingService from '@/utils/logging' - -import { - getLifeforgeModules, - getModulePath, - moduleHasSchema -} from './module-utils' -import { generateSchemaRegistry } from './schema-registry' -import { generateServerRegistry } from './server-registry' - -interface ModulePackageJson { - exports?: Record - [key: string]: unknown -} - -function generateManifestDeclaration(): string { - return `// AUTO-GENERATED - DO NOT EDIT -// This declaration file allows TypeScript to type-check module imports -// without resolving internal module aliases like @ -import type { ModuleConfig } from 'shared' - -declare const manifest: ModuleConfig -export default manifest -` -} - -function updateModulePackageJson(modulePath: string): boolean { - const packageJsonPath = path.join(modulePath, 'package.json') - - if (!fs.existsSync(packageJsonPath)) { - return false - } - - const packageJson: ModulePackageJson = JSON.parse( - fs.readFileSync(packageJsonPath, 'utf-8') - ) - - if (!packageJson.exports) { - return false - } - - let updated = false - - if (packageJson.exports['./manifest']) { - const currentExport = packageJson.exports['./manifest'] - - if (typeof currentExport === 'string') { - packageJson.exports['./manifest'] = { - types: './manifest.d.ts', - default: currentExport - } - updated = true - } else if (typeof currentExport === 'object' && !currentExport.types) { - currentExport.types = './manifest.d.ts' - updated = true - } - } - - const schemaPath = path.join(modulePath, 'server', 'schema.ts') - - if (fs.existsSync(schemaPath) && !packageJson.exports['./server/schema']) { - packageJson.exports['./server/schema'] = './server/schema.ts' - updated = true - } - - if (updated) { - fs.writeFileSync( - packageJsonPath, - JSON.stringify(packageJson, null, 2) + '\n' - ) - } - - return updated -} - -export function generateModuleRegistries(): void { - CLILoggingService.progress('Generating module registries...') - - const modules = getLifeforgeModules() - - if (modules.length === 0) { - CLILoggingService.info('No @lifeforge/* modules found') - } else { - CLILoggingService.debug(`Found ${modules.length} module(s):`) - modules.forEach(mod => CLILoggingService.debug(` - ${mod}`)) - } - - const serverOutputPath = path.join( - process.cwd(), - 'server/src/core/routes/generated-routes.ts' - ) - - const schemaOutputPath = path.join(process.cwd(), 'server/src/core/schema.ts') - - // Generate server registry - const serverContent = generateServerRegistry(modules) - - fs.mkdirSync(path.dirname(serverOutputPath), { recursive: true }) - fs.writeFileSync(serverOutputPath, serverContent) - CLILoggingService.debug(`Generated: ${serverOutputPath}`) - - // Generate schema registry (only for modules with schema.ts) - const modulesWithSchema = modules.filter(mod => moduleHasSchema(mod)) - - const schemaContent = generateSchemaRegistry(modulesWithSchema) - - fs.mkdirSync(path.dirname(schemaOutputPath), { recursive: true }) - fs.writeFileSync(schemaOutputPath, schemaContent) - CLILoggingService.debug(`Generated: ${schemaOutputPath}`) - - // Generate manifest.d.ts for each module and update package.json - for (const mod of modules) { - const modulePath = getModulePath(mod) - - if (modulePath) { - const declarationPath = path.join(modulePath, 'manifest.d.ts') - - const declarationContent = generateManifestDeclaration() - - fs.writeFileSync(declarationPath, declarationContent) - CLILoggingService.debug(`Generated: ${declarationPath}`) - - const updated = updateModulePackageJson(modulePath) - - if (updated) { - CLILoggingService.debug( - `Updated: ${path.join(modulePath, 'package.json')}` - ) - } - } - } - - CLILoggingService.success('Module registries generated') -} diff --git a/tools/src/commands/modules/functions/registry/index.ts b/tools/src/commands/modules/functions/registry/index.ts deleted file mode 100644 index 490b26fa4..000000000 --- a/tools/src/commands/modules/functions/registry/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - getLifeforgeModules, - getModulePath, - moduleHasSchema, - moduleHasServer -} from './module-utils' - -export { generateSchemaRegistry } from './schema-registry' - -export { generateServerRegistry } from './server-registry' diff --git a/tools/src/commands/modules/functions/registry/module-utils.ts b/tools/src/commands/modules/functions/registry/module-utils.ts deleted file mode 100644 index e20bca507..000000000 --- a/tools/src/commands/modules/functions/registry/module-utils.ts +++ /dev/null @@ -1,76 +0,0 @@ -import fs from 'fs' -import path from 'path' - -const LIFEFORGE_SCOPE = '@lifeforge/' - -interface PackageJson { - dependencies?: Record - devDependencies?: Record -} - -export function getLifeforgeModules(): string[] { - const packageJsonPath = path.join(process.cwd(), 'package.json') - - const packageJson: PackageJson = JSON.parse( - fs.readFileSync(packageJsonPath, 'utf-8') - ) - - const allDeps = { - ...packageJson.dependencies, - ...packageJson.devDependencies - } - - return Object.keys(allDeps) - .filter(dep => dep.startsWith(LIFEFORGE_SCOPE)) - .filter(dep => !dep.replace(LIFEFORGE_SCOPE, '').startsWith('lang-')) -} - -export function extractModuleName(packageName: string): string { - const withoutScope = packageName.replace(LIFEFORGE_SCOPE, '') - - if (withoutScope.startsWith('lifeforge--')) { - return withoutScope.replace('lifeforge--', '') - } - - return withoutScope -} - -export function getModulePath(packageName: string): string | null { - const nodeModulesPath = path.join(process.cwd(), 'node_modules', packageName) - - try { - const realPath = fs.realpathSync(nodeModulesPath) - - if (realPath.includes('/apps/')) { - return realPath - } - - return nodeModulesPath - } catch { - return null - } -} - -export function moduleHasSchema(packageName: string): boolean { - const modulePath = getModulePath(packageName) - - if (!modulePath) { - return false - } - - const schemaPath = path.join(modulePath, 'server', 'schema.ts') - - return fs.existsSync(schemaPath) -} - -export function moduleHasServer(packageName: string): boolean { - const modulePath = getModulePath(packageName) - - if (!modulePath) { - return false - } - - const serverPath = path.join(modulePath, 'server', 'index.ts') - - return fs.existsSync(serverPath) -} diff --git a/tools/src/commands/modules/functions/registry/namespace-utils.ts b/tools/src/commands/modules/functions/registry/namespace-utils.ts deleted file mode 100644 index eb495c0de..000000000 --- a/tools/src/commands/modules/functions/registry/namespace-utils.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Namespace utility functions for converting between code format ($) and PocketBase format (___) - */ - -// Triple underscore separates username from module name -const USERNAME_SEPARATOR = '___' - -// Double underscore separates module name from collection name -const COLLECTION_SEPARATOR = '__' - -// Dollar sign is used in code format for username separator -const CODE_USERNAME_SEPARATOR = '$' - -/** - * Convert code format to PocketBase format - * username$module_name → username___module_name - */ -export function toPocketBaseNamespace(codeNamespace: string): string { - return codeNamespace.replace(CODE_USERNAME_SEPARATOR, USERNAME_SEPARATOR) -} - -/** - * Convert PocketBase format to code format - * username___module_name → username$module_name - */ -export function toCodeNamespace(pbNamespace: string): string { - // Only convert the first occurrence (username separator) - const idx = pbNamespace.indexOf(USERNAME_SEPARATOR) - - if (idx === -1) { - return pbNamespace - } - - return ( - pbNamespace.slice(0, idx) + - CODE_USERNAME_SEPARATOR + - pbNamespace.slice(idx + USERNAME_SEPARATOR.length) - ) -} - -/** - * Parse a PocketBase collection name into components - * - * Examples: - * - "melvinchia3636___melvinchia3636$melvinchia3636$invoice_maker__clients" → { username: "melvinchia3636", moduleName: "invoice_maker", collectionName: "clients" } - * - "achievements__badges" → { moduleName: "achievements", collectionName: "badges" } - */ -export function parseCollectionName(pbCollectionName: string): { - username?: string - moduleName: string - collectionName: string -} { - // Check for triple underscore (username separator) - const usernameIdx = pbCollectionName.indexOf(USERNAME_SEPARATOR) - - if (usernameIdx !== -1) { - // Third-party module - const username = pbCollectionName.slice(0, usernameIdx) - - const remainder = pbCollectionName.slice( - usernameIdx + USERNAME_SEPARATOR.length - ) - - const moduleIdx = remainder.indexOf(COLLECTION_SEPARATOR) - - if (moduleIdx === -1) { - return { username, moduleName: remainder, collectionName: remainder } - } - - return { - username, - moduleName: remainder.slice(0, moduleIdx), - collectionName: remainder.slice(moduleIdx + COLLECTION_SEPARATOR.length) - } - } - - // Official module - split on first double underscore - const moduleIdx = pbCollectionName.indexOf(COLLECTION_SEPARATOR) - - if (moduleIdx === -1) { - return { moduleName: pbCollectionName, collectionName: pbCollectionName } - } - - return { - moduleName: pbCollectionName.slice(0, moduleIdx), - collectionName: pbCollectionName.slice( - moduleIdx + COLLECTION_SEPARATOR.length - ) - } -} - -/** - * Build a full PocketBase collection name from components - * - * Examples: - * - ("invoice_maker", "clients", "melvinchia3636") → "melvinchia3636___melvinchia3636$melvinchia3636$invoice_maker__clients" - * - ("achievements", "badges") → "achievements__badges" - */ -export function buildCollectionName( - moduleName: string, - collectionName: string, - username?: string -): string { - const modulePrefix = username - ? `${username}${USERNAME_SEPARATOR}${moduleName}` - : moduleName - - return `${modulePrefix}${COLLECTION_SEPARATOR}${collectionName}` -} - -/** - * Check if a module name indicates a third-party module - * (contains double dash in package name format) - */ -export function isThirdPartyModule(moduleName: string): boolean { - return moduleName.includes('--') -} - -/** - * Extract username and module name from a package name format - * "melvinchia3636--invoice-maker" → { username: "melvinchia3636", moduleName: "invoice_maker" } - * "lifeforge--achievements" → { moduleName: "achievements" } (official, no username) - */ -export function parsePackageName(packageName: string): { - username?: string - moduleName: string -} { - if (!packageName.includes('--')) { - return { moduleName: packageName.replace(/-/g, '_') } - } - - const [first, ...rest] = packageName.split('--') - - const moduleName = rest.join('--').replace(/-/g, '_') - - // Official modules start with "lifeforge" - if (first === 'lifeforge') { - return { moduleName } - } - - return { username: first, moduleName } -} diff --git a/tools/src/commands/modules/functions/registry/namespaceUtils.ts b/tools/src/commands/modules/functions/registry/namespaceUtils.ts new file mode 100644 index 000000000..f3b748713 --- /dev/null +++ b/tools/src/commands/modules/functions/registry/namespaceUtils.ts @@ -0,0 +1,27 @@ +import Logging from '@/utils/logging' + +/** + * Extract username and module name from a package name format + * + * Examples: + * - "melvinchia3636--invoice-maker" → { username: "melvinchia3636", moduleName: "invoice_maker" } + * - "lifeforge--achievements" → { moduleName: "achievements" } (official, no username) + */ +export function parsePackageName(packageName: string): { + username?: string + moduleName: string +} { + const withoutScope = packageName.replace(/^@lifeforge\//, '') + + if (!withoutScope.includes('--')) { + Logging.error(`Invalid package name: ${packageName}`) + process.exit(1) + } + + const [username, moduleName] = withoutScope.split('--', 2) + + return { + username: username === 'lifeforge' ? undefined : username, + moduleName: moduleName.replace(/-/g, '_') + } +} diff --git a/tools/src/commands/modules/functions/registry/schema-registry.ts b/tools/src/commands/modules/functions/registry/schema-registry.ts deleted file mode 100644 index 3521a5875..000000000 --- a/tools/src/commands/modules/functions/registry/schema-registry.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { extractModuleName } from './module-utils' - -export function generateSchemaRegistry(modulesWithSchema: string[]): string { - const moduleSchemas = modulesWithSchema - .map(mod => { - const name = extractModuleName(mod) - - let key: string - - if (name.includes('--')) { - // Third-party module: username--module-name → username$module_name - const [username, ...rest] = name.split('--') - - const moduleName = rest.join('--').replace(/-/g, '_') - - key = `${username}$${moduleName}` - } else { - // Official module: just convert dashes to underscores - key = name.replace(/-/g, '_') - } - - return ` ${key}: (await import('${mod}/server/schema')).default,` - }) - .join('\n') - - return `// AUTO-GENERATED - DO NOT EDIT -import flattenSchemas from '@functions/utils/flattenSchema' - -export const SCHEMAS = { - user: (await import('@lib/user/schema')).default, - api_keys: (await import('@lib/apiKeys/schema')).default, -${moduleSchemas} -} - -const COLLECTION_SCHEMAS = flattenSchemas(SCHEMAS) - -export default COLLECTION_SCHEMAS -` -} diff --git a/tools/src/commands/modules/functions/registry/server-registry.ts b/tools/src/commands/modules/functions/registry/server-registry.ts deleted file mode 100644 index 3749a4f05..000000000 --- a/tools/src/commands/modules/functions/registry/server-registry.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { extractModuleName, moduleHasServer } from './module-utils' - -export function generateServerRegistry(modules: string[]): string { - const modulesWithServer = modules.filter(mod => moduleHasServer(mod)) - - if (modulesWithServer.length === 0) { - return `// AUTO-GENERATED - DO NOT EDIT -import { forgeRouter } from '@functions/routes' - -const appRoutes = forgeRouter({}) - -export default appRoutes -` - } - - const imports = modulesWithServer - .map(mod => { - const name = extractModuleName(mod) - - let key: string - - if (name.includes('--')) { - // Third-party module: username--module-name → username$module_name - const [username, ...rest] = name.split('--') - - const moduleName = rest.join('--').replace(/-/g, '_') - - key = `${username}$${moduleName}` - } else { - // Official module: just convert dashes to underscores - key = name.replace(/-/g, '_') - } - - return ` ${key}: (await import('${mod}/server')).default,` - }) - .join('\n') - - return `// AUTO-GENERATED - DO NOT EDIT -import { forgeRouter } from '@functions/routes' - -const appRoutes = forgeRouter({ -${imports} -}) - -export default appRoutes -` -} diff --git a/tools/src/commands/modules/functions/templates/copy-template.ts b/tools/src/commands/modules/functions/templates/copy-template.ts index ea6570ae3..7a9bc1f8b 100644 --- a/tools/src/commands/modules/functions/templates/copy-template.ts +++ b/tools/src/commands/modules/functions/templates/copy-template.ts @@ -2,7 +2,7 @@ import fs from 'fs' import Handlebars from 'handlebars' import _ from 'lodash' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { AVAILABLE_TEMPLATE_MODULE_TYPES } from '../../../../constants/constants' @@ -74,7 +74,7 @@ export function renameTsConfigFile(moduleDir: string): void { } export function copyTemplateFiles(moduleMetadata: ModuleMetadata): void { - CLILoggingService.step(`Creating module "${moduleMetadata.moduleName.en}"...`) + Logging.step(`Creating module "${moduleMetadata.moduleName.en}"...`) const templateDir = `${process.cwd()}/tools/src/templates/${moduleMetadata.moduleType}` @@ -90,7 +90,7 @@ export function copyTemplateFiles(moduleMetadata: ModuleMetadata): void { renameTsConfigFile(destinationDir) - CLILoggingService.success( + Logging.success( `Module "${moduleMetadata.moduleName.en}" created successfully at ${destinationDir}` ) } diff --git a/tools/src/commands/modules/functions/templates/init-git-repo.ts b/tools/src/commands/modules/functions/templates/init-git-repo.ts index 4ca2ef8c8..df602a46c 100644 --- a/tools/src/commands/modules/functions/templates/init-git-repo.ts +++ b/tools/src/commands/modules/functions/templates/init-git-repo.ts @@ -1,8 +1,8 @@ import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' export function initializeGitRepository(modulePath: string): void { - CLILoggingService.step('Initializing git repository for the new module...') + Logging.step('Initializing git repository for the new module...') executeCommand('git init', { cwd: modulePath, stdio: 'ignore' }) executeCommand('git add .', { cwd: modulePath, stdio: 'ignore' }) diff --git a/tools/src/commands/modules/functions/validateModuleAuthor.ts b/tools/src/commands/modules/functions/validateModuleAuthor.ts new file mode 100644 index 000000000..a081e1e96 --- /dev/null +++ b/tools/src/commands/modules/functions/validateModuleAuthor.ts @@ -0,0 +1,42 @@ +import fs from 'fs' +import path from 'path' +import z from 'zod' + +import { validateMaintainerAccess } from '@/utils/github-cli' +import Logging from '@/utils/logging' +import { checkAuth } from '@/utils/registry' + +export default async function validateModuleAuthor(modulePath: string) { + const auth = await checkAuth() + + const { packageJSONSchema } = await import('shared') + + const packageJson = z.safeParse( + packageJSONSchema, + JSON.parse(fs.readFileSync(path.join(modulePath, 'package.json'), 'utf-8')) + ) + + if (!packageJson.success) { + Logging.actionableError( + 'Invalid package.json', + 'Please fix the package.json file' + ) + process.exit(1) + } + + const nameWithoutScope = packageJson.data.name.replace('@lifeforge/', '') + + const usernamePrefix = nameWithoutScope.split('--')[0] + + if (usernamePrefix && usernamePrefix !== auth.username) { + if (usernamePrefix === 'lifeforge') { + validateMaintainerAccess(auth.username || '') + } else { + Logging.actionableError( + `Cannot publish as "${auth.username}" - package belongs to "${usernamePrefix}"`, + `You can only publish packages starting with @lifeforge/${auth.username}--` + ) + process.exit(1) + } + } +} diff --git a/tools/src/commands/modules/functions/validateModuleStructure.ts b/tools/src/commands/modules/functions/validateModuleStructure.ts new file mode 100644 index 000000000..592b09ba9 --- /dev/null +++ b/tools/src/commands/modules/functions/validateModuleStructure.ts @@ -0,0 +1,111 @@ +import fs from 'fs' +import path from 'path' +import z from 'zod' + +import Logging from '@/utils/logging' + +const MODULE_STRUCTURE: Array<{ + type: 'folder' | 'file' + name: string + required: boolean + validate?: (content: string) => Promise | boolean + errorMessage: string +}> = [ + { + type: 'folder', + name: 'client', + required: true, + errorMessage: 'Missing client/ directory' + }, + { + type: 'folder', + name: 'server', + required: false, + errorMessage: 'Missing server/ directory' + }, + { + type: 'folder', + name: 'locales', + required: true, + errorMessage: 'Missing locales/ directory' + }, + { + type: 'file', + name: 'manifest.ts', + required: true, + errorMessage: 'Missing manifest.ts' + }, + { + type: 'file', + name: 'package.json', + required: true, + validate: async (content: string) => { + const json = JSON.parse(content) + + const { packageJSONSchema } = await import('shared') + + const result = z.safeParse(packageJSONSchema, json) + + return result.success + }, + errorMessage: 'Missing package.json' + }, + { + type: 'file', + name: 'manifest.d.ts', + required: true, + errorMessage: 'Missing manifest.d.ts' + }, + { + type: 'file', + name: 'tsconfig.json', + required: true, + errorMessage: 'Missing tsconfig.json' + } +] + +/** + * Validates the structure of a module based on the MODULE_STRUCTURE constant. + * Exits the process if any validation fails. + * + * @param modulePath - The path to the module directory. + */ +export default async function validateModuleStructure( + modulePath: string +): Promise { + const errors: string[] = [] + + for (const item of MODULE_STRUCTURE) { + const itemPath = path.join(modulePath, item.name) + + const exists = + item.type === 'folder' + ? fs.existsSync(itemPath) && fs.statSync(itemPath).isDirectory() + : fs.existsSync(itemPath) && fs.statSync(itemPath).isFile() + + if (!exists) { + if (item.required) { + errors.push(item.errorMessage) + } + continue + } + + if (item.validate && item.type === 'file') { + const content = fs.readFileSync(itemPath, 'utf-8') + + const isValid = await item.validate(content) + + if (!isValid) { + errors.push(`${item.name} validation failed`) + } + } + } + + if (errors.length > 0) { + Logging.error('Module validation failed:') + errors.forEach(error => { + Logging.error(` ✗ ${error}`) + }) + process.exit(1) + } +} diff --git a/tools/src/commands/modules/handlers/create-module.ts b/tools/src/commands/modules/handlers/create-module.ts deleted file mode 100644 index 4791993e6..000000000 --- a/tools/src/commands/modules/handlers/create-module.ts +++ /dev/null @@ -1,74 +0,0 @@ -import fs from 'fs' -import _ from 'lodash' - -import { runDatabaseMigrations } from '@/commands/db/functions/database-initialization/migrations' -import CLILoggingService from '@/utils/logging' -import { checkRunningPBInstances } from '@/utils/pocketbase' - -import { installDependencies } from '../functions/install-dependencies' -import { generateDatabaseSchemas } from '../functions/module-migrations' -import { - checkModuleTypeAvailability, - promptForModuleName, - promptModuleCategory, - promptModuleDescription, - promptModuleType, - selectIcon -} from '../functions/prompts' -import { generateModuleRegistries } from '../functions/registry/generator' -import { - type ModuleMetadata, - copyTemplateFiles, - initializeGitRepository, - registerHandlebarsHelpers -} from '../functions/templates' - -registerHandlebarsHelpers() - -export async function createModuleHandler(moduleName?: string): Promise { - checkRunningPBInstances() - - const moduleNameWithTranslation = await promptForModuleName(moduleName) - - const moduleType = await promptModuleType() - - checkModuleTypeAvailability(moduleType) - - const moduleIcon = await selectIcon() - - const moduleDesc = await promptModuleDescription() - - const moduleCategory = await promptModuleCategory() - - const moduleMetadata: ModuleMetadata = { - moduleName: moduleNameWithTranslation, - moduleIcon, - moduleDesc, - moduleType, - moduleCategory - } - - const camelizedModuleName = _.camelCase(moduleMetadata.moduleName.en) - - copyTemplateFiles(moduleMetadata) - - initializeGitRepository(`${process.cwd()}/apps/${camelizedModuleName}`) - - installDependencies(`${process.cwd()}/apps`) - - // Regenerate registries to include the new module - generateModuleRegistries() - - if ( - fs.existsSync( - `${process.cwd()}/apps/${camelizedModuleName}/server/schema.ts` - ) - ) { - runDatabaseMigrations() - generateDatabaseSchemas() - } - - CLILoggingService.success( - `Module "${moduleMetadata.moduleName.en}" setup is complete!` - ) -} diff --git a/tools/src/commands/modules/handlers/createModuleHandler.ts b/tools/src/commands/modules/handlers/createModuleHandler.ts new file mode 100644 index 000000000..88e09c2a0 --- /dev/null +++ b/tools/src/commands/modules/handlers/createModuleHandler.ts @@ -0,0 +1,37 @@ +import { registerHandlebarsHelpers } from '../functions/templates' + +registerHandlebarsHelpers() + +export async function createModuleHandler(moduleName?: string): Promise { + // checkRunningPBInstances() + // const moduleNameWithTranslation = await promptForModuleName(moduleName) + // const moduleType = await promptModuleType() + // checkModuleTypeAvailability(moduleType) + // const moduleIcon = await selectIcon() + // const moduleDesc = await promptModuleDescription() + // const moduleCategory = await promptModuleCategory() + // const moduleMetadata: ModuleMetadata = { + // moduleName: moduleNameWithTranslation, + // moduleIcon, + // moduleDesc, + // moduleType, + // moduleCategory + // } + // const camelizedModuleName = _.camelCase(moduleMetadata.moduleName.en) + // copyTemplateFiles(moduleMetadata) + // initializeGitRepository(`${process.cwd()}/apps/${camelizedModuleName}`) + // installDependencies(`${process.cwd()}/apps`) + // // Regenerate registries to include the new module + // generateModuleRegistries() + // if ( + // fs.existsSync( + // `${process.cwd()}/apps/${camelizedModuleName}/server/schema.ts` + // ) + // ) { + // runDatabaseMigrations() + // generateDatabaseSchemas() + // } + // CLILoggingService.success( + // `Module "${moduleMetadata.moduleName.en}" setup is complete!` + // ) +} diff --git a/tools/src/commands/modules/handlers/install-module.ts b/tools/src/commands/modules/handlers/install-module.ts deleted file mode 100644 index ce6462e54..000000000 --- a/tools/src/commands/modules/handlers/install-module.ts +++ /dev/null @@ -1,155 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -import { generateModuleRegistries } from '../functions/registry/generator' - -interface PackageJson { - name?: string - version?: string - dependencies?: Record - [key: string]: unknown -} - -function extractModuleName(packageName: string): string { - // @lifeforge/lifeforge--calendar -> lifeforge--calendar - // @lifeforge/melvin--myapp -> melvin--myapp - return packageName.replace('@lifeforge/', '') -} - -export async function installModuleHandler(moduleName: string): Promise { - // Normalize module name - const fullPackageName = moduleName.startsWith('@lifeforge/') - ? moduleName - : `@lifeforge/${moduleName}` - - const shortName = extractModuleName(fullPackageName) - - const appsDir = path.join(process.cwd(), 'apps') - - const targetDir = path.join(appsDir, shortName) - - CLILoggingService.info(`Installing module ${fullPackageName}...`) - - // Check if module already exists in apps/ - if (fs.existsSync(targetDir)) { - CLILoggingService.actionableError( - `Module already exists at apps/${shortName}`, - `Remove it first with: bun forge modules remove ${shortName}` - ) - - return - } - - // Create apps directory if it doesn't exist - if (!fs.existsSync(appsDir)) { - fs.mkdirSync(appsDir, { recursive: true }) - } - - CLILoggingService.progress('Fetching module from registry...') - - try { - // Use bun to install the package to node_modules - executeCommand(`bun add ${fullPackageName}@latest`, { - cwd: process.cwd(), - stdio: 'inherit' - }) - - // Find the installed package in node_modules - const installedPath = path.join( - process.cwd(), - 'node_modules', - fullPackageName - ) - - if (!fs.existsSync(installedPath)) { - throw new Error(`Failed to install ${fullPackageName}`) - } - - CLILoggingService.progress('Moving module to apps/...') - - // Copy from node_modules to apps/ - fs.cpSync(installedPath, targetDir, { recursive: true }) - - CLILoggingService.success(`Module copied to apps/${shortName}`) - - // Update root package.json to use workspace:* - CLILoggingService.progress('Updating package.json...') - - const rootPackageJsonPath = path.join(process.cwd(), 'package.json') - - const rootPackageJson: PackageJson = JSON.parse( - fs.readFileSync(rootPackageJsonPath, 'utf-8') - ) - - if (!rootPackageJson.dependencies) { - rootPackageJson.dependencies = {} - } - - // Change to workspace reference - rootPackageJson.dependencies[fullPackageName] = 'workspace:*' - - fs.writeFileSync( - rootPackageJsonPath, - JSON.stringify(rootPackageJson, null, 2) + '\n' - ) - - CLILoggingService.success('Updated root package.json') - - // Run bun install to create symlinks - CLILoggingService.progress('Linking workspace...') - - // Remove the node_modules copy so bun creates a proper symlink - const nodeModulesPath = path.join( - process.cwd(), - 'node_modules', - fullPackageName - ) - - if (fs.existsSync(nodeModulesPath)) { - fs.rmSync(nodeModulesPath, { recursive: true, force: true }) - } - - executeCommand('bun install', { - cwd: process.cwd(), - stdio: 'inherit' - }) - - // Generate module registries - CLILoggingService.progress('Generating module registries...') - generateModuleRegistries() - - // Generate database migrations if the module has a schema - const schemaPath = path.join(targetDir, 'server', 'schema.ts') - - if (fs.existsSync(schemaPath)) { - CLILoggingService.progress('Generating database migrations...') - - try { - executeCommand(`bun forge db push ${shortName}`, { - cwd: process.cwd(), - stdio: 'inherit' - }) - - CLILoggingService.success('Database migrations generated') - } catch { - CLILoggingService.warn( - 'Failed to generate database migrations. You may need to run "bun forge db migrations generate" manually.' - ) - } - } - - CLILoggingService.success( - `Module ${fullPackageName} installed successfully!` - ) - CLILoggingService.info(`Location: apps/${shortName}`) - } catch (error) { - CLILoggingService.actionableError( - `Failed to install ${fullPackageName}`, - 'Make sure the module exists in the registry' - ) - throw error - } -} diff --git a/tools/src/commands/modules/handlers/installModuleHandler.ts b/tools/src/commands/modules/handlers/installModuleHandler.ts new file mode 100644 index 000000000..a06ef13c4 --- /dev/null +++ b/tools/src/commands/modules/handlers/installModuleHandler.ts @@ -0,0 +1,46 @@ +import fs from 'fs' +import path from 'path' + +import { generateMigrationsHandler } from '@/commands/db/handlers/generateMigrationsHandler' +import Logging from '@/utils/logging' +import { checkPackageExists } from '@/utils/registry' + +import getFsMetadata from '../functions/getFsMetadata' +import installModulePackage from '../functions/installModulePackage' +import linkModuleToWorkspace from '../functions/linkModuleToWorkspace' +import generateSchemaRegistry from '../functions/registry/generateSchemaRegistry' +import generateServerRegistry from '../functions/registry/generateServerRegistry' + +export async function installModuleHandler(moduleName: string): Promise { + const { fullName, shortName, targetDir } = getFsMetadata(moduleName) + + if (fs.existsSync(targetDir)) { + Logging.actionableError( + `Module already exists at apps/${shortName}`, + `Remove it first with: bun forge modules remove ${shortName}` + ) + + return + } + + if (!(await checkPackageExists(fullName))) { + Logging.actionableError( + `Module ${fullName} does not exist`, + `Check the module name and try again` + ) + + return + } + + installModulePackage(fullName, targetDir) + + linkModuleToWorkspace(fullName) + + generateServerRegistry() + + generateSchemaRegistry() + + if (fs.existsSync(path.join(targetDir, 'server', 'schema.ts'))) { + generateMigrationsHandler(moduleName) + } +} diff --git a/tools/src/commands/modules/handlers/list-modules.ts b/tools/src/commands/modules/handlers/list-modules.ts deleted file mode 100644 index d28ccb5d5..000000000 --- a/tools/src/commands/modules/handlers/list-modules.ts +++ /dev/null @@ -1,21 +0,0 @@ -import CLILoggingService from '@/utils/logging' - -import { getInstalledModules } from '../utils/file-system' - -/** - * Handles the list modules command - */ -export async function listModulesHandler(): Promise { - const modules = getInstalledModules() - - if (modules.length === 0) { - CLILoggingService.info('No modules installed yet') - - return - } - - CLILoggingService.list( - `Found ${modules.length} installed module${modules.length > 1 ? 's' : ''}:`, - modules - ) -} diff --git a/tools/src/commands/modules/handlers/listModuleHandler.ts b/tools/src/commands/modules/handlers/listModuleHandler.ts new file mode 100644 index 000000000..cb73f3579 --- /dev/null +++ b/tools/src/commands/modules/handlers/listModuleHandler.ts @@ -0,0 +1,24 @@ +import chalk from 'chalk' + +import Logging from '@/utils/logging' + +import listModules from '../functions/listModules' + +/** + * Handles the list modules command + */ +export async function listModulesHandler(): Promise { + const modules = listModules(true) + + const totalCount = Object.keys(modules).length + + Logging.info( + `Found ${totalCount} installed module${totalCount > 1 ? 's' : ''}:` + ) + + Object.entries(modules).forEach(([name, info]) => { + console.log( + ` ${chalk.green(name)} - ${info.displayName} (${info.version})` + ) + }) +} diff --git a/tools/src/commands/modules/handlers/login-module.ts b/tools/src/commands/modules/handlers/login-module.ts deleted file mode 100644 index 5138ce5ba..000000000 --- a/tools/src/commands/modules/handlers/login-module.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { confirmAction } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -import { - checkAuth, - getRegistryUrl, - openRegistryLogin -} from '../../../utils/registry' - -export async function loginModuleHandler(): Promise { - CLILoggingService.progress('Checking registry authentication...') - - const auth = await checkAuth() - - if (auth.authenticated && auth.username) { - CLILoggingService.success(`Already authenticated as ${auth.username}`) - - const reLogin = await confirmAction('Would you like to login again?') - - if (!reLogin) { - return - } - } - - CLILoggingService.info('Opening registry login page...') - - openRegistryLogin() - - const registry = getRegistryUrl() - - CLILoggingService.info('Please follow these steps to complete login:') - CLILoggingService.info('1. Log in with GitHub on the registry page') - CLILoggingService.info('2. Copy your token from the registry UI') - CLILoggingService.info('3. Run the following command:') - CLILoggingService.info( - `npm config set //${registry.replace('http://', '').replace(/\/$/, '')}/:_authToken "YOUR_TOKEN"` - ) -} diff --git a/tools/src/commands/modules/handlers/migrate-module.ts b/tools/src/commands/modules/handlers/migrate-module.ts deleted file mode 100644 index 61a8e48c5..000000000 --- a/tools/src/commands/modules/handlers/migrate-module.ts +++ /dev/null @@ -1,283 +0,0 @@ -import fs from 'fs' -import kebabCase from 'lodash/kebabCase' -import path from 'path' - -import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -import { - getGithubUser, - validateMaintainerAccess -} from '../../../utils/github-cli' -import { checkAuth } from '../../../utils/registry' -import { generateModuleRegistries } from '../functions/registry/generator' - -interface PackageJson { - name?: string - version?: string - exports?: Record - [key: string]: unknown -} - -function toNewFolderName(oldName: string, username?: string): string { - // codeTime -> lifeforge--code-time (kebab-case) - // or with username: invoiceMaker -> melvinchia3636--invoice-maker - const normalized = kebabCase(oldName) - - if (username) { - return `${username}--${normalized}` - } - - if (normalized.startsWith('lifeforge-')) { - // Already has lifeforge prefix, just add the extra dash - return normalized.replace('lifeforge-', 'lifeforge--') - } - - return `lifeforge--${normalized}` -} - -function toPackageName(folderName: string): string { - // lifeforge--code-time -> @lifeforge/lifeforge--code-time - // melvinchia3636--invoice-maker -> @lifeforge/melvinchia3636--invoice-maker - return `@lifeforge/${folderName}` -} - -function getUnmigratedModules(): string[] { - const appsDir = path.join(process.cwd(), 'apps') - - if (!fs.existsSync(appsDir)) { - return [] - } - - const entries = fs.readdirSync(appsDir, { withFileTypes: true }) - - return entries - .filter(entry => entry.isDirectory()) - .filter(entry => !entry.name.includes('--')) - .filter(entry => !entry.name.startsWith('.')) - .map(entry => entry.name) -} - -async function migrateSingleModule( - moduleName: string, - username?: string, - skipGenHandler = false -): Promise { - const appsDir = path.join(process.cwd(), 'apps') - - const oldPath = path.join(appsDir, moduleName) - - // Check if module exists - if (!fs.existsSync(oldPath)) { - CLILoggingService.warn(`Module "${moduleName}" not found in apps/`) - - return false - } - - // Check if already migrated - if (moduleName.startsWith('lifeforge--')) { - CLILoggingService.debug(`Module "${moduleName}" already migrated, skipping`) - - return false - } - - const newFolderName = toNewFolderName(moduleName, username) - - const newPath = path.join(appsDir, newFolderName) - - const packageName = toPackageName(newFolderName) - - CLILoggingService.step(`Migrating "${moduleName}" → "${newFolderName}"`) - - try { - // Step 1: Rename folder - if (fs.existsSync(newPath)) { - CLILoggingService.warn( - `Target folder "${newFolderName}" already exists, skipping` - ) - - return false - } - - fs.renameSync(oldPath, newPath) - - // Step 2: Remove .git submodule reference - const gitPath = path.join(newPath, '.git') - - if (fs.existsSync(gitPath)) { - const gitStat = fs.statSync(gitPath) - - if (gitStat.isFile()) { - fs.unlinkSync(gitPath) - } else { - fs.rmSync(gitPath, { recursive: true, force: true }) - } - } - - // Step 3: Update package.json - const packageJsonPath = path.join(newPath, 'package.json') - - const packageJson: PackageJson = JSON.parse( - fs.readFileSync(packageJsonPath, 'utf-8') - ) - - packageJson.name = packageName - - if (packageJson.version && !packageJson.version.match(/^\d+\.\d+\.\d+/)) { - packageJson.version = '0.1.0' - } - - // Populate author if missing - if (!packageJson.author) { - CLILoggingService.progress( - 'Fetching GitHub user details for author field...' - ) - - const user = getGithubUser() - - if (user) { - packageJson.author = `${user.name} <${user.email}>` - CLILoggingService.success(`Set author to: ${packageJson.author}`) - } else { - CLILoggingService.warn( - 'Could not fetch GitHub user details for author field' - ) - } - } - - const hasServerIndex = fs.existsSync( - path.join(newPath, 'server', 'index.ts') - ) - - const hasSchema = fs.existsSync(path.join(newPath, 'server', 'schema.ts')) - - packageJson.exports = { - ...(hasServerIndex && { './server': './server/index.ts' }), - './manifest': './manifest.ts', - ...(hasSchema && { './server/schema': './server/schema.ts' }) - } - - fs.writeFileSync( - packageJsonPath, - JSON.stringify(packageJson, null, 2) + '\n' - ) - - // Step 4: Add to root package.json - const rootPackageJsonPath = path.join(process.cwd(), 'package.json') - - const rootPackageJson = JSON.parse( - fs.readFileSync(rootPackageJsonPath, 'utf-8') - ) - - if (!rootPackageJson.dependencies) { - rootPackageJson.dependencies = {} - } - - rootPackageJson.dependencies[packageName] = 'workspace:*' - - fs.writeFileSync( - rootPackageJsonPath, - JSON.stringify(rootPackageJson, null, 2) + '\n' - ) - - CLILoggingService.success(`Migrated "${moduleName}" → "${packageName}"`) - - // Only run bun install and gen if not batching - if (!skipGenHandler) { - executeCommand('bun install', { - cwd: process.cwd(), - stdio: 'inherit' - }) - - generateModuleRegistries() - } - - return true - } catch (error) { - CLILoggingService.error(`Failed to migrate "${moduleName}": ${error}`) - - return false - } -} - -export async function migrateModuleHandler( - folderName?: string, - options?: { official?: boolean } -): Promise { - // Check authentication first - CLILoggingService.progress('Checking registry authentication...') - - const auth = await checkAuth() - - if (!auth.authenticated || !auth.username) { - CLILoggingService.actionableError( - 'Authentication required to migrate modules', - 'Run: bun forge modules login' - ) - process.exit(1) - } - - CLILoggingService.success(`Authenticated as ${auth.username}`) - - let username = auth.username - - if (options?.official) { - const isMaintainer = validateMaintainerAccess(auth.username) - - if (!isMaintainer) { - CLILoggingService.actionableError( - 'Maintainer access required', - 'You must have maintainer access to lifeforge-app/lifeforge to migrate as official module' - ) - process.exit(1) - } - - username = 'lifeforge' // Use lifeforge as the "username" prefix for official modules - } - - // If no folder specified, migrate all unmigrated modules - if (!folderName) { - const unmigrated = getUnmigratedModules() - - if (unmigrated.length === 0) { - CLILoggingService.info('No unmigrated modules found in apps/') - - return - } - - CLILoggingService.step(`Found ${unmigrated.length} unmigrated module(s):`) - unmigrated.forEach(mod => CLILoggingService.info(` - ${mod}`)) - - let migratedCount = 0 - - for (const mod of unmigrated) { - const success = await migrateSingleModule(mod, username, true) - - if (success) { - migratedCount++ - } - } - - // Run bun install and gen once at the end - if (migratedCount > 0) { - CLILoggingService.progress('Linking workspaces...') - - executeCommand('bun install', { - cwd: process.cwd(), - stdio: 'inherit' - }) - - CLILoggingService.progress('Generating registries...') - generateModuleRegistries() - - CLILoggingService.success(`Migrated ${migratedCount} module(s)`) - } - - return - } - - // Normalize folder name (remove apps/ prefix if present) - const moduleName = folderName.replace(/^apps\//, '') - - await migrateSingleModule(moduleName, username) -} diff --git a/tools/src/commands/modules/handlers/publish-module.ts b/tools/src/commands/modules/handlers/publish-module.ts deleted file mode 100644 index 3ee065274..000000000 --- a/tools/src/commands/modules/handlers/publish-module.ts +++ /dev/null @@ -1,281 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { confirmAction, executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -import { - checkAuth, - getRegistryUrl, - openRegistryLogin -} from '../../../utils/registry' -import { validateMaintainerAccess } from '../functions' - -const LIFEFORGE_SCOPE = '@lifeforge' - -interface PackageJson { - name?: string - version?: string - description?: string - exports?: Record - [key: string]: unknown -} - -interface ModuleValidationResult { - valid: boolean - errors: string[] - warnings: string[] -} - -function validateModuleStructure(modulePath: string): ModuleValidationResult { - const errors: string[] = [] - - const warnings: string[] = [] - - // Check package.json - const packageJsonPath = path.join(modulePath, 'package.json') - - if (!fs.existsSync(packageJsonPath)) { - errors.push('Missing package.json') - - return { valid: false, errors, warnings } - } - - const packageJson: PackageJson = JSON.parse( - fs.readFileSync(packageJsonPath, 'utf-8') - ) - - // Check name follows @lifeforge/-- pattern - if (!packageJson.name) { - errors.push('package.json is missing "name" field') - } else if (!packageJson.name.startsWith(`${LIFEFORGE_SCOPE}/`)) { - errors.push(`Package name must start with "${LIFEFORGE_SCOPE}/"`) - } else { - const nameWithoutScope = packageJson.name.replace(`${LIFEFORGE_SCOPE}/`, '') - - if (!nameWithoutScope.includes('--')) { - errors.push( - 'Package name must follow format @lifeforge/--' - ) - } - } - - // Check version is semver - if (!packageJson.version) { - errors.push('package.json is missing "version" field') - } else if (!packageJson.version.match(/^\d+\.\d+\.\d+/)) { - errors.push('Version must be valid semver (e.g., 0.1.0)') - } - - // Check exports field - if (!packageJson.exports) { - errors.push('package.json is missing "exports" field') - } else { - if (!packageJson.exports['./manifest']) { - errors.push('exports must include "./manifest"') - } - } - - // Check manifest.ts exists - const manifestPath = path.join(modulePath, 'manifest.ts') - - if (!fs.existsSync(manifestPath)) { - errors.push('Missing manifest.ts') - } - - // Check client directory - const clientPath = path.join(modulePath, 'client') - - if (!fs.existsSync(clientPath)) { - warnings.push('No client/ directory found') - } - - // Check locales directory - const localesPath = path.join(modulePath, 'locales') - - if (!fs.existsSync(localesPath)) { - warnings.push('No locales/ directory found') - } - - // Check server if exports reference it - if (packageJson.exports?.['./server']) { - const serverIndexPath = path.join(modulePath, 'server', 'index.ts') - - if (!fs.existsSync(serverIndexPath)) { - errors.push('exports references "./server" but server/index.ts not found') - } - } - - if (packageJson.exports?.['./server/schema']) { - const schemaPath = path.join(modulePath, 'server', 'schema.ts') - - if (!fs.existsSync(schemaPath)) { - errors.push( - 'exports references "./server/schema" but server/schema.ts not found' - ) - } - } - - return { - valid: errors.length === 0, - errors, - warnings - } -} - -async function promptNpmLogin(): Promise { - CLILoggingService.info('You need to authenticate with the registry first.') - CLILoggingService.info( - 'The new authentication flow requires a browser login.' - ) - - const shouldLogin = await confirmAction( - 'Would you like to open the login page now?' - ) - - if (shouldLogin) { - openRegistryLogin() - - CLILoggingService.info('After logging in and copying your token, run:') - CLILoggingService.info(' bun forge modules login') - - return false // Return false to stop execution and let user run login command - } - - return false -} - -export async function publishModuleHandler( - folderName: string, - options?: { official?: boolean } -): Promise { - // Normalize folder name - const moduleName = folderName.replace(/^apps\//, '') - - const modulePath = path.join(process.cwd(), 'apps', moduleName) - - // Check module exists - if (!fs.existsSync(modulePath)) { - CLILoggingService.actionableError( - `Module "${moduleName}" not found in apps/`, - 'Make sure the module exists in the apps directory' - ) - process.exit(1) - } - - CLILoggingService.step(`Validating module "${moduleName}"...`) - - // Validate structure - const validation = validateModuleStructure(modulePath) - - if (validation.warnings.length > 0) { - validation.warnings.forEach(warning => { - CLILoggingService.warn(` ⚠ ${warning}`) - }) - } - - if (!validation.valid) { - CLILoggingService.error('Module validation failed:') - validation.errors.forEach(error => { - CLILoggingService.error(` ✗ ${error}`) - }) - process.exit(1) - } - - CLILoggingService.success('Module structure is valid') - - // Check authentication - CLILoggingService.progress('Checking registry authentication...') - - let auth = await checkAuth() - - if (!auth.authenticated) { - const loggedIn = await promptNpmLogin() - - if (!loggedIn) { - CLILoggingService.actionableError( - 'Authentication required to publish', - `Run: bun forge modules login` - ) - process.exit(1) - } - - auth = await checkAuth() - - if (!auth.authenticated) { - CLILoggingService.error('Authentication failed') - process.exit(1) - } - } - - CLILoggingService.success(`Authenticated as ${auth.username}`) - - // Read package.json for display - const packageJson: PackageJson = JSON.parse( - fs.readFileSync(path.join(modulePath, 'package.json'), 'utf-8') - ) - - // Verify authenticated user matches package name prefix - const nameWithoutScope = (packageJson.name || '').replace( - `${LIFEFORGE_SCOPE}/`, - '' - ) - - const usernamePrefix = nameWithoutScope.split('--')[0] - - if (usernamePrefix && usernamePrefix !== auth.username) { - // Check if publishing as official module and prefix is lifeforge - if (options?.official && usernamePrefix === 'lifeforge') { - const isMaintainer = validateMaintainerAccess(auth.username || '') - - if (!isMaintainer) { - CLILoggingService.actionableError( - 'Maintainer access required', - 'You must have maintainer access to lifeforge-app/lifeforge to publish official modules' - ) - process.exit(1) - } - // Pass validation if maintainer - } else { - CLILoggingService.actionableError( - `Cannot publish as "${auth.username}" - package belongs to "${usernamePrefix}"`, - `You can only publish packages starting with @lifeforge/${auth.username}--` - ) - process.exit(1) - } - } - - CLILoggingService.info(`Package: ${packageJson.name}@${packageJson.version}`) - CLILoggingService.info(`Description: ${packageJson.description || '(none)'}`) - - // Confirm publish - const shouldPublish = await confirmAction( - `Publish ${packageJson.name}@${packageJson.version} to registry?` - ) - - if (!shouldPublish) { - CLILoggingService.info('Publish cancelled') - - return - } - - // Publish to registry - CLILoggingService.progress('Publishing to registry...') - - try { - executeCommand(`npm publish --registry ${getRegistryUrl()}`, { - cwd: modulePath, - stdio: 'inherit' - }) - - CLILoggingService.success( - `Published ${packageJson.name}@${packageJson.version} to registry!` - ) - CLILoggingService.info('') - CLILoggingService.info('Others can install with:') - CLILoggingService.info(` bun forge modules install ${packageJson.name}`) - } catch (error) { - CLILoggingService.error(`Publish failed: ${error}`) - process.exit(1) - } -} diff --git a/tools/src/commands/modules/handlers/publishModuleHandler.ts b/tools/src/commands/modules/handlers/publishModuleHandler.ts new file mode 100644 index 000000000..58c013e64 --- /dev/null +++ b/tools/src/commands/modules/handlers/publishModuleHandler.ts @@ -0,0 +1,34 @@ +import fs from 'fs' +import path from 'path' + +import { executeCommand } from '@/utils/helpers' +import Logging from '@/utils/logging' + +import { getRegistryUrl } from '../../../utils/registry' +import validateModuleAuthor from '../functions/validateModuleAuthor' +import validateModuleStructure from '../functions/validateModuleStructure' + +export async function publishModuleHandler(moduleName: string): Promise { + const modulePath = path.join(process.cwd(), 'apps', moduleName) + + if (!fs.existsSync(modulePath)) { + Logging.actionableError( + `Module "${moduleName}" not found in apps/`, + 'Make sure the module exists in the apps directory' + ) + process.exit(1) + } + + await validateModuleStructure(modulePath) + await validateModuleAuthor(modulePath) + + try { + executeCommand(`npm publish --registry ${getRegistryUrl()}`, { + cwd: modulePath, + stdio: 'inherit' + }) + } catch (error) { + Logging.error(`Publish failed: ${error}`) + process.exit(1) + } +} diff --git a/tools/src/commands/modules/handlers/uninstall-module.ts b/tools/src/commands/modules/handlers/uninstall-module.ts deleted file mode 100644 index b3bcda676..000000000 --- a/tools/src/commands/modules/handlers/uninstall-module.ts +++ /dev/null @@ -1,125 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -import { generateModuleRegistries } from '../functions/registry/generator' - -interface PackageJson { - dependencies?: Record - [key: string]: unknown -} - -function extractModuleName(packageName: string): string { - // @lifeforge/lifeforge--calendar -> lifeforge--calendar - // @lifeforge/melvin--myapp -> melvin--myapp - return packageName.replace('@lifeforge/', '') -} - -function findModulePackageName( - shortName: string, - dependencies: Record -): string | null { - // Try to find the full package name from dependencies - for (const dep of Object.keys(dependencies)) { - if (dep.startsWith('@lifeforge/') && extractModuleName(dep) === shortName) { - return dep - } - } - - return null -} - -export async function uninstallModuleHandler( - moduleName: string -): Promise { - const rootPackageJsonPath = path.join(process.cwd(), 'package.json') - - const rootPackageJson: PackageJson = JSON.parse( - fs.readFileSync(rootPackageJsonPath, 'utf-8') - ) - - // Determine the full package name and short name - let fullPackageName: string - let shortName: string - - if (moduleName.startsWith('@lifeforge/')) { - fullPackageName = moduleName - shortName = extractModuleName(moduleName) - } else { - shortName = moduleName - - const found = findModulePackageName( - shortName, - rootPackageJson.dependencies || {} - ) - - if (!found) { - CLILoggingService.actionableError( - `Module "${shortName}" is not installed`, - 'Run "bun forge modules list" to see installed modules' - ) - - return - } - - fullPackageName = found - } - - const appsDir = path.join(process.cwd(), 'apps') - - const targetDir = path.join(appsDir, shortName) - - CLILoggingService.info(`Uninstalling module ${fullPackageName}...`) - - // Check if module exists in apps/ - if (!fs.existsSync(targetDir)) { - CLILoggingService.warn(`Module not found in apps/${shortName}`) - } else { - // Remove from apps/ - CLILoggingService.progress('Removing module files...') - fs.rmSync(targetDir, { recursive: true, force: true }) - CLILoggingService.success(`Removed apps/${shortName}`) - } - - // Remove from package.json - if (rootPackageJson.dependencies?.[fullPackageName]) { - CLILoggingService.progress('Updating package.json...') - delete rootPackageJson.dependencies[fullPackageName] - - fs.writeFileSync( - rootPackageJsonPath, - JSON.stringify(rootPackageJson, null, 2) + '\n' - ) - - CLILoggingService.success('Updated root package.json') - } - - // Remove from node_modules - const nodeModulesPath = path.join( - process.cwd(), - 'node_modules', - fullPackageName - ) - - if (fs.existsSync(nodeModulesPath)) { - fs.rmSync(nodeModulesPath, { recursive: true, force: true }) - } - - // Run bun install to clean up - CLILoggingService.progress('Cleaning up...') - - executeCommand('bun install', { - cwd: process.cwd(), - stdio: 'inherit' - }) - - // Regenerate module registries - CLILoggingService.progress('Regenerating module registries...') - generateModuleRegistries() - - CLILoggingService.success( - `Module ${fullPackageName} uninstalled successfully!` - ) -} diff --git a/tools/src/commands/modules/handlers/uninstallModuleHandler.ts b/tools/src/commands/modules/handlers/uninstallModuleHandler.ts new file mode 100644 index 000000000..1bfaac42f --- /dev/null +++ b/tools/src/commands/modules/handlers/uninstallModuleHandler.ts @@ -0,0 +1,34 @@ +import fs from 'fs' + +import { executeCommand } from '@/utils/helpers' +import Logging from '@/utils/logging' +import { findPackageName, removeDependency } from '@/utils/packageJson' + +import getFsMetadata from '../functions/getFsMetadata' +import generateSchemaRegistry from '../functions/registry/generateSchemaRegistry' +import generateServerRegistry from '../functions/registry/generateServerRegistry' + +export async function uninstallModuleHandler( + moduleName: string +): Promise { + const { targetDir, fullName } = getFsMetadata(moduleName) + + if (!findPackageName(fullName)) { + Logging.error(`Module ${fullName} not found`) + + return + } + + removeDependency(fullName) + + fs.rmSync(targetDir, { recursive: true, force: true }) + + executeCommand('bun install', { + cwd: process.cwd(), + stdio: 'inherit' + }) + + generateServerRegistry() + + generateSchemaRegistry() +} diff --git a/tools/src/commands/modules/handlers/upgrade-module.ts b/tools/src/commands/modules/handlers/upgrade-module.ts deleted file mode 100644 index b73f1ba28..000000000 --- a/tools/src/commands/modules/handlers/upgrade-module.ts +++ /dev/null @@ -1,292 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { confirmAction, executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' - -import { - checkAuth, - getRegistryUrl, - openRegistryLogin -} from '../../../utils/registry' -import { generateModuleRegistries } from '../functions/registry/generator' - -const LIFEFORGE_SCOPE = '@lifeforge/' - -interface PackageJson { - name?: string - version?: string - dependencies?: Record - [key: string]: unknown -} - -function getInstalledModules(): { - name: string - version: string - folder: string -}[] { - const rootPackageJsonPath = path.join(process.cwd(), 'package.json') - - const rootPackageJson: PackageJson = JSON.parse( - fs.readFileSync(rootPackageJsonPath, 'utf-8') - ) - - const modules: { name: string; version: string; folder: string }[] = [] - - for (const [dep, version] of Object.entries( - rootPackageJson.dependencies || {} - )) { - if (dep.startsWith(LIFEFORGE_SCOPE) && version === 'workspace:*') { - // Get version from the module's package.json - const folderName = dep.replace(LIFEFORGE_SCOPE, '') - - const modulePath = path.join( - process.cwd(), - 'apps', - folderName, - 'package.json' - ) - - if (fs.existsSync(modulePath)) { - const modulePackageJson = JSON.parse( - fs.readFileSync(modulePath, 'utf-8') - ) - - modules.push({ - name: dep, - version: modulePackageJson.version || '0.0.0', - folder: folderName - }) - } - } - } - - return modules -} - -async function getLatestVersion(packageName: string): Promise { - const registry = getRegistryUrl() - - try { - // Query local Verdaccio registry using bun - const response = await fetch(`${registry}${packageName}`) - - // If unauthorized, check auth and prompt login - if (response.status === 401 || response.status === 403) { - const auth = await checkAuth() - - if (!auth.authenticated) { - CLILoggingService.info( - `Authentication required to check updates for ${packageName}` - ) - - const shouldLogin = await confirmAction( - 'Would you like to open the login page now?' - ) - - if (shouldLogin) { - openRegistryLogin() - CLILoggingService.info( - 'After logging in and copying your token, run: bun forge modules login' - ) - CLILoggingService.info('Then try upgrading again.') - - return null - } - } - } - - if (!response.ok) { - return null - } - - const data = (await response.json()) as { - 'dist-tags'?: { latest?: string } - } - - return data['dist-tags']?.latest || null - } catch { - return null - } -} - -function compareVersions(current: string, latest: string): number { - const currentParts = current.split('.').map(Number) - - const latestParts = latest.split('.').map(Number) - - for (let i = 0; i < 3; i++) { - const c = currentParts[i] || 0 - - const l = latestParts[i] || 0 - - if (l > c) { - return 1 - } - - if (l < c) { - return -1 - } - } - - return 0 -} - -async function upgradeModule( - packageName: string, - folder: string, - currentVersion: string -): Promise { - const latestVersion = await getLatestVersion(packageName) - - if (!latestVersion) { - CLILoggingService.warn(`Could not check registry for ${packageName}`) - - return false - } - - if (compareVersions(currentVersion, latestVersion) >= 0) { - CLILoggingService.info(`${packageName}@${currentVersion} is up to date`) - - return false - } - - CLILoggingService.info( - `Update available: ${packageName} ${currentVersion} → ${latestVersion}` - ) - - const shouldUpgrade = await confirmAction( - `Upgrade ${packageName}? This will replace your local copy.` - ) - - if (!shouldUpgrade) { - CLILoggingService.info(`Skipping ${packageName}`) - - return false - } - - const appsDir = path.join(process.cwd(), 'apps') - - const modulePath = path.join(appsDir, folder) - - const backupPath = path.join(appsDir, `${folder}.backup`) - - try { - // Backup current module - CLILoggingService.progress(`Backing up ${folder}...`) - - if (fs.existsSync(backupPath)) { - fs.rmSync(backupPath, { recursive: true, force: true }) - } - - fs.cpSync(modulePath, backupPath, { recursive: true }) - - // Remove current module - fs.rmSync(modulePath, { recursive: true, force: true }) - - // Fetch latest from registry - CLILoggingService.progress(`Fetching ${packageName}@${latestVersion}...`) - - executeCommand(`bun add ${packageName}@latest`, { - cwd: process.cwd(), - stdio: 'inherit' - }) - - // Find installed path in node_modules - const installedPath = path.join(process.cwd(), 'node_modules', packageName) - - if (!fs.existsSync(installedPath)) { - throw new Error(`Failed to fetch ${packageName} from registry`) - } - - // Copy to apps/ - fs.cpSync(installedPath, modulePath, { recursive: true }) - - // Remove node_modules copy so bun creates symlink - fs.rmSync(installedPath, { recursive: true, force: true }) - - // Run bun install - executeCommand('bun install', { - cwd: process.cwd(), - stdio: 'inherit' - }) - - // Remove backup on success - fs.rmSync(backupPath, { recursive: true, force: true }) - - CLILoggingService.success(`Upgraded ${packageName} to ${latestVersion}`) - - return true - } catch (error) { - CLILoggingService.error(`Failed to upgrade ${packageName}: ${error}`) - - // Restore from backup if exists - if (fs.existsSync(backupPath)) { - CLILoggingService.progress('Restoring from backup...') - - if (fs.existsSync(modulePath)) { - fs.rmSync(modulePath, { recursive: true, force: true }) - } - - fs.renameSync(backupPath, modulePath) - CLILoggingService.info('Restored previous version') - } - - return false - } -} - -export async function upgradeModuleHandler(moduleName?: string): Promise { - const modules = getInstalledModules() - - if (modules.length === 0) { - CLILoggingService.info('No @lifeforge/* modules installed') - - return - } - - let upgradedCount = 0 - - if (moduleName) { - // Upgrade specific module - const normalizedName = moduleName.startsWith(LIFEFORGE_SCOPE) - ? moduleName - : `${LIFEFORGE_SCOPE}${moduleName}` - - const mod = modules.find( - m => m.name === normalizedName || m.folder === moduleName - ) - - if (!mod) { - CLILoggingService.actionableError( - `Module "${moduleName}" not found`, - 'Run "bun forge modules list" to see installed modules' - ) - process.exit(1) - } - - const upgraded = await upgradeModule(mod.name, mod.folder, mod.version) - - if (upgraded) { - upgradedCount++ - } - } else { - // Check all modules for updates - CLILoggingService.step('Checking for updates...') - - for (const mod of modules) { - const upgraded = await upgradeModule(mod.name, mod.folder, mod.version) - - if (upgraded) { - upgradedCount++ - } - } - } - - if (upgradedCount > 0) { - CLILoggingService.progress('Regenerating registries...') - generateModuleRegistries() - - CLILoggingService.success(`Upgraded ${upgradedCount} module(s)`) - } -} diff --git a/tools/src/commands/modules/handlers/upgradeModuleHandler.ts b/tools/src/commands/modules/handlers/upgradeModuleHandler.ts new file mode 100644 index 000000000..499a166c6 --- /dev/null +++ b/tools/src/commands/modules/handlers/upgradeModuleHandler.ts @@ -0,0 +1,106 @@ +import fs from 'fs' +import path from 'path' +import semver from 'semver' + +import { generateMigrationsHandler } from '@/commands/db/handlers/generateMigrationsHandler' +import Logging from '@/utils/logging' +import { getPackageLatestVersion } from '@/utils/registry' + +import getFsMetadata from '../functions/getFsMetadata' +import installModulePackage from '../functions/installModulePackage' +import linkModuleToWorkspace from '../functions/linkModuleToWorkspace' +import listModules from '../functions/listModules' +import generateSchemaRegistry from '../functions/registry/generateSchemaRegistry' +import generateServerRegistry from '../functions/registry/generateServerRegistry' + +async function upgradeModule( + packageName: string, + currentVersion: string +): Promise { + const { fullName, targetDir } = getFsMetadata(packageName) + + const latestVersion = await getPackageLatestVersion(fullName) + + if (!latestVersion) { + Logging.warn(`Could not check registry for ${fullName}`) + + return false + } + + if (semver.eq(currentVersion, latestVersion)) { + Logging.info(`${packageName}@${currentVersion} is up to date`) + + return false + } + + const backupPath = path.join(path.dirname(targetDir), `${packageName}.backup`) + + try { + if (fs.existsSync(backupPath)) { + fs.rmSync(backupPath, { recursive: true, force: true }) + } + + fs.cpSync(targetDir, backupPath, { recursive: true }) + + fs.rmSync(targetDir, { recursive: true, force: true }) + + installModulePackage(fullName, targetDir) + + linkModuleToWorkspace(fullName) + + fs.rmSync(backupPath, { recursive: true, force: true }) + + return true + } catch (error) { + Logging.error(`Failed to upgrade ${fullName}: ${error}`) + + if (fs.existsSync(backupPath)) { + fs.renameSync(backupPath, targetDir) + } + + return false + } +} + +export async function upgradeModuleHandler(moduleName?: string): Promise { + const modules = listModules() + + let upgradedCount = 0 + + if (moduleName) { + const { fullName } = getFsMetadata(moduleName) + + const mod = modules[fullName] + + if (!mod) { + Logging.actionableError( + `Module "${moduleName}" not found`, + 'Run "bun forge modules list" to see installed modules' + ) + + process.exit(1) + } + + const upgraded = await upgradeModule(fullName, mod.version) + + if (upgraded) { + upgradedCount++ + } + } else { + for (const [name, { version }] of Object.entries(modules)) { + const upgraded = await upgradeModule(name, version) + + if (upgraded) { + upgradedCount++ + } + } + } + + if (upgradedCount > 0) { + generateServerRegistry() + + generateSchemaRegistry() + + generateMigrationsHandler() + } +} diff --git a/tools/src/commands/modules/index.ts b/tools/src/commands/modules/index.ts index 551ea7ec1..ebc9c5ce3 100644 --- a/tools/src/commands/modules/index.ts +++ b/tools/src/commands/modules/index.ts @@ -1,24 +1,17 @@ import type { Command } from 'commander' -import { createModuleHandler } from './handlers/create-module' -import { installModuleHandler } from './handlers/install-module' -import { listModulesHandler } from './handlers/list-modules' -import { loginModuleHandler } from './handlers/login-module' -import { migrateModuleHandler } from './handlers/migrate-module' -import { publishModuleHandler } from './handlers/publish-module' -import { uninstallModuleHandler } from './handlers/uninstall-module' -import { upgradeModuleHandler } from './handlers/upgrade-module' +import { createModuleHandler } from './handlers/createModuleHandler' +import { installModuleHandler } from './handlers/installModuleHandler' +import { listModulesHandler } from './handlers/listModuleHandler' +import { publishModuleHandler } from './handlers/publishModuleHandler' +import { uninstallModuleHandler } from './handlers/uninstallModuleHandler' +import { upgradeModuleHandler } from './handlers/upgradeModuleHandler' export default function setup(program: Command): void { const command = program .command('modules') .description('Manage LifeForge modules') - command - .command('login') - .description('Login to the module registry') - .action(loginModuleHandler) - command .command('list') .description('List all installed modules') @@ -66,17 +59,4 @@ export default function setup(program: Command): void { 'Publish as official module (requires maintainer access)' ) .action(publishModuleHandler) - - command - .command('migrate') - .description('Migrate legacy modules to the new package architecture') - .argument( - '[folder]', - 'Module folder name (optional, migrates all if omitted)' - ) - .option( - '--official', - 'Migrate as official module (requires maintainer access)' - ) - .action(migrateModuleHandler) } diff --git a/tools/src/commands/modules/utils/file-system.ts b/tools/src/commands/modules/utils/file-system.ts deleted file mode 100644 index 4916d86bc..000000000 --- a/tools/src/commands/modules/utils/file-system.ts +++ /dev/null @@ -1,56 +0,0 @@ -import fs from 'fs' -import path from 'path' - -/** - * File system utilities for module operations - */ - -/** - * Checks if module already exists in the apps directory - */ -export function moduleExists(moduleName: string): boolean { - return fs.existsSync(`apps/${moduleName}`) -} - -/** - * Checks if module has server components - */ -export function hasServerComponents(moduleName: string): { - hasServerDir: boolean - hasServerIndex: boolean -} { - const serverPath = path.resolve(`apps/${moduleName}/server`) - - const serverIndexPath = path.resolve(`apps/${moduleName}/server/index.ts`) - - return { - hasServerDir: fs.existsSync(serverPath), - hasServerIndex: fs.existsSync(serverIndexPath) - } -} - -/** - * Gets list of installed modules from apps directory - */ -export function getInstalledModules(): string[] { - const appsDir = 'apps' - - if (!fs.existsSync(appsDir)) { - return [] - } - - return fs - .readdirSync(appsDir, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => dirent.name) - .filter(name => !name.startsWith('.')) // Exclude hidden directories -} - -/** - * Cleans up temporary directory - */ -export function cleanup(tempDir: string): void { - if (fs.existsSync(tempDir)) { - fs.rmdirSync(tempDir, { recursive: true }) - } -} diff --git a/tools/src/commands/project/functions/validateProjectArguments.ts b/tools/src/commands/project/functions/validateProjectArguments.ts index d4d3d7746..ffff9fd93 100644 --- a/tools/src/commands/project/functions/validateProjectArguments.ts +++ b/tools/src/commands/project/functions/validateProjectArguments.ts @@ -1,4 +1,4 @@ -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { PROJECTS } from '../constants/projects' @@ -26,7 +26,7 @@ export function validateProjectArguments(projects: string[] | undefined): void { const validation = validateProjects(projects, validProjects) if (!validation.isValid) { - CLILoggingService.options( + Logging.options( `Invalid project(s): ${validation.invalidProjects.join(', ')}`, validProjects ) diff --git a/tools/src/constants/constants.ts b/tools/src/constants/constants.ts index 6193bad9c..b34301a99 100644 --- a/tools/src/constants/constants.ts +++ b/tools/src/constants/constants.ts @@ -4,7 +4,7 @@ import path from 'path' /** * Directory containing all tools */ -export const TOOLS_DIR = path.join(__dirname, '../../../../tools') +export const TOOLS_DIR = path.join(__dirname, '../../../tools') /** * Dynamically discovered tools from the tools directory @@ -25,3 +25,16 @@ export const AVAILABLE_TEMPLATE_MODULE_TYPES = { 'client-only': 'Client-side only functionality', widget: 'Standalone widget component' } as const + +const GENERATED_DIR = path.join( + import.meta.dirname.split('/tools')[0], + 'server/src/generated' +) + +if (!fs.existsSync(GENERATED_DIR)) { + fs.mkdirSync(GENERATED_DIR, { recursive: true }) +} + +export const SERVER_ROUTES_PATH = path.join(GENERATED_DIR, 'routes.ts') + +export const SERVER_SCHEMA_PATH = path.join(GENERATED_DIR, 'schemas.ts') diff --git a/tools/src/constants/db.ts b/tools/src/constants/db.ts index 25557b0c5..d80672885 100644 --- a/tools/src/constants/db.ts +++ b/tools/src/constants/db.ts @@ -3,7 +3,7 @@ import fs from 'fs' import path from 'path' import { getEnvVar, isDockerMode } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' dotenv.config({ path: path.resolve(process.cwd(), 'env/.env.local'), @@ -31,6 +31,6 @@ export const PB_KWARGS = [ try { fs.accessSync(PB_DIR) } catch (error) { - CLILoggingService.error(`PB_DIR is not accessible: ${error}`) + Logging.error(`PB_DIR is not accessible: ${error}`) process.exit(1) } diff --git a/tools/src/index.ts b/tools/src/index.ts index 3edffd02c..e5e788ec7 100644 --- a/tools/src/index.ts +++ b/tools/src/index.ts @@ -4,7 +4,7 @@ import fs from 'fs' import path from 'path' import { runCLI, setupCLI } from './cli/setup' -import CLILoggingService from './utils/logging' +import Logging from './utils/logging' /** * LifeForge Forge - Build and development tool for LifeForge projects @@ -21,7 +21,7 @@ const envPath = path.resolve(process.cwd(), 'env/.env.local') if (fs.existsSync(envPath)) { dotenv.config({ path: envPath, quiet: true }) } else { - CLILoggingService.warn( + Logging.warn( `Environment file not found at ${envPath}. Continuing without loading environment variables from file.` ) } @@ -31,5 +31,5 @@ try { await setupCLI() runCLI() } catch (error) { - CLILoggingService.fatal(`Unexpected error occurred: ${error}`) + Logging.fatal(`Unexpected error occurred: ${error}`) } diff --git a/tools/src/utils/ai.ts b/tools/src/utils/ai.ts index 5465cc293..002e34db8 100644 --- a/tools/src/utils/ai.ts +++ b/tools/src/utils/ai.ts @@ -5,7 +5,7 @@ import ora from 'ora' import z from 'zod' import { getEnvVars } from './helpers' -import CLILoggingService from './logging' +import Logging from './logging' import getPBInstance from './pocketbase' import { zodTextFormat } from './zodResponseFormat' @@ -34,7 +34,7 @@ export async function getAPIKey(): Promise { CryptoJS.enc.Utf8 ) } catch { - CLILoggingService.error('Failed to decrypt OpenAI API key.') + Logging.error('Failed to decrypt OpenAI API key.') return null } finally { @@ -50,7 +50,7 @@ export async function fetchAI({ const apiKey = await getAPIKey() if (!apiKey) { - CLILoggingService.error('OpenAI API key not found.') + Logging.error('OpenAI API key not found.') return null } diff --git a/tools/src/utils/github-cli.ts b/tools/src/utils/github-cli.ts index a921b0854..07f89846e 100644 --- a/tools/src/utils/github-cli.ts +++ b/tools/src/utils/github-cli.ts @@ -1,11 +1,9 @@ import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' -export function validateMaintainerAccess(username: string) { +export function validateMaintainerAccess(username: string): void { try { - CLILoggingService.progress( - `Checking maintainer privileges for ${username}...` - ) + Logging.progress(`Checking maintainer privileges for ${username}...`) // Check permission level on the official repo const result = executeCommand( @@ -22,16 +20,18 @@ export function validateMaintainerAccess(username: string) { const allowedPermissions = ['admin', 'maintain', 'write'] if (allowedPermissions.includes(response.permission)) { - CLILoggingService.success(`Verified maintainer access for ${username}`) + Logging.success(`Verified maintainer access for ${username}`) + + return } - CLILoggingService.warn( + Logging.warn( 'Failed to verify maintainer access. Ensure you are authenticated with "gh auth login".' ) process.exit(1) } catch (error) { - CLILoggingService.actionableError( + Logging.actionableError( `Failed to check maintainer access for ${username}.`, `Error: ${error instanceof Error ? error.message : String(error)}` ) @@ -77,7 +77,7 @@ export function getGithubUser(): { name: string; email: string } | null { return null } catch (error) { - CLILoggingService.debug(`Failed to fetch GitHub user info: ${error}`) + Logging.debug(`Failed to fetch GitHub user info: ${error}`) return null } diff --git a/tools/src/utils/helpers.ts b/tools/src/utils/helpers.ts index 223a478a3..5092845ce 100644 --- a/tools/src/utils/helpers.ts +++ b/tools/src/utils/helpers.ts @@ -5,9 +5,7 @@ import path from 'path' import prompts from 'prompts' import type { CommandExecutionOptions } from '../types' -import CLILoggingService from './logging' - - +import Logging from './logging' /** * Executes a shell command with proper error handling @@ -22,7 +20,7 @@ export function executeCommand( try { cmd = typeof command === 'function' ? command() : command } catch (error) { - CLILoggingService.actionableError( + Logging.actionableError( `Failed to generate command: ${error}`, 'Check the command generation logic for errors' ) @@ -30,7 +28,7 @@ export function executeCommand( } try { - CLILoggingService.debug(`Executing: ${cmd}`) + Logging.debug(`Executing: ${cmd}`) const [toBeExecuted, ...args] = cmd.split(' ') @@ -50,7 +48,7 @@ export function executeCommand( } if (!options.stdio || options.stdio === 'inherit') { - CLILoggingService.debug(`Completed: ${cmd}`) + Logging.debug(`Completed: ${cmd}`) } return result.stdout?.toString().trim() || '' @@ -59,11 +57,11 @@ export function executeCommand( throw error } - CLILoggingService.actionableError( + Logging.actionableError( `Command execution failed: ${cmd}`, 'Check if the command exists and you have the necessary permissions' ) - CLILoggingService.debug(`Error details: ${error}`) + Logging.debug(`Error details: ${error}`) process.exit(1) } } @@ -89,7 +87,7 @@ export function getEnvVars( } if (missing.length > 0) { - CLILoggingService.actionableError( + Logging.actionableError( `Missing required environment variables: ${missing.join(', ')}`, 'Use the "forge db init" command to set up the environment variables, or set them manually in your env/.env.local file' ) @@ -110,7 +108,7 @@ export function getEnvVar(varName: string, fallback?: string): string { return fallback } - CLILoggingService.actionableError( + Logging.actionableError( `Missing required environment variable: ${varName}`, 'Use the "forge db init" command to set up the environment variables, or set them manually in your env/.env.local file' ) @@ -128,7 +126,7 @@ export function formatProjectList(projects: string[]): string { * Logs a process start message */ export function logProcessStart(processType: string, projects: string[]): void { - CLILoggingService.step( + Logging.step( `Running ${processType} for ${projects.length} project(s): ${formatProjectList(projects)}` ) } @@ -137,9 +135,7 @@ export function logProcessStart(processType: string, projects: string[]): void { * Logs a process completion message */ export function logProcessComplete(processType: string): void { - CLILoggingService.success( - `All projects ${processType} completed successfully` - ) + Logging.success(`All projects ${processType} completed successfully`) } /** @@ -152,7 +148,7 @@ export function killExistingProcess( if (typeof processKeywordOrPID === 'number') { process.kill(processKeywordOrPID) - CLILoggingService.debug( + Logging.debug( `Killed process with PID: ${chalk.bold.blue(processKeywordOrPID)}` ) @@ -167,7 +163,7 @@ export function killExistingProcess( if (serverInstance) { executeCommand(`pkill -f "${processKeywordOrPID}"`) - CLILoggingService.debug( + Logging.debug( `Killed process matching keyword: ${chalk.bold.blue( processKeywordOrPID )} (PID: ${chalk.bold.blue(serverInstance)})` @@ -196,23 +192,19 @@ export function validateFilePaths( const fullPath = path.resolve(basedir, pth) if (!fs.existsSync(fullPath)) { - CLILoggingService.error( - `Invalid module structure detected: ${pth} does not exist` - ) + Logging.error(`Invalid module structure detected: ${pth} does not exist`) process.exit(1) } const stats = fs.lstatSync(fullPath) if (type === 'file' && !stats.isFile()) { - CLILoggingService.error( - `Invalid module structure detected: ${pth} is not a file` - ) + Logging.error(`Invalid module structure detected: ${pth} is not a file`) process.exit(1) } if (type === 'directory' && !stats.isDirectory()) { - CLILoggingService.error( + Logging.error( `Invalid module structure detected: ${pth} is not a directory` ) process.exit(1) diff --git a/tools/src/utils/logging.ts b/tools/src/utils/logging.ts index 339d375d4..b9b6a4f61 100644 --- a/tools/src/utils/logging.ts +++ b/tools/src/utils/logging.ts @@ -15,19 +15,19 @@ const LEVEL_ORDER = { * CLI Logging service that wraps the server's LoggingService * Provides consistent logging across the entire CLI with file persistence */ -export default class CLILoggingService { +export default class Logging { private static readonly SERVICE_NAME = 'CLI' private static level: number = LEVEL_ORDER['info'] static setLevel(level: keyof typeof LEVEL_ORDER): void { - CLILoggingService.level = LEVEL_ORDER[level] + Logging.level = LEVEL_ORDER[level] } /** * Log an informational message */ static info(message: string): void { - if (CLILoggingService.level > LEVEL_ORDER['info']) { + if (Logging.level > LEVEL_ORDER['info']) { return } @@ -38,7 +38,7 @@ export default class CLILoggingService { * Log an error message with consistent formatting */ static error(message: string, details?: string): void { - if (CLILoggingService.level > LEVEL_ORDER['error']) { + if (Logging.level > LEVEL_ORDER['error']) { return } @@ -51,7 +51,7 @@ export default class CLILoggingService { * Log a warning message */ static warn(message: string): void { - if (CLILoggingService.level > LEVEL_ORDER['warn']) { + if (Logging.level > LEVEL_ORDER['warn']) { return } @@ -62,7 +62,7 @@ export default class CLILoggingService { * Log a debug message */ static debug(message: string): void { - if (CLILoggingService.level > LEVEL_ORDER['debug']) { + if (Logging.level > LEVEL_ORDER['debug']) { return } @@ -73,7 +73,7 @@ export default class CLILoggingService { * Log a success message with green checkmark */ static success(message: string): void { - if (CLILoggingService.level > LEVEL_ORDER['info']) { + if (Logging.level > LEVEL_ORDER['info']) { return } @@ -84,7 +84,7 @@ export default class CLILoggingService { * Log a step in a process with consistent formatting */ static step(message: string): void { - if (CLILoggingService.level > LEVEL_ORDER['info']) { + if (Logging.level > LEVEL_ORDER['info']) { return } @@ -95,7 +95,7 @@ export default class CLILoggingService { * Log a process start with spinner-like indicator */ static progress(message: string): void { - if (CLILoggingService.level > LEVEL_ORDER['info']) { + if (Logging.level > LEVEL_ORDER['info']) { return } @@ -106,7 +106,7 @@ export default class CLILoggingService { * Display a formatted list of items */ static list(title: string, items: string[]): void { - if (CLILoggingService.level > LEVEL_ORDER['info']) { + if (Logging.level > LEVEL_ORDER['info']) { return } @@ -124,7 +124,7 @@ export default class CLILoggingService { * Display available options in a formatted way */ static options(title: string, options: string[]): void { - if (CLILoggingService.level > LEVEL_ORDER['error']) { + if (Logging.level > LEVEL_ORDER['error']) { return } @@ -136,7 +136,7 @@ export default class CLILoggingService { * Add a visual separator/newline for better readability */ static newline(): void { - if (CLILoggingService.level > LEVEL_ORDER['info']) { + if (Logging.level > LEVEL_ORDER['info']) { return } @@ -147,7 +147,7 @@ export default class CLILoggingService { * Log a fatal error and exit the process */ static fatal(message: string, exitCode = 1): never { - if (CLILoggingService.level > LEVEL_ORDER['fatal']) { + if (Logging.level > LEVEL_ORDER['fatal']) { process.exit(exitCode) } @@ -159,7 +159,7 @@ export default class CLILoggingService { * Log an actionable error with next steps */ static actionableError(message: string, suggestion: string): void { - if (CLILoggingService.level > LEVEL_ORDER['error']) { + if (Logging.level > LEVEL_ORDER['error']) { return } diff --git a/tools/src/utils/package.ts b/tools/src/utils/packageJson.ts similarity index 51% rename from tools/src/utils/package.ts rename to tools/src/utils/packageJson.ts index 91a6b2542..ea94131ed 100644 --- a/tools/src/utils/package.ts +++ b/tools/src/utils/packageJson.ts @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' -import CLILoggingService from './logging' +import Logging from './logging' interface PackageJson { name?: string @@ -10,14 +10,24 @@ interface PackageJson { [key: string]: unknown } +/** + * Reads the root package.json file and returns it as a JSON object. + * + * @returns The root package.json file as a JSON object. + */ export function readRootPackageJson(): PackageJson { const rootPackageJsonPath = path.join(process.cwd(), 'package.json') return JSON.parse(fs.readFileSync(rootPackageJsonPath, 'utf-8')) } +/** + * Writes the root package.json file with the given JSON object. + * + * @param packageJson The JSON object to write to the root package.json file. + */ export function writeRootPackageJson(packageJson: PackageJson): void { - CLILoggingService.debug(`Writing root package.json`) + Logging.debug(`Writing root package.json`) const rootPackageJsonPath = path.join(process.cwd(), 'package.json') @@ -26,14 +36,20 @@ export function writeRootPackageJson(packageJson: PackageJson): void { JSON.stringify(packageJson, null, 2) + '\n' ) - CLILoggingService.debug(`Wrote root package.json`) + Logging.debug(`Wrote root package.json`) } -export function addWorkspaceDependency( +/** + * Adds a dependency to the root package.json file. + * + * @param packageName The name of the package to add as a dependency. + * @param version The version of the package to add as a dependency. Defaults to 'workspace:*'. + */ +export function addDependency( packageName: string, version = 'workspace:*' ): void { - CLILoggingService.debug(`Adding workspace dependency: ${packageName}`) + Logging.debug(`Adding workspace dependency: ${packageName}`) const packageJson = readRootPackageJson() @@ -45,11 +61,16 @@ export function addWorkspaceDependency( writeRootPackageJson(packageJson) - CLILoggingService.debug(`Added workspace dependency: ${packageName}`) + Logging.debug(`Added workspace dependency: ${packageName}`) } -export function removeWorkspaceDependency(packageName: string): void { - CLILoggingService.debug(`Removing workspace dependency: ${packageName}`) +/** + * Removes a dependency from the root package.json file. + * + * @param packageName The name of the package to remove as a dependency. + */ +export function removeDependency(packageName: string): void { + Logging.debug(`Removing workspace dependency: ${packageName}`) const packageJson = readRootPackageJson() @@ -58,11 +79,11 @@ export function removeWorkspaceDependency(packageName: string): void { writeRootPackageJson(packageJson) } - CLILoggingService.debug(`Removed workspace dependency: ${packageName}`) + Logging.debug(`Removed workspace dependency: ${packageName}`) } export function findPackageName(name: string): string | null { - CLILoggingService.debug(`Finding package name: ${name}`) + Logging.debug(`Finding package name: ${name}`) const packageJson = readRootPackageJson() @@ -70,13 +91,13 @@ export function findPackageName(name: string): string | null { for (const dep of Object.keys(dependencies)) { if (dep === name) { - CLILoggingService.debug(`Found package name: ${name}`) + Logging.debug(`Found package name: ${name}`) return dep } } - CLILoggingService.debug(`Package name not found: ${name}`) + Logging.debug(`Package name not found: ${name}`) return null } diff --git a/tools/src/utils/pocketbase.ts b/tools/src/utils/pocketbase.ts index b77576e5c..264eb91c1 100644 --- a/tools/src/utils/pocketbase.ts +++ b/tools/src/utils/pocketbase.ts @@ -1,14 +1,75 @@ import chalk from 'chalk' -import { spawn, spawnSync } from 'child_process' +import { spawn } from 'child_process' import PocketBase from 'pocketbase' import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db' import { executeCommand } from '@/utils/helpers' import { getEnvVars } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' import { killExistingProcess } from './helpers' +// Triple underscore separates username from module name +const USERNAME_SEPARATOR = { + code: '$', + pb: '___' +} + +// Double underscore separates module name from collection name +const COLLECTION_SEPARATOR = { + code: '__', + pb: '__' +} + +/** + * Parse a PocketBase collection name into components + * + * Examples: + * - "melvinchia3636___melvinchia3636$melvinchia3636$invoice_maker__clients" → { username: "melvinchia3636", moduleName: "invoice_maker", collectionName: "clients" } + * - "achievements__badges" → { moduleName: "achievements", collectionName: "badges" } + */ +export function parseCollectionName( + str: string, + type: 'code' | 'pb' +): { + username?: string + moduleName: string + collectionName: string +} { + const userNameSeparator = USERNAME_SEPARATOR[type] + + const collectionSeparator = COLLECTION_SEPARATOR[type] + + if (str.includes(userNameSeparator)) { + const [username, remainder] = str.split(userNameSeparator, 2) + + if (!remainder.includes(collectionSeparator)) { + Logging.error(`Invalid collection name: ${str}`) + process.exit(1) + } + + const [moduleName, collectionName] = remainder.split(collectionSeparator, 2) + + return { + username, + moduleName, + collectionName + } + } + + if (!str.includes(collectionSeparator)) { + Logging.error(`Invalid collection name: ${str}`) + process.exit(1) + } + + const [moduleName, collectionName] = str.split(collectionSeparator, 2) + + return { + moduleName, + collectionName + } +} + /** * Verifies if a PID is actually running and is a PocketBase process */ @@ -56,7 +117,7 @@ export function checkRunningPBInstances(exitOnError = true): boolean { if (validPids.length > 0) { if (exitOnError) { - CLILoggingService.actionableError( + Logging.actionableError( `PocketBase is already running (PID: ${validPids.join(', ')})`, 'Stop the existing instance with "pkill -f pocketbase" before proceeding' ) @@ -89,7 +150,7 @@ export async function startPBServer(): Promise { } if (output.includes('bind: address already in use')) { - CLILoggingService.actionableError( + Logging.actionableError( 'Port 8090 is already in use by another application.', 'Please free up the port. Are you using the port for non-pocketbase applications? (e.g., port forwarding, etc.)' ) @@ -121,18 +182,16 @@ export async function startPocketbase(): Promise<(() => void) | null> { const pbRunning = checkRunningPBInstances(false) if (pbRunning) { - CLILoggingService.step( - 'PocketBase server is already running, skipping...' - ) + Logging.step('PocketBase server is already running, skipping...') return null } - CLILoggingService.step('Starting PocketBase server...') + Logging.step('Starting PocketBase server...') const pbPid = await startPBServer() - CLILoggingService.success( + Logging.success( `PocketBase server started successfully with PID ${chalk.bold.blue( pbPid.toString() )}` @@ -142,7 +201,7 @@ export async function startPocketbase(): Promise<(() => void) | null> { killExistingProcess(pbPid) } } catch (error) { - CLILoggingService.error( + Logging.error( `Failed to start PocketBase server: ${ error instanceof Error ? error.message : 'Unknown error' }` @@ -177,7 +236,7 @@ export default async function getPBInstance(createNewInstance = true): Promise<{ killPB } } catch (error) { - CLILoggingService.error( + Logging.error( `Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}` ) process.exit(1) diff --git a/tools/src/utils/registry.ts b/tools/src/utils/registry.ts index 83d295340..9bcae2b3b 100644 --- a/tools/src/utils/registry.ts +++ b/tools/src/utils/registry.ts @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import { executeCommand } from '@/utils/helpers' -import CLILoggingService from '@/utils/logging' +import Logging from '@/utils/logging' export function getRegistryUrl(): string { const bunfigPath = path.join(process.cwd(), 'bunfig.toml') @@ -55,25 +55,41 @@ export async function checkAuth(): Promise<{ const username = result?.toString().trim() if (username) { + Logging.success(`Authenticated as ${username}`) + return { authenticated: true, username } } throw new Error('Not authenticated') } catch { - CLILoggingService.warn('Not authenticated. Please login first.') - openRegistryLogin() + Logging.warn('Not authenticated. Please login first.') process.exit(1) } } -export function openRegistryLogin(): void { +export async function getPackageLatestVersion( + packageName: string +): Promise { const registry = getRegistryUrl() - const loginUrl = registry.replace(/\/$/, '') + try { + const targetURL = new URL(registry) - executeCommand(`open "${loginUrl}"`, { - cwd: process.cwd(), - stdio: 'ignore' - }) + targetURL.pathname = packageName + + const response = await fetch(targetURL.toString()) + + if (!response.ok) { + return null + } + + const data = (await response.json()) as { + 'dist-tags'?: { latest?: string } + } + + return data['dist-tags']?.latest || null + } catch { + return null + } } diff --git a/tools/tsconfig.json b/tools/tsconfig.json index b766d5d16..6e5b2eaa3 100644 --- a/tools/tsconfig.json +++ b/tools/tsconfig.json @@ -2,12 +2,13 @@ "extends": "../tsconfig.json", "compilerOptions": { "rootDir": ".", + "baseUrl": ".", "paths": { "@/*": [ "./src/*" ], "@server/*": [ - "./server/src/*" + "../server/src/*" ] } },