Merge branch 'style'
This commit is contained in:
+1
-1
@@ -3,6 +3,6 @@ dist
|
||||
*.log
|
||||
.env
|
||||
scripts/deploy.env
|
||||
scripts/craftshop-remote-lan.env
|
||||
server/prisma/dev.db
|
||||
server/prisma/dev.db-journal
|
||||
.deployed-commit
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# AGENTS.md — shop (craftshop monorepo)
|
||||
|
||||
## Project structure
|
||||
|
||||
- `client/` — frontend (React + Vite + TypeScript + MUI), **FSD architecture**: `app/pages/widgets/features/entities/shared`
|
||||
- `server/` — backend (Fastify + Prisma + SQLite)
|
||||
- `shared/constants/` — JS + `.d.ts` files shared between client and server (order statuses, delivery carriers, payment methods, upload limits)
|
||||
|
||||
## Developer commands
|
||||
|
||||
### Client (`cd client`)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `npm run dev` | Vite dev server on `:5173`, proxies `/api` and `/uploads` to `http://127.0.0.1:3333` |
|
||||
| `npm run build` | Runs `tsc -b` first, then `vite build` |
|
||||
| `npm run lint` | ESLint (flat config) |
|
||||
| `npm run lint:fix` | ESLint with `--fix` |
|
||||
| `npm run format` | Prettier write all |
|
||||
| `npm run format:check` | Prettier check only |
|
||||
| `npm test` | vitest run |
|
||||
| `npm run test:watch` | vitest watch mode |
|
||||
|
||||
### Server (`cd server`)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `npm run dev` | `node --env-file=.dev_env --watch src/index.js` (requires Node 20.6+) |
|
||||
| `npm run dev:classic` | `node --watch src/index.js` (loads `.env` via dotenv) |
|
||||
| `npm test` | vitest run |
|
||||
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses `.dev_env`) |
|
||||
|
||||
### Build order (when changing both packages)
|
||||
|
||||
```bash
|
||||
cd server && npm run db:migrate # if schema changed
|
||||
cd server && npm test # server tests first
|
||||
cd client && npm run lint && npm run format:check && npm test # then client
|
||||
cd client && npm run build # full typecheck + build
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Language**: Отвечай пользователю **на русском**.
|
||||
- **Single quotes**, no semicolons, trailing commas, 120 print width (Prettier + ESLint enforce).
|
||||
- **FSD import boundaries** enforced by `eslint-plugin-boundaries`. Lower layers cannot import upper layers. If ESLint complains about an import, the architecture is wrong.
|
||||
- **Aliases**: `@/` → `client/src/`, `@shared/` → `shared/` (configured in both vite.config.ts and tsconfig).
|
||||
- **API requests**: Use `apiClient` (axios wrapper from `shared/api/`) with `@tanstack/react-query`. Invalidate queries after mutations.
|
||||
- **UI**: Prefer MUI components over custom HTML/CSS.
|
||||
- **`no-console`**: ESLint error; use `console.warn/error/info` only.
|
||||
- **Admin access**: Only users with email matching `ADMIN_EMAIL` env var can access admin routes. Server auto-creates the admin user on startup.
|
||||
- **Server helpers**: `slugify`, `parseMaterialsInput`, `mapProductForApi` are decorated on fastify instance, accessed via `request.server.*`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Client**: vitest + jsdom + @testing-library/react. Setup file: `client/src/testing/setup.ts`.
|
||||
- **Server**: vitest with globals enabled.
|
||||
- Test files live in `__tests__/` directories next to the code they test.
|
||||
|
||||
## OAuth
|
||||
|
||||
- VK callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback`
|
||||
- Yandex callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
|
||||
- Required env vars: `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, `SERVER_PUBLIC_URL`, `CLIENT_PUBLIC_URL`
|
||||
|
||||
## Notable quirks
|
||||
|
||||
- `.env` is gitignored. Use `.dev_env` in the server repo for local dev (it is committed). Copy `.env.example` to `.env` for custom config.
|
||||
- Vite dev server (client) relies on backend running at `127.0.0.1:3333`. Start server first.
|
||||
- Rich text rendering uses `shared/ui/RichTextMessageContent` (TipTap). Pass `tone="review"`, `tone="chat"`, or `tone="default"`.
|
||||
- `db:reset:test` runs `prisma migrate reset --force`, which destroys all data.
|
||||
@@ -73,9 +73,6 @@ npm run dev # переменные из `.dev_env`
|
||||
|
||||
Сервер: `http://127.0.0.1:3333`. Проверка: `GET /health`.
|
||||
|
||||
Черновик деплоя на Proxmox: [docs/test-deploy-proxmox.md](docs/test-deploy-proxmox.md).
|
||||
Обновление уже развёрнутого стенда: [docs/deploy-changes.md](docs/deploy-changes.md).
|
||||
|
||||
### Фронтенд
|
||||
|
||||
В другом терминале:
|
||||
@@ -124,3 +121,21 @@ npm run dev
|
||||
- `PATCH /api/admin/products/:id`
|
||||
- `DELETE /api/admin/products/:id`
|
||||
- `POST /api/admin/categories`
|
||||
|
||||
## Деплой
|
||||
|
||||
```bash
|
||||
# Заполнить scripts/deploy.env (DEPLOY_HOST, DEPLOY_PATH и т.д.)
|
||||
|
||||
# Первичная настройка LXC: см. scripts/SERVER_SETUP.md
|
||||
|
||||
# Деплой только изменившихся компонентов:
|
||||
./scripts/deploy-auto.sh
|
||||
|
||||
# Полный деплой (игнорировать diff):
|
||||
./scripts/deploy-auto.sh --force
|
||||
|
||||
# Только фронт или только бэкенд:
|
||||
./scripts/deploy-auto.sh --frontend-only
|
||||
./scripts/deploy-auto.sh --backend-only
|
||||
```
|
||||
|
||||
Generated
+10
@@ -19,6 +19,7 @@
|
||||
"axios": "^1.15.2",
|
||||
"effector": "^23.4.4",
|
||||
"effector-react": "^23.3.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
@@ -7415,6 +7416,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"axios": "^1.15.2",
|
||||
"effector": "^23.4.4",
|
||||
"effector-react": "^23.3.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
|
||||
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
|
||||
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'
|
||||
import MenuRoundedIcon from '@mui/icons-material/MenuRounded'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { alpha, useTheme } from '@mui/material/styles'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
@@ -21,7 +14,6 @@ import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||
import type { ColorScheme } from '@/app/providers/theme-controller'
|
||||
import { useThemeController } from '@/app/providers/theme-controller'
|
||||
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
||||
import { fetchMyOrders } from '@/entities/order/api/order-api'
|
||||
@@ -29,143 +21,24 @@ import { CartBadge } from '@/features/cart/cart-badge'
|
||||
import { UserMenu } from '@/features/user/user-menu'
|
||||
import { STORE_NAME } from '@/shared/config'
|
||||
import { $user, logout, tokenSet } from '@/shared/model/auth'
|
||||
import type { ColorScheme } from '@/shared/model/theme'
|
||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||
import { ModeSwitcher } from '@/shared/ui/ModeSwitcher'
|
||||
import { SchemeSwitcher } from '@/shared/ui/SchemeSwitcher'
|
||||
import { NavigationDrawer } from '@/widgets/navigation-drawer'
|
||||
|
||||
type NavItem = { label: string; to: string }
|
||||
|
||||
const navItems: NavItem[] = [{ label: 'Каталог', to: '/' }]
|
||||
|
||||
function ThemeControlsDesktop(props: {
|
||||
scheme: string
|
||||
mode: string
|
||||
resolvedMode: 'light' | 'dark'
|
||||
onSchemeChange: (e: SelectChangeEvent<string>) => void
|
||||
onModeChange: (e: SelectChangeEvent<string>) => void
|
||||
onCycleMode: () => void
|
||||
}) {
|
||||
const { scheme, mode, resolvedMode, onSchemeChange, onModeChange, onCycleMode } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
minWidth: 160,
|
||||
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
|
||||
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
|
||||
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
|
||||
}}
|
||||
>
|
||||
<InputLabel id="scheme-label">Схема</InputLabel>
|
||||
<Select
|
||||
labelId="scheme-label"
|
||||
value={scheme}
|
||||
label="Схема"
|
||||
onChange={onSchemeChange}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
|
||||
'.MuiSvgIcon-root': { color: 'inherit' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="craft">Крафт</MenuItem>
|
||||
<MenuItem value="forest">Лес</MenuItem>
|
||||
<MenuItem value="ocean">Океан</MenuItem>
|
||||
<MenuItem value="berry">Ягоды</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
minWidth: 150,
|
||||
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
|
||||
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
|
||||
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
|
||||
}}
|
||||
>
|
||||
<InputLabel id="mode-label">Тема</InputLabel>
|
||||
<Select
|
||||
labelId="mode-label"
|
||||
value={mode}
|
||||
label="Тема"
|
||||
onChange={onModeChange}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
|
||||
'.MuiSvgIcon-root': { color: 'inherit' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="system">Авто (система)</MenuItem>
|
||||
<MenuItem value="light">Светлая</MenuItem>
|
||||
<MenuItem value="dark">Тёмная</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={onCycleMode}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Переключить режим темы (авто/светлая/тёмная)"
|
||||
title={`Сейчас: ${mode === 'system' ? `авто (${resolvedMode})` : mode}`}
|
||||
>
|
||||
{resolvedMode === 'dark' ? <LightModeOutlinedIcon /> : <DarkModeOutlinedIcon />}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeControlsMobile(props: {
|
||||
scheme: string
|
||||
mode: string
|
||||
resolvedMode: 'light' | 'dark'
|
||||
onSchemeChange: (e: SelectChangeEvent<string>) => void
|
||||
onModeChange: (e: SelectChangeEvent<string>) => void
|
||||
onCycleMode: () => void
|
||||
}) {
|
||||
const { scheme, mode, resolvedMode, onSchemeChange, onModeChange, onCycleMode } = props
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="scheme-label-mobile">Схема</InputLabel>
|
||||
<Select labelId="scheme-label-mobile" value={scheme} label="Схема" onChange={onSchemeChange}>
|
||||
<MenuItem value="craft">Крафт</MenuItem>
|
||||
<MenuItem value="forest">Лес</MenuItem>
|
||||
<MenuItem value="ocean">Океан</MenuItem>
|
||||
<MenuItem value="berry">Ягоды</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="mode-label-mobile">Тема</InputLabel>
|
||||
<Select labelId="mode-label-mobile" value={mode} label="Тема" onChange={onModeChange}>
|
||||
<MenuItem value="system">Авто (система)</MenuItem>
|
||||
<MenuItem value="light">Светлая</MenuItem>
|
||||
<MenuItem value="dark">Тёмная</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button variant="outlined" onClick={onCycleMode}>
|
||||
Быстро переключить: {mode === 'system' ? `авто (${resolvedMode})` : mode}
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppHeader() {
|
||||
const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController()
|
||||
const { mode, resolvedMode, scheme, setScheme, cycleMode } = useThemeController()
|
||||
const user = useUnit($user)
|
||||
const navigate = useNavigate()
|
||||
const isAdmin = Boolean(user?.isAdmin)
|
||||
const headerNavItems = isAdmin ? [...navItems, { label: 'Админка', to: '/admin' }] : navItems
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const cartQuery = useQuery({
|
||||
queryKey: ['me', 'cart'],
|
||||
@@ -184,14 +57,14 @@ export function AppHeader() {
|
||||
).length
|
||||
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
|
||||
const onSchemeChange = (e: SelectChangeEvent<string>) => setScheme(e.target.value as ColorScheme)
|
||||
const onModeChange = (e: SelectChangeEvent<string>) => {
|
||||
const v = e.target.value
|
||||
if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
|
||||
}
|
||||
useEffect(() => {
|
||||
const handler = () => setScrolled(window.scrollY > 0)
|
||||
handler()
|
||||
window.addEventListener('scroll', handler, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handler)
|
||||
}, [])
|
||||
|
||||
const go = (to: string) => {
|
||||
setMobileOpen(false)
|
||||
@@ -205,11 +78,20 @@ export function AppHeader() {
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const themeControls = { scheme, mode, resolvedMode, onSchemeChange, onModeChange, onCycleMode: cycleMode }
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
color="primary"
|
||||
elevation={scrolled ? 2 : 0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.88),
|
||||
backdropFilter: 'blur(8px)',
|
||||
transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
@@ -219,7 +101,7 @@ export function AppHeader() {
|
||||
edge="start"
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<MenuOutlinedIcon />
|
||||
<MenuRoundedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
@@ -255,7 +137,7 @@ export function AppHeader() {
|
||||
<Tooltip title="Заказы">
|
||||
<IconButton color="inherit" sx={{ ml: 1 }} onClick={() => navigate('/me/orders')} aria-label="Заказы">
|
||||
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
|
||||
<LocalShippingOutlinedIcon />
|
||||
<Inventory2OutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -273,7 +155,12 @@ export function AppHeader() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isMobile && <ThemeControlsDesktop {...themeControls} />}
|
||||
{!isMobile && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 1.5 }}>
|
||||
<SchemeSwitcher value={scheme} onChange={(s: ColorScheme) => setScheme(s)} />
|
||||
<ModeSwitcher mode={mode} resolvedMode={resolvedMode} onCycleMode={cycleMode} />
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
@@ -283,10 +170,13 @@ export function AppHeader() {
|
||||
user={user}
|
||||
isAdmin={isAdmin}
|
||||
navItems={headerNavItems}
|
||||
themeControls={themeControls}
|
||||
scheme={scheme}
|
||||
mode={mode}
|
||||
resolvedMode={resolvedMode}
|
||||
onSchemeChange={(s: ColorScheme) => setScheme(s)}
|
||||
onCycleMode={cycleMode}
|
||||
onNavigate={go}
|
||||
onLogout={onLogout}
|
||||
ThemeControlsMobile={ThemeControlsMobile}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { PaletteMode } from '@mui/material'
|
||||
|
||||
export type ColorScheme = 'craft' | 'forest' | 'ocean' | 'berry'
|
||||
|
||||
export type ThemeModePreference = 'system' | PaletteMode
|
||||
import type { ColorScheme, ThemeModePreference } from '@/shared/model/theme'
|
||||
|
||||
export type ThemeSettings = {
|
||||
mode: ThemeModePreference
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
|
||||
import ShoppingCartRoundedIcon from '@mui/icons-material/ShoppingCartRounded'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
@@ -23,7 +23,7 @@ export function CartBadge({ user, cartCount, onNavigate }: Props) {
|
||||
aria-label="Корзина"
|
||||
>
|
||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||
<ShoppingCartOutlinedIcon />
|
||||
<ShoppingCartRoundedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
|
||||
import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
@@ -40,7 +40,7 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) {
|
||||
invisible={!user}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<AccountCircleOutlinedIcon />
|
||||
<PersonOutlineRoundedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PaletteMode } from '@mui/material'
|
||||
|
||||
export type ColorScheme = 'craft' | 'forest' | 'ocean' | 'berry'
|
||||
|
||||
export type ThemeModePreference = 'system' | PaletteMode
|
||||
@@ -0,0 +1,32 @@
|
||||
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
|
||||
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import type { ThemeModePreference } from '@/shared/model/theme'
|
||||
|
||||
type Props = {
|
||||
mode: ThemeModePreference
|
||||
resolvedMode: 'light' | 'dark'
|
||||
onCycleMode: () => void
|
||||
}
|
||||
|
||||
function getModeLabel(mode: ThemeModePreference, resolvedMode: 'light' | 'dark'): string {
|
||||
switch (mode) {
|
||||
case 'system':
|
||||
return `Авто (${resolvedMode === 'dark' ? 'тёмная' : 'светлая'})`
|
||||
case 'light':
|
||||
return 'Светлая'
|
||||
case 'dark':
|
||||
return 'Тёмная'
|
||||
}
|
||||
}
|
||||
|
||||
export function ModeSwitcher({ mode, resolvedMode, onCycleMode }: Props) {
|
||||
return (
|
||||
<Tooltip title={`Тема: ${getModeLabel(mode, resolvedMode)}`}>
|
||||
<IconButton color="inherit" onClick={onCycleMode} aria-label="Переключить тему">
|
||||
{resolvedMode === 'dark' ? <LightModeOutlinedIcon /> : <DarkModeOutlinedIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ModeSwitcher } from './ModeSwitcher'
|
||||
@@ -0,0 +1,61 @@
|
||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'
|
||||
import Box from '@mui/material/Box'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import { Cherry, Hammer, Trees, WavesHorizontal } from 'lucide-react'
|
||||
import type { ColorScheme } from '@/shared/model/theme'
|
||||
|
||||
type Props = {
|
||||
value: ColorScheme
|
||||
onChange: (scheme: ColorScheme) => void
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
}
|
||||
|
||||
const SCHEMES: { key: ColorScheme; color: string; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'craft', color: '#6D4C41', label: 'Крафт', icon: <Hammer size={14} /> },
|
||||
{ key: 'forest', color: '#2E7D32', label: 'Лес', icon: <Trees size={14} /> },
|
||||
{ key: 'ocean', color: '#1565C0', label: 'Океан', icon: <WavesHorizontal size={14} /> },
|
||||
{ key: 'berry', color: '#7B1FA2', label: 'Ягоды', icon: <Cherry size={14} /> },
|
||||
]
|
||||
|
||||
export function SchemeSwitcher({ value, onChange, orientation = 'horizontal' }: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: orientation === 'vertical' ? 'column' : 'row',
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{SCHEMES.map((s) => {
|
||||
const active = value === s.key
|
||||
return (
|
||||
<IconButton
|
||||
key={s.key}
|
||||
onClick={() => onChange(s.key)}
|
||||
size="small"
|
||||
title={s.label}
|
||||
sx={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
minWidth: 30,
|
||||
bgcolor: s.color,
|
||||
border: 2,
|
||||
borderColor: active ? 'common.white' : 'rgba(255,255,255,0.4)',
|
||||
boxShadow: active ? `0 0 0 1.5px ${s.color}, 0 0 8px ${s.color}99` : 'none',
|
||||
transform: active ? 'scale(1.1)' : 'scale(1)',
|
||||
color: 'common.white',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.2)',
|
||||
bgcolor: s.color,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{active ? <CheckCircleRoundedIcon sx={{ fontSize: 14 }} /> : s.icon}
|
||||
</IconButton>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SchemeSwitcher } from './SchemeSwitcher'
|
||||
@@ -2,20 +2,13 @@ import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Drawer from '@mui/material/Drawer'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { STORE_NAME } from '@/shared/config'
|
||||
import type { AuthUser } from '@/shared/model/auth'
|
||||
import type { ColorScheme, ThemeModePreference } from '@/shared/model/theme'
|
||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||
|
||||
type ThemeControls = {
|
||||
scheme: string
|
||||
mode: string
|
||||
resolvedMode: 'light' | 'dark'
|
||||
onSchemeChange: (e: SelectChangeEvent<string>) => void
|
||||
onModeChange: (e: SelectChangeEvent<string>) => void
|
||||
onCycleMode: () => void
|
||||
}
|
||||
import { ModeSwitcher } from '@/shared/ui/ModeSwitcher'
|
||||
import { SchemeSwitcher } from '@/shared/ui/SchemeSwitcher'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@@ -23,10 +16,13 @@ type Props = {
|
||||
user: AuthUser | null
|
||||
isAdmin: boolean
|
||||
navItems: { label: string; to: string }[]
|
||||
themeControls: ThemeControls
|
||||
scheme: ColorScheme
|
||||
mode: ThemeModePreference
|
||||
resolvedMode: 'light' | 'dark'
|
||||
onSchemeChange: (scheme: ColorScheme) => void
|
||||
onCycleMode: () => void
|
||||
onNavigate: (to: string) => void
|
||||
onLogout: () => void
|
||||
ThemeControlsMobile: React.ComponentType<ThemeControls>
|
||||
}
|
||||
|
||||
export function NavigationDrawer({
|
||||
@@ -35,10 +31,13 @@ export function NavigationDrawer({
|
||||
user,
|
||||
isAdmin,
|
||||
navItems,
|
||||
themeControls,
|
||||
scheme,
|
||||
mode,
|
||||
resolvedMode,
|
||||
onSchemeChange,
|
||||
onCycleMode,
|
||||
onNavigate,
|
||||
onLogout,
|
||||
ThemeControlsMobile,
|
||||
}: Props) {
|
||||
const go = (to: string) => {
|
||||
onClose()
|
||||
@@ -93,7 +92,10 @@ export function NavigationDrawer({
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<ThemeControlsMobile {...themeControls} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'flex-start' }}>
|
||||
<SchemeSwitcher value={scheme} onChange={onSchemeChange} orientation="vertical" />
|
||||
<ModeSwitcher mode={mode} resolvedMode={resolvedMode} onCycleMode={onCycleMode} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# Справка: как задеплоить изменения
|
||||
|
||||
Как обновить уже работающий стенд (код на сервере + фронт в nginx) после правок в репозитории.
|
||||
|
||||
## 1. Перед выкладкой (локально, по желанию)
|
||||
|
||||
- **Фронт**: `cd client && npm run lint && npm run build` — убедиться, что сборка проходит.
|
||||
|
||||
### Windows: `npm ci` / сборка падает с EPERM на `.node`
|
||||
|
||||
Часто файл держит **запущенный `npm run dev`** (Vite), другой терминал с Node или антивирус. Сделайте:
|
||||
|
||||
1. Остановить все dev-серверы и лишние процессы `node`.
|
||||
2. Запускать деплой из **Git Bash** или через **`.\scripts\deploy-ssh.ps1`** (не вызывайте `deploy-ssh.sh` напрямую из PowerShell без `bash`).
|
||||
3. Повторить `./scripts/deploy-ssh.sh` (скрипт перед `npm ci` удаляет каталоги `@unrs` и `@rolldown` в `client/node_modules`, если EPERM из‑за блокировок).
|
||||
4. Если не помогло: вручную `cd client && npm run build`, затем **`./scripts/deploy-ssh.sh --frontend-only --skip-build`** (выложится уже готовый `client/dist`).
|
||||
|
||||
- **Бэкенд**: при изменениях в `server/prisma` — миграции должны быть в репозитории; на сервере выполнится `prisma migrate deploy` (см. скрипт деплоя).
|
||||
- **Общие константы**: каталог `shared/constants/` синхронизируется скриптом деплоя вместе с `server/` (автоматически в `deploy_backend`).
|
||||
|
||||
## 2. Переменные окружения на сервере
|
||||
|
||||
После обновления кода сверьте `server/.env` с актуальным **`server/.env.example`** на предмет новых переменных.
|
||||
|
||||
Актуально для загрузок файлов (если нужны нестандартные лимиты):
|
||||
|
||||
- `ADMIN_IMAGE_MAX_FILE_BYTES` (или устаревшее `PRODUCT_IMAGE_MAX_FILE_BYTES`) — одно изображение в админке: товары, галерея (по умолчанию 20 МБ).
|
||||
- `OTHER_UPLOAD_MAX_FILE_BYTES` — отзывы, чек оплаты и т.п. (по умолчанию 2 МБ).
|
||||
- `MAX_UPLOAD_BODY_BYTES` — весь POST multipart (по умолчанию рассчитывается от лимита фото товара × 10 + запас).
|
||||
|
||||
**413 на проде, локально ок:** чаще всего **nginx** с лимитом по умолчанию 1 МБ. См. **[docs/nginx-upload-limit.md](nginx-upload-limit.md)** — добавьте `client_max_body_size` для `location /api/`.
|
||||
|
||||
Общие для публикации: `JWT_SECRET`, `ADMIN_EMAIL`, `DATABASE_URL`, `CORS_ORIGIN`, при раздельных доменах — `VITE_*` на этапе сборки фронта, `SERVER_PUBLIC_URL` / `CLIENT_PUBLIC_URL` для OAuth.
|
||||
|
||||
После правок `.env` **перезапустите API** (systemd или ваша команда).
|
||||
|
||||
## 3. Автоматический деплой по SSH (рекомендуется)
|
||||
|
||||
1. Скопируйте `scripts/deploy.env.example` в **`scripts/deploy.env`** и заполните:
|
||||
- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_PATH`, `DEPLOY_FRONTEND_DIST`;
|
||||
- при необходимости `DEPLOY_SSH_IDENTITY`, `DEPLOY_RESTART_CMD` (например `systemctl restart craftshop-api`).
|
||||
2. Из корня репозитория (нужен **bash**: Git Bash / WSL / Linux/macOS):
|
||||
|
||||
```bash
|
||||
./scripts/deploy-ssh.sh --all
|
||||
```
|
||||
|
||||
Варианты:
|
||||
|
||||
- `--frontend-only` / `-f` — только сборка `client` и выкладка `dist` на сервер.
|
||||
- `--backend-only` / `-b` — только `server` (rsync, `npm ci`, Prisma migrate), плюс `DEPLOY_RESTART_CMD` если задан.
|
||||
- `--all` / `-a` — и фронт, и бэкенд (поведение по умолчанию).
|
||||
|
||||
3. Убедитесь, что после бэкенд-части API перезапущен (`DEPLOY_RESTART_CMD` или вручную).
|
||||
|
||||
Подробности опций: в начале файла **`scripts/deploy-ssh.sh`** (справка `usage`).
|
||||
|
||||
## 4. Первичный деплой с нуля (LAN / один раз)
|
||||
|
||||
Если сервер ещё не настроен, используйте сценарий первичной подготовки (bootstrap, systemd, первый выклад):
|
||||
|
||||
- **`scripts/complete-lan-deploy.ps1`** (Windows) — нужны `scripts/deploy.env` и `scripts/craftshop-remote-lan.env`, см. комментарии в скрипте.
|
||||
|
||||
Черновик архитектуры и требований к ВМ: **[docs/test-deploy-proxmox.md](test-deploy-proxmox.md)**.
|
||||
|
||||
## 5. Ручное обновление (если без скрипта)
|
||||
|
||||
**Бэкенд** (на сервере, в каталоге приложения, например `/opt/craftshop/server`):
|
||||
|
||||
```bash
|
||||
git pull
|
||||
npm ci
|
||||
npx prisma migrate deploy
|
||||
# при необходимости: npx prisma db seed
|
||||
```
|
||||
|
||||
Перезапуск процесса Node (пример):
|
||||
|
||||
```bash
|
||||
systemctl restart craftshop-api
|
||||
```
|
||||
|
||||
**Фронт** (на машине разработчика):
|
||||
|
||||
```bash
|
||||
cd client
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Содержимое **`client/dist/`** скопируйте в каталог статики nginx (как у вас настроено, часто совпадает с `DEPLOY_FRONTEND_DIST` из `deploy.env.example`).
|
||||
|
||||
## 6. Что не потерять при деплое
|
||||
|
||||
- Каталоги **`shared/`** и **`server/`** должны быть рядом на одном уровне (например, `/opt/craftshop/shared/constants/order-status.js` и `/opt/craftshop/server/src/lib/order-status.js`). Скрипт деплоя синхронизирует оба.
|
||||
- Файл **SQLite** и каталог **`server/uploads/`** должны лежать на **персистентном диске** (не внутри временного слоя контейнера без тома).
|
||||
- Nginx (или аналог): **`/api`** → прокси на Fastify, **`/uploads`** → те же файлы, что пишет сервер, либо прокси на `@fastify/static` (см. [test-deploy-proxmox.md](test-deploy-proxmox.md)).
|
||||
|
||||
## 7. Проверка после выкладки
|
||||
|
||||
- `GET https://<ваш-домен>/api/health` или `curl http://127.0.0.1:3333/health` на сервере.
|
||||
- Открыть витрину, при необходимости войти в админку и проверить загрузку фото (лимиты см. выше).
|
||||
|
||||
Дополнительно: общий обзор проекта и локальный запуск — **[README.md](../README.md)**.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Лимит размера запроса (413) за reverse proxy
|
||||
|
||||
Локально запросы идут напрямую в Node (Vite проксирует на Fastify) — тело до **~201 МБ** (см. `getMaxUploadBodyBytes()` в [`server/src/lib/upload-limits.js`](../server/src/lib/upload-limits.js)).
|
||||
|
||||
На проде перед Node часто стоит **nginx** (или Caddy, Traefik). У **nginx** по умолчанию **`client_max_body_size 1m`** — загрузка картинки больше ~1 МБ даёт **413**, хотя на локалке всё работает.
|
||||
|
||||
## Nginx
|
||||
|
||||
В блоке `http`, `server` или в `location /api/` (где проксируется API) задайте лимит **не меньше** максимального тела одного запроса загрузки:
|
||||
|
||||
- один файл до **20 МБ** — достаточно **`25m`**;
|
||||
- до **10 файлов** за один `POST /api/admin/uploads` — теоретически до **~200 МБ** данных + multipart — разумно **`250m`** или **`300m`**.
|
||||
|
||||
Пример для `location`, который проксирует API:
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
client_max_body_size 250m;
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
После правки конфига: `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
|
||||
## Переменные Node (если 413 не от nginx)
|
||||
|
||||
В `server/.env` не задавайте слишком маленькое **`MAX_UPLOAD_BODY_BYTES`** — иначе Fastify отрежет тело раньше прокси. По умолчанию считается как `getProductImageMaxFileBytes() * 10 + 1 МБ`. Если задали вручную — должно быть **≥ суммы ваших файлов** в одном multipart-запросе.
|
||||
|
||||
## Caddy (кратко)
|
||||
|
||||
В соответствующем site-блоке задайте лимит тела запроса по [документации Caddy v2](https://caddyserver.com/docs/caddyfile/directives/request_body) (например, `request_body` с `max_size`).
|
||||
@@ -0,0 +1,546 @@
|
||||
# Auto-Deploy Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** One-command auto-deploy script that detects git changes and deploys only modified components. Clean up obsolete files.
|
||||
|
||||
**Architecture:** One bash script (`deploy-auto.sh`) replaces the existing `deploy-ssh.sh`. It uses `git diff --name-only` against a saved commit hash (`.deployed-commit`) to decide what changed, then reuses existing rsync/tar transport logic. A markdown guide (`SERVER_SETUP.md`) replaces the bootstrap script.
|
||||
|
||||
**Tech Stack:** Bash, git, rsync/tar, SSH, nginx, systemd, Node.js
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Delete obsolete files
|
||||
|
||||
**Files:**
|
||||
- Delete: `scripts/deploy-ssh.sh`
|
||||
- Delete: `scripts/deploy-ssh.ps1`
|
||||
- Delete: `scripts/deploy.env.example`
|
||||
- Delete: `scripts/read-deploy-env.ps1`
|
||||
- Delete: `scripts/register-ssh-key-for-root.ps1`
|
||||
- Delete: `scripts/complete-lan-deploy.ps1`
|
||||
- Delete: `scripts/craftshop-remote-lan.env`
|
||||
- Delete: `scripts/server-bootstrap.sh`
|
||||
- Delete: `docs/deploy-changes.md`
|
||||
- Delete: `docs/test-deploy-proxmox.md`
|
||||
- Delete: `docs/nginx-upload-limit.md`
|
||||
|
||||
- [ ] **Step 1: Remove all obsolete files**
|
||||
|
||||
```bash
|
||||
git rm scripts/deploy-ssh.sh \
|
||||
scripts/deploy-ssh.ps1 \
|
||||
scripts/deploy.env.example \
|
||||
scripts/read-deploy-env.ps1 \
|
||||
scripts/register-ssh-key-for-root.ps1 \
|
||||
scripts/complete-lan-deploy.ps1 \
|
||||
scripts/craftshop-remote-lan.env \
|
||||
scripts/server-bootstrap.sh \
|
||||
docs/deploy-changes.md \
|
||||
docs/test-deploy-proxmox.md \
|
||||
docs/nginx-upload-limit.md
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "chore: remove obsolete deploy scripts and docs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add .deployed-commit to .gitignore
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: Add .deployed-commit to .gitignore**
|
||||
|
||||
Add line at the end of `.gitignore`:
|
||||
|
||||
```
|
||||
.deployed-commit
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitignore && git commit -m "chore: add .deployed-commit to gitignore"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Write deploy-auto.sh
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/deploy-auto.sh`
|
||||
|
||||
- [ ] **Step 1: Create deploy-auto.sh with full implementation**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Auto-deploy: детект изменений через git diff, сборка и деплой только изменённых компонентов.
|
||||
#
|
||||
# Зависимости: bash, git, ssh; rsync (Linux/macOS) или tar (Git Bash).
|
||||
# Конфиг: scripts/deploy.env (скопируйте из deploy.env.example).
|
||||
#
|
||||
# Примеры:
|
||||
# ./scripts/deploy-auto.sh # деплой изменений
|
||||
# ./scripts/deploy-auto.sh --force # полный деплой всех компонентов
|
||||
# ./scripts/deploy-auto.sh -f # только фронт
|
||||
# ./scripts/deploy-auto.sh -b # только бэкенд
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW* | MSYS*) export MSYS2_ARG_CONV_EXCL="*" ;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# --- Config ---
|
||||
if [[ -f "$SCRIPT_DIR/deploy.env" ]]; then
|
||||
set -a
|
||||
source "$SCRIPT_DIR/deploy.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
DEPLOY_HOST="${DEPLOY_HOST:-}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-root}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/craftshop}"
|
||||
DEPLOY_FRONTEND_DIST="${DEPLOY_FRONTEND_DIST:-$DEPLOY_PATH/www}"
|
||||
DEPLOY_SSH_IDENTITY="${DEPLOY_SSH_IDENTITY:-}"
|
||||
DEPLOY_RESTART_CMD="${DEPLOY_RESTART_CMD:-systemctl restart craftshop-api}"
|
||||
DEPLOY_SERVER_OWNER="${DEPLOY_SERVER_OWNER:-deploy}"
|
||||
DEPLOY_SKIP_CHOWN="${DEPLOY_SKIP_CHOWN:-0}"
|
||||
|
||||
RSYNC_OPTS=(-az --delete --human-readable --progress)
|
||||
SSH_OPTS=()
|
||||
if [[ -n "${DEPLOY_SSH_IDENTITY}" ]]; then
|
||||
SSH_OPTS+=(-i "$DEPLOY_SSH_IDENTITY")
|
||||
fi
|
||||
|
||||
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
SSH_BASE=(ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new "$REMOTE")
|
||||
|
||||
# --- Flags ---
|
||||
FORCE=false
|
||||
TARGET="auto"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Использование: $(basename "$0") [опции]
|
||||
|
||||
--force Деплой всех компонентов (игнорировать diff)
|
||||
--frontend-only | -f Только клиент
|
||||
--backend-only | -b Только сервер
|
||||
--help | -h Помощь
|
||||
|
||||
Окружение (или scripts/deploy.env):
|
||||
DEPLOY_HOST хост SSH (обязательно)
|
||||
DEPLOY_PATH корень приложения на сервере (по умолчанию: /opt/craftshop)
|
||||
DEPLOY_SSH_IDENTITY путь к приватному ключу (-i ssh)
|
||||
DEPLOY_RESTART_CMD команда после бэкенд-деплоя
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--force) FORCE=true ;;
|
||||
--frontend-only | -f) TARGET="frontend" ;;
|
||||
--backend-only | -b) TARGET="backend" ;;
|
||||
-h | --help) usage; exit 0 ;;
|
||||
*) echo "Неизвестный аргумент: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z "$DEPLOY_HOST" ]]; then
|
||||
echo "Укажите DEPLOY_HOST (или добавьте в scripts/deploy.env)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Helpers ---
|
||||
remote_exec() {
|
||||
"${SSH_BASE[@]}" "$@"
|
||||
}
|
||||
|
||||
should_use_tar_transport() {
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW*|MSYS*|CYGWIN_NT*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_rsync_rsh() {
|
||||
printf '%q ' ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new
|
||||
}
|
||||
|
||||
# --- Diff detection ---
|
||||
changed_client=false
|
||||
changed_server=false
|
||||
|
||||
if [[ "$TARGET" == "frontend" ]]; then
|
||||
changed_client=true
|
||||
elif [[ "$TARGET" == "backend" ]]; then
|
||||
changed_server=true
|
||||
elif [[ "$FORCE" == true ]]; then
|
||||
changed_client=true
|
||||
changed_server=true
|
||||
else
|
||||
DEPLOYED_FILE="$ROOT/.deployed-commit"
|
||||
if [[ -f "$DEPLOYED_FILE" ]]; then
|
||||
LAST_DEPLOYED=$(cat "$DEPLOYED_FILE")
|
||||
else
|
||||
LAST_DEPLOYED="HEAD~1"
|
||||
fi
|
||||
|
||||
echo ">>> Diff: $LAST_DEPLOYED..HEAD"
|
||||
CHANGED_FILES=$(git -C "$ROOT" diff --name-only "$LAST_DEPLOYED" HEAD 2>/dev/null || true)
|
||||
|
||||
if echo "$CHANGED_FILES" | grep -q "^client/"; then
|
||||
changed_client=true
|
||||
fi
|
||||
if echo "$CHANGED_FILES" | grep -q "^server/"; then
|
||||
changed_server=true
|
||||
fi
|
||||
if echo "$CHANGED_FILES" | grep -q "^shared/"; then
|
||||
changed_client=true
|
||||
changed_server=true
|
||||
fi
|
||||
|
||||
if [[ "$changed_client" == false && "$changed_server" == false ]]; then
|
||||
echo ">>> Ничего не изменилось с последнего деплоя."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ">>> Клиент: $changed_client, Сервер: $changed_server"
|
||||
|
||||
# --- Deploy: Client ---
|
||||
if [[ "$changed_client" == true ]]; then
|
||||
echo ">>> Сборка клиента..."
|
||||
(cd "$ROOT/client" && npm ci && npm run build)
|
||||
|
||||
remote_exec mkdir -p "$DEPLOY_FRONTEND_DIST"
|
||||
|
||||
if should_use_tar_transport; then
|
||||
remote_exec "find ${DEPLOY_FRONTEND_DIST} -mindepth 1 -delete 2>/dev/null || true"
|
||||
(cd "$ROOT/client/dist" && tar -czf - .) | \
|
||||
"${SSH_BASE[@]}" "mkdir -p ${DEPLOY_FRONTEND_DIST} && tar xzf - -C ${DEPLOY_FRONTEND_DIST}"
|
||||
else
|
||||
local rsh
|
||||
rsh="$(build_rsync_rsh)"
|
||||
rsync "${RSYNC_OPTS[@]}" -e "$rsh" \
|
||||
"$ROOT/client/dist/" "${REMOTE}:${DEPLOY_FRONTEND_DIST}/"
|
||||
fi
|
||||
|
||||
echo ">>> Клиент задеплоен"
|
||||
fi
|
||||
|
||||
# --- Deploy: Server ---
|
||||
if [[ "$changed_server" == true ]]; then
|
||||
remote_exec mkdir -p "$DEPLOY_PATH/server" "$DEPLOY_PATH/shared"
|
||||
|
||||
if should_use_tar_transport; then
|
||||
(cd "$ROOT/server" && tar -czf - \
|
||||
--exclude=node_modules --exclude=uploads --exclude=.git \
|
||||
--exclude='*.db' --exclude=.env --exclude=.dev_env \
|
||||
.) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/server && tar xzf - -C ${DEPLOY_PATH}/server"
|
||||
|
||||
(cd "$ROOT/shared" && tar -czf - \
|
||||
--exclude=.git \
|
||||
.) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/shared && tar xzf - -C ${DEPLOY_PATH}/shared"
|
||||
else
|
||||
local rsh
|
||||
rsh="$(build_rsync_rsh)"
|
||||
rsync "${RSYNC_OPTS[@]}" -e "$rsh" \
|
||||
--exclude node_modules --exclude uploads --exclude .git \
|
||||
--exclude '*.db' --exclude .env --exclude .dev_env \
|
||||
"$ROOT/server/" "${REMOTE}:${DEPLOY_PATH}/server/"
|
||||
|
||||
rsync "${RSYNC_OPTS[@]}" -e "$rsh" \
|
||||
--exclude .git \
|
||||
"$ROOT/shared/" "${REMOTE}:${DEPLOY_PATH}/shared/"
|
||||
fi
|
||||
|
||||
echo ">>> Сервер: npm ci, prisma generate, migrate deploy"
|
||||
remote_exec bash -lc "set -e
|
||||
cd \"$DEPLOY_PATH/server\"
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
"
|
||||
|
||||
if [[ "${DEPLOY_USER}" == "root" && "${DEPLOY_SKIP_CHOWN}" != "1" ]]; then
|
||||
echo ">>> chown ${DEPLOY_SERVER_OWNER}"
|
||||
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/server"
|
||||
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/shared"
|
||||
fi
|
||||
|
||||
if [[ -n "${DEPLOY_RESTART_CMD}" ]]; then
|
||||
echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
|
||||
remote_exec bash -lc "$DEPLOY_RESTART_CMD"
|
||||
fi
|
||||
|
||||
echo ">>> Сервер задеплоен"
|
||||
fi
|
||||
|
||||
# --- Save deployed commit ---
|
||||
if [[ "$TARGET" == "auto" && "$FORCE" != true ]]; then
|
||||
CURRENT_HEAD=$(git -C "$ROOT" rev-parse HEAD)
|
||||
echo "$CURRENT_HEAD" > "$ROOT/.deployed-commit"
|
||||
echo ">>> Сохранён коммит $CURRENT_HEAD"
|
||||
fi
|
||||
|
||||
echo ">>> Готово."
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x scripts/deploy-auto.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/deploy-auto.sh && git commit -m "feat: add deploy-auto.sh with git diff detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Write SERVER_SETUP.md
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/SERVER_SETUP.md`
|
||||
|
||||
- [ ] **Step 1: Create SERVER_SETUP.md**
|
||||
|
||||
```markdown
|
||||
# Первичная настройка LXC для Craftshop
|
||||
|
||||
Выполнять от **root** на свежем Debian/Ubuntu LXC.
|
||||
|
||||
---
|
||||
|
||||
## 1. Базовые пакеты и Node.js
|
||||
|
||||
```bash
|
||||
apt-get update -y
|
||||
apt-get install -y ca-certificates curl gnupg curl git
|
||||
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
node --version # >= 22
|
||||
npm --version
|
||||
```
|
||||
|
||||
## 2. Пользователь и каталоги
|
||||
|
||||
```bash
|
||||
useradd --create-home --shell /bin/bash deploy
|
||||
mkdir -p /opt/craftshop/server/uploads /opt/craftshop/www
|
||||
chown -R deploy:deploy /opt/craftshop
|
||||
chmod 755 /opt/craftshop /opt/craftshop/server /opt/craftshop/www
|
||||
```
|
||||
|
||||
## 3. systemd unit
|
||||
|
||||
```bash
|
||||
cat >/etc/systemd/system/craftshop-api.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Craftshop API (Fastify)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
Group=deploy
|
||||
WorkingDirectory=/opt/craftshop/server
|
||||
EnvironmentFile=-/opt/craftshop/server/.env
|
||||
ExecStart=/usr/bin/node src/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable craftshop-api.service
|
||||
```
|
||||
|
||||
## 4. Nginx
|
||||
|
||||
```bash
|
||||
apt-get install -y nginx
|
||||
|
||||
cat >/etc/nginx/sites-available/craftshop <<'NGX'
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
server_name _;
|
||||
|
||||
root /opt/craftshop/www;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 250m;
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf /etc/nginx/sites-available/craftshop /etc/nginx/sites-enabled/craftshop
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
## 5. NetBird VPN
|
||||
|
||||
Установи NetBird и подключись к сети:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://pkgs.netbird.io/install.sh | sh
|
||||
netbird up # потребуется SSO-логин
|
||||
```
|
||||
|
||||
Проверь: `ip a` — должен появиться интерфейс `wt0` с IP из твоей NetBird-сети.
|
||||
|
||||
## 6. VPS с Nginx Proxy Manager
|
||||
|
||||
На VPS (где установлен NPM):
|
||||
|
||||
1. Добавь DNS-запись (A) для домена, указывающую на IP VPS
|
||||
2. В NPM → Proxy Hosts → Add:
|
||||
- Domain: `craftshop.твой-домен`
|
||||
- Forward Hostname: `<NetBird-IP-LXC>` (IP интерфейса `wt0` на LXC)
|
||||
- Forward Port: `80`
|
||||
- SSL: запросить Let's Encrypt сертификат
|
||||
3. Сохрани
|
||||
|
||||
## 7. Переменные окружения
|
||||
|
||||
```bash
|
||||
cat >/opt/craftshop/server/.env <<'ENV'
|
||||
DATABASE_URL="file:./prod.db"
|
||||
PORT=3333
|
||||
JWT_SECRET=<сгенерируй случайную строку>
|
||||
ADMIN_EMAIL=<твой email>
|
||||
CORS_ORIGIN=https://craftshop.твой-домен
|
||||
IS_DEFAULT_CODE_ENABLED=false
|
||||
ENV
|
||||
chown deploy:deploy /opt/craftshop/server/.env
|
||||
chmod 600 /opt/craftshop/server/.env
|
||||
```
|
||||
|
||||
Сгенерировать `JWT_SECRET`:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
## 8. Первый деплой
|
||||
|
||||
На машине разработчика:
|
||||
|
||||
```bash
|
||||
./scripts/deploy-auto.sh --force
|
||||
```
|
||||
|
||||
После деплоя:
|
||||
|
||||
```bash
|
||||
systemctl start craftshop-api
|
||||
systemctl status craftshop-api
|
||||
curl http://127.0.0.1:3333/health
|
||||
```
|
||||
|
||||
Также проверь через внешний URL: `https://craftshop.твой-домен/api/health`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/SERVER_SETUP.md && git commit -m "docs: add SERVER_SETUP.md for first-time LXC setup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update README.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Remove old doc references and add deploy section**
|
||||
|
||||
Удалить строки со ссылками на удалённые doc-файлы:
|
||||
- `docs/test-deploy-proxmox.md`
|
||||
- `docs/deploy-changes.md`
|
||||
- `docs/nginx-upload-limit.md`
|
||||
|
||||
Добавить в конец README.md:
|
||||
|
||||
```markdown
|
||||
## Деплой
|
||||
|
||||
```bash
|
||||
# Настроить scripts/deploy.env
|
||||
cp scripts/deploy.env.example scripts/deploy.env
|
||||
# Заполнить DEPLOY_HOST, DEPLOY_PATH
|
||||
|
||||
# Первичная настройка LXC: см. scripts/SERVER_SETUP.md
|
||||
# Деплой только изменившихся компонентов:
|
||||
./scripts/deploy-auto.sh
|
||||
|
||||
# Полный деплой (игнорировать diff):
|
||||
./scripts/deploy-auto.sh --force
|
||||
```
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md && git commit -m "docs: update README with new deploy instructions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist
|
||||
|
||||
1. **Spec coverage:**
|
||||
- Удаление файлов → Task 1 ✓
|
||||
- .gitignore → Task 2 ✓
|
||||
- deploy-auto.sh с детектом diff → Task 3 ✓
|
||||
- Интерфейс команд (--force, -f, -b) → Task 3 ✓
|
||||
- Transport rsync/tar → Task 3 ✓
|
||||
- SERVER_SETUP.md → Task 4 ✓
|
||||
- README.md → Task 5 ✓
|
||||
- Все пункты spec покрыты ✓
|
||||
|
||||
2. **Placeholder scan:** полный код в каждом шаге, нет "TBD", "TODO", "implement later" ✓
|
||||
3. **Type consistency:** единый подход к переменным (DEPLOY_*), флагам, transport-функциям ✓
|
||||
@@ -0,0 +1,95 @@
|
||||
# Auto-Deploy для Craftshop
|
||||
|
||||
**Дата:** 2026-05-14
|
||||
**Статус:** Утверждён
|
||||
|
||||
## Мотивация
|
||||
|
||||
Заменить ручной SSH-деплой (deploy-ssh.sh) на скрипт с автоматическим детектом изменений через git diff. Деплоятся только те компоненты (client/server), которые реально изменились с последнего деплоя. Удалить устаревшие скрипты и документацию.
|
||||
|
||||
## Состав репозитория после изменений
|
||||
|
||||
### Новые файлы
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `scripts/deploy-auto.sh` | Основной скрипт — детект изменений, сборка, деплой |
|
||||
| `scripts/SERVER_SETUP.md` | Инструкция с командами для первичной настройки LXC от root |
|
||||
|
||||
### Изменённые файлы
|
||||
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| `.gitignore` | Добавить `.deployed-commit` |
|
||||
| `README.md` | Убрать ссылки на удалённые doc'и, добавить секцию по деплою |
|
||||
|
||||
### Удаляемые файлы
|
||||
|
||||
| Файл | Причина |
|
||||
|---|---|
|
||||
| `scripts/deploy-ssh.sh` | Заменён на `deploy-auto.sh` |
|
||||
| `scripts/deploy-ssh.ps1` | Не нужен |
|
||||
| `scripts/deploy.env.example` | Есть `deploy.env` |
|
||||
| `scripts/read-deploy-env.ps1` | Не нужен |
|
||||
| `scripts/register-ssh-key-for-root.ps1` | Разовое действие |
|
||||
| `scripts/complete-lan-deploy.ps1` | Устарел |
|
||||
| `scripts/craftshop-remote-lan.env` | Устарел |
|
||||
| `scripts/server-bootstrap.sh` | Заменён на `SERVER_SETUP.md` |
|
||||
| `docs/deploy-changes.md` | Неактуально |
|
||||
| `docs/test-deploy-proxmox.md` | Неактуально |
|
||||
| `docs/nginx-upload-limit.md` | Неактуально |
|
||||
|
||||
## deploy-auto.sh — алгоритм
|
||||
|
||||
1. Загрузить `scripts/deploy.env` (без перезаписи уже экспортированных переменных)
|
||||
2. Прочитать `.deployed-commit` (если нет файла — взять `HEAD~1`)
|
||||
3. `git diff --name-only <last_commit> HEAD` → определить:
|
||||
- `changed_client` — есть изменения в `client/`
|
||||
- `changed_server` — есть изменения в `server/`
|
||||
- `changed_shared` — есть изменения в `shared/`
|
||||
4. Если `changed_shared` → `changed_server=true`, `changed_client=true` (shared используется обоими)
|
||||
5. Если ни один флаг не true → `echo "nothing to deploy"` → exit 0
|
||||
6. **Клиент:** `cd client && npm ci && npm run build` → rsync/tar `client/dist/` → `$DEPLOY_PATH/www/`
|
||||
7. **Сервер:** rsync/tar `server/` и `shared/` → `$DEPLOY_PATH/` → remote `npm ci && npx prisma generate && npx prisma migrate deploy` → remote `systemctl restart craftshop-api`
|
||||
8. Если все шаги успешны → записать `HEAD` в `.deployed-commit`
|
||||
9. Флаг `--force` — игнорировать diff и деплоить всё
|
||||
|
||||
### Интерфейс команд
|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `./scripts/deploy-auto.sh` | Деплой изменений |
|
||||
| `./scripts/deploy-auto.sh --force` | Полный деплой всех компонентов |
|
||||
| `./scripts/deploy-auto.sh --frontend-only` | Только клиент |
|
||||
| `./scripts/deploy-auto.sh --backend-only` | Только сервер |
|
||||
|
||||
### Поддерживаемые конфиги (deploy.env)
|
||||
|
||||
Обязательные: `DEPLOY_HOST`, `DEPLOY_PATH`
|
||||
Опциональные: `DEPLOY_USER` (root), `DEPLOY_SSH_IDENTITY`, `DEPLOY_RESTART_CMD`, `DEPLOY_FRONTEND_DIST`
|
||||
|
||||
### Transport
|
||||
|
||||
Переиспользуется логика из текущего `deploy-ssh.sh`:
|
||||
- Linux/macOS → rsync
|
||||
- Git Bash Windows → tar | ssh (из‑за проблем cwRsync)
|
||||
|
||||
## SERVER_SETUP.md — структура
|
||||
|
||||
Инструкция с блоками копируемых команд для последовательного выполнения от root:
|
||||
|
||||
1. Установка Node.js 22.x (NodeSource)
|
||||
2. Создание пользователя deploy
|
||||
3. Создание директорий `/opt/craftshop/{server,www,uploads}`
|
||||
4. systemd unit `craftshop-api`
|
||||
5. Установка и настройка nginx (статика + proxy /api + /uploads)
|
||||
6. NetBird VPN (подключение к сети)
|
||||
7. Настройка `.env`
|
||||
8. Запуск сервиса и проверка `GET /health`
|
||||
|
||||
## Безопасность
|
||||
|
||||
- `prisma migrate deploy` — не сбрасывает БД, только накатывает новые миграции
|
||||
- `*.db` исключены из rsync
|
||||
- `.env` и `.dev_env` исключены из rsync
|
||||
- `.deployed-commit` — локальный файл, добавлен в .gitignore
|
||||
@@ -1,78 +0,0 @@
|
||||
# Тестовый деплой на Proxmox (локально у вас в ЦОД)
|
||||
|
||||
Цель — один недорогой стенд: витрина + API + SQLite, без отказоустойчивого кластера.
|
||||
|
||||
## Серверов и ресурсов
|
||||
|
||||
Рекомендация для **минимального тестового** инстанса на Proxmox:
|
||||
|
||||
| Вариант | Ресурс | Комментарий |
|
||||
|---------|--------|---------------|
|
||||
| **1× LXC Debian 12 или Ubuntu 22.04** | CPU: **2 vCPU**, RAM: **1–2 GB**, Диск: **15–25 GB** (SSD/pool SSD) | Один контейнер: Node (API), при желании статика фронта с того же узла через nginx |
|
||||
|
||||
Для сборки (`npm ci` + `vite build`) и запасов по памяти лучше **2 GB RAM**; на **1 GB** возможны OOM при параллельной сборке — собирать фронт можно на машине разработчика и копировать `client/dist`.
|
||||
|
||||
Отделять приложение на **два LXC** (фронт / бэкенд) для теста обычно не нужно; имеет смысл только если хотите явно ограничить поверхность API.
|
||||
|
||||
### Сеть и доступ
|
||||
|
||||
- Одна виртуальная сеть VM/LAN вашего хостинга до Proxmox.
|
||||
- Открыть снаружи (если нужен доступ из браузера): **80/tcp** и **443/tcp** на reverse proxy; прямой порт Fastify (**3333**) наружу не публиковать.
|
||||
- Сервер приложения слушает **опционально** `127.0.0.1:3333`, наружу отдаёт **nginx** или **caddy**:
|
||||
|
||||
- статика из `client/dist` (SPA `try_files`);
|
||||
|
||||
- `location /api/` → proxy на `http://127.0.0.1:3333`;
|
||||
|
||||
- `location /uploads/` → те же файлы с диска, что использует сервер (`server/uploads/` рядом с процессом) или через тот же origin с proxy на Fastify `@fastify/static`.
|
||||
|
||||
## Очистка БД локально перед тестом
|
||||
|
||||
В каталоге `server/` (нужны Node **20.6+** или **22+** для флага `--env-file`; иначе скопируйте переменные из `.dev_env` в `.env` и используйте обычные команды Prisma):
|
||||
|
||||
```bash
|
||||
npm run db:reset:test
|
||||
```
|
||||
|
||||
Удалит данные SQLite, заново применит миграции и выполнит `prisma`-seed из `server/package.json`.
|
||||
|
||||
## Программный стек на ВМ/LXC
|
||||
|
||||
- **Node.js** LTS (20.6+, лучше 22; скрипты `dev`, `start:dev_env`, `db:*:test` читают `server/.dev_env` через `node --env-file=.dev_env`).
|
||||
- Приложение ставится так: склонировать репозиторий, на сервере в `server/` — `npm ci`, `npm run db:migrate:test` (или `migrate deploy`), при необходимости `npm run db:seed:test`.
|
||||
- Переменные окружения: скопировать `server/.dev_env` или `server/.env.example`, задать **сильный** `JWT_SECRET`, свой `ADMIN_EMAIL`, `DATABASE_URL=file:./dev.db` или путь под персистентный раздел (`/var/lib/craftshop/data.db`).
|
||||
- Для прод-подобного теста: **`IS_DEFAULT_CODE_ENABLED=false`** (не оставлять общий код на поставку наружу).
|
||||
|
||||
## Сервис (systemd, пример)
|
||||
|
||||
Один юнит `craftshop-api.service`:
|
||||
|
||||
- `WorkingDirectory=/opt/craftshop/server`
|
||||
- `ExecStart=/usr/bin/node src/index.js` (перед этим экспортировать env через `EnvironmentFile=` к вашему `.env`)
|
||||
- После деплоя: `systemctl daemon-reload && systemctl enable --now craftshop-api`
|
||||
|
||||
Персистентность: файл SQLite и каталог **`uploads/`** должны жить на диске, который не теряется при пересборке образа контейнера.
|
||||
|
||||
## Контрольный чеклист перед «тестом в бою»
|
||||
|
||||
1. Прогон миграций на чистую БД: `npm run db:reset:test` (только в **закрытом** окружении — стирает данные).
|
||||
2. Сборка фронта и выставление `VITE_API_URL` (если API не на том же origin, что SPA).
|
||||
3. `CORS_ORIGIN` — URL публичного фронта.
|
||||
4. Выключить `DEFAULT_CODE` на внешнем стенде.
|
||||
|
||||
## Краткая схема
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[Браузер]
|
||||
Px[nginx на LXC]
|
||||
Api[Fastify Node :3333]
|
||||
Db[(SQLite файл)]
|
||||
Up[диск uploads]
|
||||
|
||||
User --> Px
|
||||
Px -->|"/api"| Api
|
||||
Px -->|"статика /"| Ui[client dist]
|
||||
Api --> Db
|
||||
Api --> Up
|
||||
```
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"],
|
||||
"mcp": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# Первичная настройка LXC для Craftshop
|
||||
|
||||
Выполнять от **root** на свежем Debian/Ubuntu LXC.
|
||||
|
||||
---
|
||||
|
||||
## 1. Базовые пакеты и Node.js
|
||||
|
||||
```bash
|
||||
apt-get update -y
|
||||
apt-get install -y ca-certificates curl gnupg curl git
|
||||
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
node --version # ожидается >= 22
|
||||
npm --version
|
||||
```
|
||||
|
||||
## 2. Каталоги
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/craftshop/server/uploads /opt/craftshop/www
|
||||
```
|
||||
|
||||
## 3. systemd unit
|
||||
|
||||
```bash
|
||||
cat >/etc/systemd/system/craftshop-api.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Craftshop API (Fastify)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/craftshop/server
|
||||
EnvironmentFile=-/opt/craftshop/server/.env
|
||||
ExecStart=/usr/bin/node src/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable craftshop-api.service
|
||||
```
|
||||
|
||||
## 4. Nginx
|
||||
|
||||
```bash
|
||||
apt-get install -y nginx
|
||||
|
||||
cat >/etc/nginx/sites-available/craftshop <<'NGX'
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
server_name _;
|
||||
|
||||
root /opt/craftshop/www;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 250m;
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf /etc/nginx/sites-available/craftshop /etc/nginx/sites-enabled/craftshop
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
## 5. NetBird VPN
|
||||
|
||||
```bash
|
||||
curl -fsSL https://pkgs.netbird.io/install.sh | sh
|
||||
netbird up
|
||||
```
|
||||
|
||||
После `netbird up` появится интерфейс `wt0` с IP из твоей NetBird-сети. Запомни его — он понадобится для NPM.
|
||||
|
||||
## 6. Переменные окружения
|
||||
|
||||
Сгенерируй JWT_SECRET:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Создай `.env`:
|
||||
|
||||
```bash
|
||||
cat >/opt/craftshop/server/.env <<'ENV'
|
||||
DATABASE_URL="file:./prod.db"
|
||||
PORT=3333
|
||||
JWT_SECRET=<вставь сгенерированную строку>
|
||||
ADMIN_EMAIL=<твой email>
|
||||
CORS_ORIGIN=https://<твой-домен>
|
||||
IS_DEFAULT_CODE_ENABLED=false
|
||||
ENV
|
||||
chmod 600 /opt/craftshop/server/.env
|
||||
```
|
||||
|
||||
## 7. Первый деплой
|
||||
|
||||
На машине разработчика (после заполнения `scripts/deploy.env`):
|
||||
|
||||
```bash
|
||||
./scripts/deploy-auto.sh --force
|
||||
```
|
||||
|
||||
После завершения — на сервере:
|
||||
|
||||
```bash
|
||||
systemctl start craftshop-api
|
||||
systemctl status craftshop-api
|
||||
curl http://127.0.0.1:3333/health
|
||||
```
|
||||
|
||||
## 8. VPS с Nginx Proxy Manager
|
||||
|
||||
На VPS (где установлен NPM):
|
||||
|
||||
1. DNS-запись A: `craftshop.твой-домен` → IP VPS
|
||||
2. В NPM → Proxy Hosts → Add:
|
||||
- Domain: `craftshop.твой-домен`
|
||||
- Forward Hostname: `<NetBird-IP-LXC>` (IP wt0 на LXC)
|
||||
- Forward Port: `80`
|
||||
- SSL: Let's Encrypt
|
||||
3. Сохрани
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
curl https://craftshop.твой-домен/api/health
|
||||
```
|
||||
@@ -1,64 +0,0 @@
|
||||
# First-time LAN deploy: bootstrap, craftshop-remote-lan.env, deploy-ssh.ps1 -All, systemd.
|
||||
# Prerequisites: SSH to root works with key (see register-ssh-key-for-root.ps1).
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptsDir = $PSScriptRoot
|
||||
$repoRoot = (Resolve-Path (Join-Path $scriptsDir "..")).Path
|
||||
$deployEnv = Join-Path $scriptsDir "deploy.env"
|
||||
|
||||
if (-not (Test-Path $deployEnv)) {
|
||||
Write-Error "Missing scripts/deploy.env - copy deploy.env.example and set DEPLOY_HOST."
|
||||
}
|
||||
|
||||
. "$PSScriptRoot\read-deploy-env.ps1"
|
||||
Import-DeployDotEnv $deployEnv
|
||||
|
||||
$deployHost = [Environment]::GetEnvironmentVariable("DEPLOY_HOST", "Process")
|
||||
$user = [Environment]::GetEnvironmentVariable("DEPLOY_USER", "Process")
|
||||
if ([string]::IsNullOrWhiteSpace($user)) { $user = "root" }
|
||||
if ([string]::IsNullOrWhiteSpace($deployHost)) {
|
||||
Write-Error "DEPLOY_HOST is missing in scripts/deploy.env."
|
||||
}
|
||||
|
||||
$remote = "${user}@${deployHost}"
|
||||
$bootstrap = Join-Path $scriptsDir "server-bootstrap.sh"
|
||||
$lanEnv = Join-Path $scriptsDir "craftshop-remote-lan.env"
|
||||
|
||||
if (-not (Test-Path $bootstrap)) {
|
||||
Write-Error "Bootstrap script not found: $bootstrap"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $lanEnv)) {
|
||||
Write-Error "Missing scripts/craftshop-remote-lan.env (gitignored). Create it or copy from server/.env.example."
|
||||
}
|
||||
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=8 $remote "echo ok" 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Passwordless SSH to $remote failed. Run .\scripts\register-ssh-key-for-root.ps1 first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ">>> scp bootstrap"
|
||||
scp -o StrictHostKeyChecking=accept-new $bootstrap "${remote}:/root/server-bootstrap.sh"
|
||||
|
||||
Write-Host ">>> run bootstrap on server"
|
||||
ssh $remote "bash /root/server-bootstrap.sh"
|
||||
|
||||
Write-Host ">>> scp server .env"
|
||||
scp -o StrictHostKeyChecking=accept-new $lanEnv "${remote}:/opt/craftshop/server/.env"
|
||||
|
||||
Write-Host ">>> chmod .env (owner = same as /opt/craftshop/server, deploy or root)"
|
||||
ssh $remote "chown --reference=/opt/craftshop/server /opt/craftshop/server/.env || chown root:root /opt/craftshop/server/.env; chmod 600 /opt/craftshop/server/.env"
|
||||
|
||||
Set-Location $repoRoot
|
||||
Write-Host ">>> deploy-ssh.ps1 -All"
|
||||
& (Join-Path $scriptsDir "deploy-ssh.ps1") -All
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
Write-Host ">>> systemd enable craftshop-api"
|
||||
ssh $remote "systemctl enable --now craftshop-api"
|
||||
|
||||
Write-Host ">>> health check"
|
||||
ssh $remote "curl -sS http://127.0.0.1:3333/health"
|
||||
|
||||
Write-Host "Done. Open http://${deployHost}/"
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auto-deploy: детект изменений через git diff, сборка и деплой только изменённых компонентов.
|
||||
#
|
||||
# Зависимости: bash, git, ssh; rsync (Linux/macOS) или tar (Git Bash).
|
||||
# Конфиг: scripts/deploy.env.
|
||||
#
|
||||
# Примеры:
|
||||
# ./scripts/deploy-auto.sh # деплой изменений
|
||||
# ./scripts/deploy-auto.sh --force # полный деплой всех компонентов
|
||||
# ./scripts/deploy-auto.sh -f # только фронт
|
||||
# ./scripts/deploy-auto.sh -b # только бэкенд
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW* | MSYS*) export MSYS2_ARG_CONV_EXCL="*" ;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# --- Config ---
|
||||
if [[ -f "$SCRIPT_DIR/deploy.env" ]]; then
|
||||
set -a
|
||||
source "$SCRIPT_DIR/deploy.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
DEPLOY_HOST="${DEPLOY_HOST:-}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-root}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/craftshop}"
|
||||
DEPLOY_FRONTEND_DIST="${DEPLOY_FRONTEND_DIST:-$DEPLOY_PATH/www}"
|
||||
DEPLOY_SSH_IDENTITY="${DEPLOY_SSH_IDENTITY:-}"
|
||||
DEPLOY_RESTART_CMD="${DEPLOY_RESTART_CMD:-systemctl restart craftshop-api}"
|
||||
DEPLOY_SERVER_OWNER="${DEPLOY_SERVER_OWNER:-root}"
|
||||
DEPLOY_SKIP_CHOWN="${DEPLOY_SKIP_CHOWN:-0}"
|
||||
|
||||
RSYNC_OPTS=(-az --delete --human-readable --progress)
|
||||
SSH_OPTS=()
|
||||
if [[ -n "${DEPLOY_SSH_IDENTITY}" ]]; then
|
||||
SSH_OPTS+=(-i "$DEPLOY_SSH_IDENTITY")
|
||||
fi
|
||||
|
||||
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
SSH_BASE=(ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new "$REMOTE")
|
||||
|
||||
# --- Flags ---
|
||||
FORCE=false
|
||||
TARGET="auto"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Использование: $(basename "$0") [опции]
|
||||
|
||||
--force Деплой всех компонентов (игнорировать diff)
|
||||
--frontend-only | -f Только клиент
|
||||
--backend-only | -b Только сервер
|
||||
--help | -h Помощь
|
||||
|
||||
Окружение (или scripts/deploy.env):
|
||||
DEPLOY_HOST хост SSH (обязательно)
|
||||
DEPLOY_PATH корень приложения на сервере (по умолчанию: /opt/craftshop)
|
||||
DEPLOY_SSH_IDENTITY путь к приватному ключу (-i ssh)
|
||||
DEPLOY_RESTART_CMD команда рестарта API после деплоя бэкенда
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--force) FORCE=true ;;
|
||||
--frontend-only | -f) TARGET="frontend" ;;
|
||||
--backend-only | -b) TARGET="backend" ;;
|
||||
-h | --help) usage; exit 0 ;;
|
||||
*) echo "Неизвестный аргумент: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z "$DEPLOY_HOST" ]]; then
|
||||
echo "Укажите DEPLOY_HOST (или добавьте в scripts/deploy.env)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Helpers ---
|
||||
remote_exec() {
|
||||
"${SSH_BASE[@]}" "$@"
|
||||
}
|
||||
|
||||
should_use_tar_transport() {
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW*|MSYS*|CYGWIN_NT*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_rsync_rsh() {
|
||||
printf '%q ' ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new
|
||||
}
|
||||
|
||||
# --- Diff detection ---
|
||||
changed_client=false
|
||||
changed_server=false
|
||||
|
||||
if [[ "$TARGET" == "frontend" ]]; then
|
||||
changed_client=true
|
||||
elif [[ "$TARGET" == "backend" ]]; then
|
||||
changed_server=true
|
||||
elif [[ "$FORCE" == true ]]; then
|
||||
changed_client=true
|
||||
changed_server=true
|
||||
else
|
||||
DEPLOYED_FILE="$ROOT/.deployed-commit"
|
||||
if [[ -f "$DEPLOYED_FILE" ]]; then
|
||||
LAST_DEPLOYED=$(cat "$DEPLOYED_FILE")
|
||||
else
|
||||
LAST_DEPLOYED="HEAD~1"
|
||||
fi
|
||||
|
||||
echo ">>> Diff: $LAST_DEPLOYED..HEAD"
|
||||
CHANGED_FILES=$(git -C "$ROOT" diff --name-only "$LAST_DEPLOYED" HEAD 2>/dev/null || true)
|
||||
|
||||
if echo "$CHANGED_FILES" | grep -q "^client/"; then
|
||||
changed_client=true
|
||||
fi
|
||||
if echo "$CHANGED_FILES" | grep -q "^server/"; then
|
||||
changed_server=true
|
||||
fi
|
||||
if echo "$CHANGED_FILES" | grep -q "^shared/"; then
|
||||
changed_client=true
|
||||
changed_server=true
|
||||
fi
|
||||
|
||||
if [[ "$changed_client" == false && "$changed_server" == false ]]; then
|
||||
echo ">>> Ничего не изменилось с последнего деплоя."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ">>> Клиент: $changed_client, Сервер: $changed_server"
|
||||
|
||||
# --- Deploy: Client ---
|
||||
if [[ "$changed_client" == true ]]; then
|
||||
echo ">>> Сборка клиента..."
|
||||
(cd "$ROOT/client" && npm ci && npm run build)
|
||||
|
||||
remote_exec mkdir -p "$DEPLOY_FRONTEND_DIST"
|
||||
|
||||
if should_use_tar_transport; then
|
||||
remote_exec "find ${DEPLOY_FRONTEND_DIST} -mindepth 1 -delete 2>/dev/null || true"
|
||||
(cd "$ROOT/client/dist" && tar -czf - .) | \
|
||||
"${SSH_BASE[@]}" "mkdir -p ${DEPLOY_FRONTEND_DIST} && tar xzf - -C ${DEPLOY_FRONTEND_DIST}"
|
||||
else
|
||||
rsh="$(build_rsync_rsh)"
|
||||
rsync "${RSYNC_OPTS[@]}" -e "$rsh" \
|
||||
"$ROOT/client/dist/" "${REMOTE}:${DEPLOY_FRONTEND_DIST}/"
|
||||
fi
|
||||
|
||||
echo ">>> Клиент задеплоен"
|
||||
fi
|
||||
|
||||
# --- Deploy: Server ---
|
||||
if [[ "$changed_server" == true ]]; then
|
||||
remote_exec mkdir -p "$DEPLOY_PATH/server" "$DEPLOY_PATH/shared"
|
||||
|
||||
if should_use_tar_transport; then
|
||||
(cd "$ROOT/server" && tar -czf - \
|
||||
--exclude=node_modules --exclude=uploads --exclude=.git \
|
||||
--exclude='*.db' --exclude=.env --exclude=.dev_env \
|
||||
.) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/server && tar xzf - -C ${DEPLOY_PATH}/server"
|
||||
|
||||
(cd "$ROOT/shared" && tar -czf - \
|
||||
--exclude=.git \
|
||||
.) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/shared && tar xzf - -C ${DEPLOY_PATH}/shared"
|
||||
else
|
||||
rsh="$(build_rsync_rsh)"
|
||||
rsync "${RSYNC_OPTS[@]}" -e "$rsh" \
|
||||
--exclude node_modules --exclude uploads --exclude .git \
|
||||
--exclude '*.db' --exclude .env --exclude .dev_env \
|
||||
"$ROOT/server/" "${REMOTE}:${DEPLOY_PATH}/server/"
|
||||
|
||||
rsync "${RSYNC_OPTS[@]}" -e "$rsh" \
|
||||
--exclude .git \
|
||||
"$ROOT/shared/" "${REMOTE}:${DEPLOY_PATH}/shared/"
|
||||
fi
|
||||
|
||||
echo ">>> Сервер: npm ci, prisma generate, migrate deploy"
|
||||
remote_exec bash -lc "set -e
|
||||
cd \"$DEPLOY_PATH/server\"
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
"
|
||||
|
||||
if [[ "${DEPLOY_USER}" == "root" && "${DEPLOY_SKIP_CHOWN}" != "1" ]]; then
|
||||
echo ">>> chown ${DEPLOY_SERVER_OWNER}"
|
||||
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/server"
|
||||
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/shared"
|
||||
fi
|
||||
|
||||
if [[ -n "${DEPLOY_RESTART_CMD}" ]]; then
|
||||
echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
|
||||
remote_exec bash -lc "$DEPLOY_RESTART_CMD"
|
||||
fi
|
||||
|
||||
echo ">>> Сервер задеплоен"
|
||||
fi
|
||||
|
||||
# --- Save deployed commit ---
|
||||
if [[ "$TARGET" == "auto" && "$FORCE" != true ]]; then
|
||||
CURRENT_HEAD=$(git -C "$ROOT" rev-parse HEAD)
|
||||
echo "$CURRENT_HEAD" > "$ROOT/.deployed-commit"
|
||||
echo ">>> Сохранён коммит $CURRENT_HEAD"
|
||||
fi
|
||||
|
||||
echo ">>> Готово."
|
||||
@@ -1,82 +0,0 @@
|
||||
# Вызывает deploy-ssh.sh через bash (Git for Windows или bash в PATH).
|
||||
# Запускайте из корня репозитория или откуда угодно — скрипт перейдёт в root.
|
||||
|
||||
param(
|
||||
[switch]$FrontendOnly,
|
||||
[switch]$BackendOnly,
|
||||
[switch]$All,
|
||||
[switch]$DryRun,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptsDir = $PSScriptRoot
|
||||
$repoRoot = (Resolve-Path (Join-Path $scriptsDir "..")).Path
|
||||
|
||||
$gitUsrRs = "C:\Program Files\Git\usr\bin\rsync.exe"
|
||||
$chocoRsDir = "C:\ProgramData\chocolatey\bin"
|
||||
$chocoRs = Join-Path $chocoRsDir "rsync.exe"
|
||||
if (Test-Path -LiteralPath $gitUsrRs) {
|
||||
$env:Path = "C:\Program Files\Git\usr\bin;$env:Path"
|
||||
} elseif (Test-Path -LiteralPath $chocoRs) {
|
||||
$env:Path = "$chocoRsDir;$env:Path"
|
||||
}
|
||||
|
||||
function Show-Help {
|
||||
@"
|
||||
Использование (рядом с репозиторием или из любого каталога):
|
||||
.\scripts\deploy-ssh.ps1 [-FrontendOnly] [-BackendOnly] [-All]
|
||||
.\scripts\deploy-ssh.ps1 -DryRun -BackendOnly
|
||||
|
||||
Конфиг: scripts/deploy.env (скопируйте из deploy.env.example).
|
||||
|
||||
Нужны: bash (Git for Windows) и rsync в PATH. rsync без Git: установите пакет (например, choco install rsync).
|
||||
|
||||
Если npm ci падает с EPERM на .node (Windows): остановите Vite/Node, затем снова .\scripts\deploy-ssh.ps1
|
||||
Или: cd client; npm run build; затем .\scripts\deploy-ssh.ps1 -SkipBuild (только выкладка dist).
|
||||
"@ | Write-Host
|
||||
}
|
||||
|
||||
if ($Help) { Show-Help; exit 0 }
|
||||
|
||||
$bash = Get-Command bash -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
|
||||
if (-not $bash) {
|
||||
$git = "C:\Program Files\Git\bin\bash.exe"
|
||||
if (Test-Path $git) { $bash = $git }
|
||||
}
|
||||
if (-not $bash) {
|
||||
Write-Error "Не найден bash. Установите Git for Windows и добавьте Git\usr\bin в PATH (rsync)."
|
||||
}
|
||||
|
||||
if (-not (Get-Command rsync -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "Не найден rsync. Установите: choco install rsync -y либо добавьте C:\Program Files\Git\usr\bin в PATH."
|
||||
}
|
||||
|
||||
$argsToSh = [System.Collections.ArrayList]@()
|
||||
if ($FrontendOnly) { [void]$argsToSh.Add("--frontend-only") }
|
||||
elseif ($BackendOnly) { [void]$argsToSh.Add("--backend-only") }
|
||||
else { [void]$argsToSh.Add("--all") }
|
||||
|
||||
if ($DryRun) { [void]$argsToSh.Add("--dry-run") }
|
||||
if ($SkipBuild) { [void]$argsToSh.Add("--skip-build") }
|
||||
|
||||
function ConvertTo-MsysPath {
|
||||
param([string]$Path)
|
||||
$full = if (Test-Path $Path) { (Resolve-Path -LiteralPath $Path).Path } else { $Path }
|
||||
if ($full -match '^([A-Za-z]):[\\/](.*)$') {
|
||||
return "/" + $Matches[1].ToLower() + "/" + ($Matches[2] -replace '\\', '/')
|
||||
}
|
||||
return ($full -replace '\\', '/')
|
||||
}
|
||||
|
||||
$sh = Join-Path $scriptsDir "deploy-ssh.sh"
|
||||
$shUnix = ConvertTo-MsysPath $sh
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
& $bash $shUnix @argsToSh
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Деплой по SSH: отдельно фронт (сборка + rsync dist), бэк (rsync + npm ci + prisma migrate).
|
||||
#
|
||||
# Требования: bash, ssh; на Linux/macOS используется rsync, на Git Bash/WIN — tar|ssh (обход проблем cwRsync с путами).
|
||||
#
|
||||
# Конфиг: переменные окружения или файл scripts/deploy.env (скопируйте из scripts/deploy.env.example).
|
||||
#
|
||||
# Примеры:
|
||||
# ./scripts/deploy-ssh.sh --backend-only
|
||||
# ./scripts/deploy-ssh.sh --frontend-only
|
||||
# ./scripts/deploy-ssh.sh --all
|
||||
# DEPLOY_HOST=10.0.0.5 ./scripts/deploy-ssh.sh -b
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Git Bash вызывает Win32 ssh.exe: аргументы вроде /opt/... иначе подменяются → «mkdir: missing operand».
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW* | MSYS*) export MSYS2_ARG_CONV_EXCL="*" ;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Загрузка deploy.env без перезаписи уже экспортированных переменных
|
||||
if [[ -f "$SCRIPT_DIR/deploy.env" ]]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$SCRIPT_DIR/deploy.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
DEPLOY_HOST="${DEPLOY_HOST:-}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-root}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/craftshop}"
|
||||
DEPLOY_FRONTEND_DIST="${DEPLOY_FRONTEND_DIST:-$DEPLOY_PATH/www}"
|
||||
DEPLOY_SSH_IDENTITY="${DEPLOY_SSH_IDENTITY:-}"
|
||||
DEPLOY_RESTART_CMD="${DEPLOY_RESTART_CMD:-}" # от root: systemctl restart craftshop-api; от непривилегированного: sudo …
|
||||
# При SSH от root файлы после rsync оказываются root:root; если API в systemd под другим пользователем (bootstrap: deploy), нужен chown:
|
||||
DEPLOY_SERVER_OWNER="${DEPLOY_SERVER_OWNER:-deploy}"
|
||||
DEPLOY_SKIP_CHOWN="${DEPLOY_SKIP_CHOWN:-0}" # 1 — не вызывать chown (например API тоже под root)
|
||||
|
||||
RSYNC_OPTS=(-az --delete --human-readable --progress)
|
||||
SSH_OPTS=()
|
||||
if [[ -n "${DEPLOY_SSH_IDENTITY}" ]]; then
|
||||
SSH_OPTS+=(-i "$DEPLOY_SSH_IDENTITY")
|
||||
fi
|
||||
|
||||
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
SSH_BASE=(ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new "$REMOTE")
|
||||
|
||||
should_use_tar_transport() {
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW*|MSYS*|CYGWIN_NT*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Использование: $(basename "$0") [опции]
|
||||
|
||||
--frontend-only | -f Деплой только фронта (локальная сборка + rsync dist)
|
||||
--backend-only | -b Деплой только бэкенда (rsync server + npm ci + prisma)
|
||||
--all | -a Оба компонента (по умолчанию)
|
||||
|
||||
--dry-run Только показать команды rsync (без записи на сервер)
|
||||
--skip-build Не вызывать npm run build (использовать текущий client/dist)
|
||||
|
||||
Окружение (или scripts/deploy.env):
|
||||
DEPLOY_HOST хост SSH (обязательно)
|
||||
DEPLOY_USER пользователь SSH (по умолчанию: root)
|
||||
DEPLOY_PATH корень приложения на сервере (по умолчанию: /opt/craftshop)
|
||||
DEPLOY_FRONTEND_DIST каталог под статику SPA (по умолчанию: \$DEPLOY_PATH/www)
|
||||
DEPLOY_SSH_IDENTITY путь к приватному ключу (-i ssh)
|
||||
DEPLOY_RESTART_CMD команда после бэкенд-деплоя (опционально; под root без sudo)
|
||||
DEPLOY_SERVER_OWNER при DEPLOY_USER=root: владелец \$DEPLOY_PATH/server после деплоя (по умолчанию: deploy)
|
||||
DEPLOY_SKIP_CHOWN 1 — не менять владельца каталога server (если процесс под root и т.п.)
|
||||
USAGE
|
||||
}
|
||||
|
||||
TARGET="all"
|
||||
DRY_RUN=""
|
||||
SKIP_BUILD=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--frontend-only | -f) TARGET="frontend" ;;
|
||||
--backend-only | -b) TARGET="backend" ;;
|
||||
--all | -a) TARGET="all" ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--skip-build) SKIP_BUILD=1 ;;
|
||||
-h | --help) usage; exit 0 ;;
|
||||
*) echo "Неизвестный аргумент: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z "$DEPLOY_HOST" ]]; then
|
||||
echo "Укажите DEPLOY_HOST (или добавьте в scripts/deploy.env)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
remote_exec() {
|
||||
"${SSH_BASE[@]}" "$@"
|
||||
}
|
||||
|
||||
if [[ -n "$DRY_RUN" ]]; then
|
||||
RSYNC_OPTS+=(--dry-run)
|
||||
fi
|
||||
|
||||
# Строка для rsync -e (одна подкоманда; пути без пробелов в -i надёжнее)
|
||||
build_rsync_rsh() {
|
||||
printf '%q ' ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new
|
||||
}
|
||||
|
||||
deploy_backend() {
|
||||
remote_exec mkdir -p "$DEPLOY_PATH/server"
|
||||
remote_exec mkdir -p "$DEPLOY_PATH/shared"
|
||||
|
||||
if should_use_tar_transport; then
|
||||
echo ">>> Бэкенд (server): tar|ssh → $REMOTE:$DEPLOY_PATH/server/"
|
||||
if [[ -n "$DRY_RUN" ]]; then
|
||||
echo "(dry-run) без передачи tar"
|
||||
else
|
||||
(
|
||||
cd "$ROOT/server" || exit 1
|
||||
tar -czf - \
|
||||
--exclude=node_modules \
|
||||
--exclude=uploads \
|
||||
--exclude=.git \
|
||||
--exclude='*.db' \
|
||||
--exclude=.env \
|
||||
--exclude=.dev_env \
|
||||
.
|
||||
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/server && tar xzf - -C ${DEPLOY_PATH}/server"
|
||||
|
||||
echo ">>> Бэкенд (shared): tar|ssh → $REMOTE:$DEPLOY_PATH/shared/"
|
||||
(
|
||||
cd "$ROOT/shared" || exit 1
|
||||
tar -czf - \
|
||||
--exclude=.git \
|
||||
.
|
||||
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/shared && tar xzf - -C ${DEPLOY_PATH}/shared"
|
||||
fi
|
||||
else
|
||||
echo ">>> Бэкенд (server): rsync → $REMOTE:$DEPLOY_PATH/server/"
|
||||
local rsh
|
||||
rsh="$(build_rsync_rsh)"
|
||||
|
||||
rsync "${RSYNC_OPTS[@]}" \
|
||||
-e "$rsh" \
|
||||
--exclude node_modules \
|
||||
--exclude uploads \
|
||||
--exclude .git \
|
||||
--exclude '*.db' \
|
||||
--exclude .env \
|
||||
--exclude .dev_env \
|
||||
"${ROOT}/server/" "${REMOTE}:${DEPLOY_PATH}/server/"
|
||||
|
||||
echo ">>> Бэкенд (shared): rsync → $REMOTE:$DEPLOY_PATH/shared/"
|
||||
rsync "${RSYNC_OPTS[@]}" \
|
||||
-e "$rsh" \
|
||||
--exclude .git \
|
||||
"${ROOT}/shared/" "${REMOTE}:${DEPLOY_PATH}/shared/"
|
||||
fi
|
||||
|
||||
if [[ -n "$DRY_RUN" ]]; then
|
||||
echo "(dry-run) пропуск удалённых команд npm/prisma"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ">>> Бэкенд: npm ci, prisma generate, migrate deploy на сервере"
|
||||
remote_exec bash -lc "set -e
|
||||
cd \"$DEPLOY_PATH/server\"
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
"
|
||||
if [[ "${DEPLOY_USER}" == "root" && "${DEPLOY_SKIP_CHOWN}" != "1" ]]; then
|
||||
echo ">>> Права на серверный каталог: chown ${DEPLOY_SERVER_OWNER} (деплой от root)"
|
||||
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/server"
|
||||
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/shared"
|
||||
fi
|
||||
if [[ -n "${DEPLOY_RESTART_CMD}" ]]; then
|
||||
echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
|
||||
remote_exec bash -lc "$DEPLOY_RESTART_CMD"
|
||||
elif [[ -z "$DRY_RUN" ]]; then
|
||||
echo "" >&2
|
||||
echo "ВНИМАНИЕ: код сервера обновлён, но процесс Node не перезапущен." >&2
|
||||
echo " Без рестарта новые маршруты (и правки API) не появятся." >&2
|
||||
echo " Задайте в deploy.env: DEPLOY_RESTART_CMD='systemctl restart <ваш-сервис-api>'" >&2
|
||||
echo "" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
deploy_frontend() {
|
||||
if [[ -z "$SKIP_BUILD" ]]; then
|
||||
echo ">>> Фронт: npm ci и npm run build (локально)"
|
||||
# Windows: ESLint/typescript-eslint тянут @unrs/*.node — npm ci часто получает EPERM unlink, если файл держит Node/IDE или остался мусор .resolver-binding-* после сбоя.
|
||||
if should_use_tar_transport; then
|
||||
echo ">>> (Windows/Git Bash) перед npm ci: снимаем блокировки нативных .node (@unrs, @rolldown, .resolver-binding-*)"
|
||||
echo ">>> Подсказка: остановите «npm run dev» / dev-серверы и IDE, если EPERM останется."
|
||||
rm -rf "$ROOT/client/node_modules/@unrs" "$ROOT/client/node_modules/@rolldown" 2>/dev/null || true
|
||||
(
|
||||
cd "$ROOT/client/node_modules" 2>/dev/null || exit 0
|
||||
shopt -s nullglob
|
||||
for x in ./.resolver-binding-*; do
|
||||
[[ -d "$x" ]] && rm -rf "$x"
|
||||
done
|
||||
)
|
||||
fi
|
||||
(cd "$ROOT/client" && npm ci && npm run build) || {
|
||||
echo "" >&2
|
||||
echo "Сборка фронта не удалась. На Windows часто EPERM на .node — закройте процессы Node (dev-сервер), повторите." >&2
|
||||
echo "Или соберите фронт вручную (cd client && npm run build), затем: $0 --frontend-only --skip-build" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo ">>> Фронт: сборка пропущена (--skip-build)"
|
||||
if [[ ! -d "$ROOT/client/dist" ]]; then
|
||||
echo "Нет $ROOT/client/dist — выполните сборку без --skip-build" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
remote_exec mkdir -p "$DEPLOY_FRONTEND_DIST"
|
||||
|
||||
if should_use_tar_transport; then
|
||||
echo ">>> Фронт: tar|ssh dist → $REMOTE:$DEPLOY_FRONTEND_DIST/"
|
||||
if [[ -n "$DRY_RUN" ]]; then
|
||||
echo "(dry-run) без передачи tar (www)"
|
||||
else
|
||||
remote_exec "mkdir -p ${DEPLOY_FRONTEND_DIST} && find ${DEPLOY_FRONTEND_DIST} -mindepth 1 -delete 2>/dev/null || true"
|
||||
(
|
||||
cd "$ROOT/client/dist" || exit 1
|
||||
tar -czf - .
|
||||
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_FRONTEND_DIST} && tar xzf - -C ${DEPLOY_FRONTEND_DIST}"
|
||||
fi
|
||||
else
|
||||
echo ">>> Фронт: rsync dist → $REMOTE:$DEPLOY_FRONTEND_DIST/"
|
||||
local rsh
|
||||
rsh="$(build_rsync_rsh)"
|
||||
rsync "${RSYNC_OPTS[@]}" \
|
||||
-e "$rsh" \
|
||||
"${ROOT}/client/dist/" "${REMOTE}:${DEPLOY_FRONTEND_DIST}/"
|
||||
fi
|
||||
|
||||
echo ">>> Фронт готов (проверьте nginx/root на путь $DEPLOY_FRONTEND_DIST)"
|
||||
}
|
||||
|
||||
case "$TARGET" in
|
||||
backend) deploy_backend ;;
|
||||
frontend) deploy_frontend ;;
|
||||
all)
|
||||
deploy_backend
|
||||
deploy_frontend
|
||||
;;
|
||||
*) echo "internal: bad TARGET=$TARGET" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "Готово."
|
||||
@@ -1,19 +0,0 @@
|
||||
# Скопируйте в deploy.env рядом с deploy-ssh.sh и подставьте значения.
|
||||
|
||||
DEPLOY_HOST=192.168.1.88
|
||||
DEPLOY_USER=root
|
||||
DEPLOY_PATH=/opt/craftshop
|
||||
|
||||
# Куда выкладывается `client/dist/` (совпадайте с root в nginx для SPA + try_files).
|
||||
DEPLOY_FRONTEND_DIST=/opt/craftshop/www
|
||||
|
||||
# Опционально: ssh -i
|
||||
# DEPLOY_SSH_IDENTITY=C:/Users/Me/.ssh/id_ed25519
|
||||
|
||||
# Если API под пользователем deploy — оставьте DEPLOY_SKIP_CHOWN=0 (дефолт chown deploy).
|
||||
# Если на сервере CRAFTSHOP_USER=root (systemd под root): DEPLOY_SKIP_CHOWN=1 и DEPLOY_SERVER_OWNER=root
|
||||
DEPLOY_SKIP_CHOWN=0
|
||||
# DEPLOY_SERVER_OWNER=deploy
|
||||
|
||||
# После обновления кода API (под root без sudo)
|
||||
DEPLOY_RESTART_CMD='systemctl restart craftshop-api'
|
||||
@@ -1,14 +0,0 @@
|
||||
function Import-DeployDotEnv {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path $Path)) { return }
|
||||
Get-Content $Path | ForEach-Object {
|
||||
if ($_ -match '^\s*#' -or $_ -match '^\s*$') { return }
|
||||
if ($_ -match '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') {
|
||||
$name = $Matches[1]; $raw = $Matches[2].Trim()
|
||||
$v = $raw
|
||||
if ($raw.StartsWith("'") -and $raw.EndsWith("'")) { $v = $raw.Trim("'") }
|
||||
elseif ($raw.StartsWith('"') -and $raw.EndsWith('"')) { $v = $raw.Trim('"') }
|
||||
[Environment]::SetEnvironmentVariable($name, $v, "Process")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
# Appends local id_ed25519.pub to root authorized_keys on the server (uses scripts/deploy.env).
|
||||
# Run from repo root: .\scripts\register-ssh-key-for-root.ps1
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptsDir = $PSScriptRoot
|
||||
$deployEnv = Join-Path $scriptsDir "deploy.env"
|
||||
|
||||
if (-not (Test-Path $deployEnv)) {
|
||||
Write-Error "Missing scripts/deploy.env. Copy from deploy.env.example and set DEPLOY_HOST."
|
||||
}
|
||||
|
||||
. "$PSScriptRoot\read-deploy-env.ps1"
|
||||
Import-DeployDotEnv $deployEnv
|
||||
|
||||
$deployHost = [Environment]::GetEnvironmentVariable("DEPLOY_HOST", "Process")
|
||||
$user = [Environment]::GetEnvironmentVariable("DEPLOY_USER", "Process")
|
||||
if ([string]::IsNullOrWhiteSpace($user)) { $user = "root" }
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($deployHost)) {
|
||||
Write-Error "DEPLOY_HOST is not set in scripts/deploy.env."
|
||||
}
|
||||
|
||||
$keyPub = Join-Path $env:USERPROFILE ".ssh\id_ed25519.pub"
|
||||
if (-not (Test-Path $keyPub)) {
|
||||
Write-Error "Public key not found: $keyPub"
|
||||
}
|
||||
|
||||
$remote = "${user}@${deployHost}"
|
||||
Write-Host "Adding key to $remote (from $keyPub). Enter password if SSH asks."
|
||||
$bashCmd = "umask 077; mkdir -p .ssh && touch .ssh/authorized_keys && chmod 700 .ssh && cat >> .ssh/authorized_keys && chmod 600 .ssh/authorized_keys"
|
||||
Get-Content -Raw $keyPub | ssh -o StrictHostKeyChecking=accept-new $remote $bashCmd
|
||||
|
||||
Write-Host "Done. Verify: ssh $remote echo ssh-ok"
|
||||
@@ -1,203 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Этап A: первичная настройка свежего Debian/Ubuntu (LXC/VM) под Craftshop.
|
||||
# Запускать НА СЕРВЕРЕ от root один раз:
|
||||
# curl -fsSL … | bash или scp + bash server-bootstrap.sh
|
||||
#
|
||||
# По умолчанию: ставит Node 22.x (NodeSource), nginx, пользователя deploy, каталоги
|
||||
# /opt/craftshop/server и /opt/craftshop/www, systemd craftshop-api, сайт nginx.
|
||||
#
|
||||
# После выполнения:
|
||||
# 1) Положите .env в /opt/craftshop/server/ (DATABASE_URL, JWT_SECRET, ADMIN_EMAIL, CORS_ORIGIN, PORT=3333)
|
||||
# 2) DEPLOY_RESTART_CMD при SSH от root: «systemctl restart craftshop-api» (без sudo)
|
||||
# 3) Запустите deploy-ssh.sh с вашей машины (по умолчанию пользователь SSH — root, см. scripts/deploy.env.example)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$(id -u)" != "0" ]]; then
|
||||
echo "Запускайте от root: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CRAFTSHOP_ROOT="${CRAFTSHOP_ROOT:-/opt/craftshop}"
|
||||
CRAFTSHOP_USER="${CRAFTSHOP_USER:-deploy}"
|
||||
CRAFTSHOP_SERVER_NAME="${CRAFTSHOP_SERVER_NAME:-_}"
|
||||
CRAFTSHOP_NODE_MAJOR="${CRAFTSHOP_NODE_MAJOR:-22}"
|
||||
SKIP_NODE_INSTALL="${SKIP_NODE_INSTALL:-0}"
|
||||
SKIP_NGINX="${SKIP_NGINX:-0}"
|
||||
SKIP_SYSTEMD="${SKIP_SYSTEMD:-0}"
|
||||
|
||||
if [[ -f /etc/craftshop-bootstrap.env ]]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source /etc/craftshop-bootstrap.env
|
||||
set +a
|
||||
fi
|
||||
|
||||
detect_os() {
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source /etc/os-release
|
||||
echo "${ID:-unknown}"
|
||||
return 0
|
||||
fi
|
||||
echo unknown
|
||||
}
|
||||
|
||||
OS="$(detect_os)"
|
||||
if [[ "$OS" != "debian" && "$OS" != "ubuntu" ]]; then
|
||||
echo "Ожидался Debian или Ubuntu (получено: $OS). Прервите выполнение, если образ другой." >&2
|
||||
fi
|
||||
|
||||
echo ">>> apt-get update"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
|
||||
echo ">>> Базовые пакеты"
|
||||
apt-get install -y ca-certificates curl gnupg
|
||||
|
||||
if [[ "$SKIP_NODE_INSTALL" != "1" ]]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
ver="$(node -p "parseInt(process.versions.node,10)" 2>/dev/null || echo 0)"
|
||||
if [[ "${ver:-0}" -ge 20 ]]; then
|
||||
echo ">>> Node уже есть (версия $(node --version)), пропуск NodeSource"
|
||||
else
|
||||
echo ">>> Node слишком старый, ставим ${CRAFTSHOP_NODE_MAJOR}.x через NodeSource"
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${CRAFTSHOP_NODE_MAJOR}.x" | bash -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
else
|
||||
echo ">>> Установка Node.js ${CRAFTSHOP_NODE_MAJOR}.x через NodeSource"
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${CRAFTSHOP_NODE_MAJOR}.x" | bash -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
else
|
||||
echo ">>> SKIP_NODE_INSTALL=1 — убедитесь, что node >= 20.6 есть в системе"
|
||||
command -v node >/dev/null 2>&1 || {
|
||||
echo "node не найден" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo ">>> Пользователь $CRAFTSHOP_USER"
|
||||
if ! id -u "$CRAFTSHOP_USER" >/dev/null 2>&1; then
|
||||
useradd --create-home --shell /bin/bash "$CRAFTSHOP_USER"
|
||||
fi
|
||||
|
||||
echo ">>> Каталоги $CRAFTSHOP_ROOT/{server,www,uploads}"
|
||||
mkdir -p "$CRAFTSHOP_ROOT/server/uploads" "$CRAFTSHOP_ROOT/www"
|
||||
chown -R "$CRAFTSHOP_USER:$CRAFTSHOP_USER" "$CRAFTSHOP_ROOT"
|
||||
chmod 755 "$CRAFTSHOP_ROOT" "$CRAFTSHOP_ROOT/server" "$CRAFTSHOP_ROOT/www"
|
||||
|
||||
if [[ "$SKIP_SYSTEMD" != "1" ]]; then
|
||||
echo ">>> systemd craftshop-api"
|
||||
cat >/etc/systemd/system/craftshop-api.service <<EOF
|
||||
[Unit]
|
||||
Description=Craftshop API (Fastify)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$CRAFTSHOP_USER
|
||||
Group=$CRAFTSHOP_USER
|
||||
WorkingDirectory=$CRAFTSHOP_ROOT/server
|
||||
EnvironmentFile=-$CRAFTSHOP_ROOT/server/.env
|
||||
ExecStart=/usr/bin/node src/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable craftshop-api.service
|
||||
echo "(сервис не запущен, пока не появится код и \$CRAFTSHOP_ROOT/server/.env)"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_NGINX" != "1" ]]; then
|
||||
apt-get install -y nginx
|
||||
|
||||
NGINX_SITE="/etc/nginx/sites-available/craftshop"
|
||||
|
||||
cat >"$NGINX_SITE" <<NGX
|
||||
# Craftshop: статика SPA + proxy /api и /uploads на Node (:3333)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
server_name $CRAFTSHOP_SERVER_NAME;
|
||||
|
||||
root $CRAFTSHOP_ROOT/www;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 250m;
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
if [[ -f /etc/nginx/sites-enabled/default ]]; then
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
fi
|
||||
ln -sf "$NGINX_SITE" /etc/nginx/sites-enabled/craftshop
|
||||
|
||||
nginx -t
|
||||
systemctl reload nginx || systemctl restart nginx
|
||||
|
||||
echo ">>> nginx включён на порту 80 (server_name=$CRAFTSHOP_SERVER_NAME)"
|
||||
fi
|
||||
|
||||
cat >"$CRAFTSHOP_ROOT/server/README-PLACE-ENV.txt" <<TXT
|
||||
Создайте файл .env в этом каталоге (права только для $CRAFTSHOP_USER).
|
||||
Минимум:
|
||||
DATABASE_URL="file:./prod.db"
|
||||
PORT=3333
|
||||
JWT_SECRET=<длинная случайная строка>
|
||||
ADMIN_EMAIL=<ваш email админа>
|
||||
CORS_ORIGIN=http://<IP_или_домен_фронта>
|
||||
IS_DEFAULT_CODE_ENABLED=false
|
||||
|
||||
Базу и код вы зальёте скриптом deploy-ssh.sh с машины разработчика:
|
||||
первый запуск после деплоя: systemctl start craftshop-api (от root; иначе sudo)
|
||||
TXT
|
||||
chown "$CRAFTSHOP_USER:$CRAFTSHOP_USER" "$CRAFTSHOP_ROOT/server/README-PLACE-ENV.txt"
|
||||
|
||||
if [[ -n "${CRAFTSHOP_AUTHORIZED_KEY:-}" ]]; then
|
||||
uhome="$(getent passwd "$CRAFTSHOP_USER" | cut -d: -f6)"
|
||||
install -d -m 700 -o "$CRAFTSHOP_USER" -g "$CRAFTSHOP_USER" "$uhome/.ssh"
|
||||
afile="$uhome/.ssh/authorized_keys"
|
||||
if [[ ! -f "$afile" ]]; then
|
||||
install -m 600 -o "$CRAFTSHOP_USER" -g "$CRAFTSHOP_USER" /dev/null "$afile"
|
||||
fi
|
||||
if ! grep -qFx "$CRAFTSHOP_AUTHORIZED_KEY" "$afile" 2>/dev/null; then
|
||||
printf '%s\n' "$CRAFTSHOP_AUTHORIZED_KEY" >>"$afile"
|
||||
fi
|
||||
echo ">>> Добавлен SSH-ключ в $afile"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Готово Этап A."
|
||||
echo "- Каталог: $CRAFTSHOP_ROOT"
|
||||
echo "- Пользователь: $CRAFTSHOP_USER"
|
||||
echo "- Положите $CRAFTSHOP_ROOT/server/.env и выполните деплой кода (--backend-only), затем: systemctl start craftshop-api"
|
||||
Reference in New Issue
Block a user