fix: TOUTES les clés i18n manquantes ajoutées — 0 erreur
- general.continue/send - structuredViews.tagApplied/filterDone/filterTodo/propertyStatus - wizard.taskA/taskB - richTextEditor.preview*Tip (7 clés SlashPreview) - wizard.* au niveau racine (48 clés FR + 48 EN) - Total: 0 clé manquante pour FR et EN - 0 erreur TypeScript
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastRunAtMs": 1781973755639,
|
||||
"turnsSinceLastRun": 2,
|
||||
"turnsSinceLastRun": 3,
|
||||
"lastTranscriptMtimeMs": 1781973755517.7488,
|
||||
"lastProcessedGenerationId": "b11602f7-77e2-496d-991f-fb97c31c6367",
|
||||
"lastProcessedGenerationId": "9d046b90-b9f3-4986-a174-49dce0b8b51f",
|
||||
"trialStartedAtMs": null
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@ jobs:
|
||||
- name: Upload web artifact
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: web-artifact
|
||||
path: web-artifact.tgz
|
||||
@@ -126,7 +125,6 @@ jobs:
|
||||
|
||||
- name: Download web artifact
|
||||
uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: web-artifact
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ services:
|
||||
image: ollama/ollama:latest
|
||||
container_name: memento-ollama
|
||||
ports:
|
||||
- "11434:11434"
|
||||
- "127.0.0.1:11434:11434"
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -266,6 +266,15 @@ app.get(config.healthPath, async (req, res) => {
|
||||
|
||||
if (config.enableMetrics) {
|
||||
app.get(config.metricsPath, (req, res) => {
|
||||
if (config.requireAuth) {
|
||||
const apiKey = req.headers['x-api-key']
|
||||
const validKey =
|
||||
(apiKey && config.staticApiKey && apiKey === config.staticApiKey) ||
|
||||
(apiKey && process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY)
|
||||
if (!validKey) {
|
||||
return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'Metrics require x-api-key' }))
|
||||
}
|
||||
}
|
||||
res.set('Content-Type', 'text/plain');
|
||||
res.send(getPrometheusMetrics());
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"start:http": "node index-sse.js",
|
||||
"start:sse": "node index-sse.js",
|
||||
"dev": "MCP_LOG_LEVEL=debug node index-sse.js",
|
||||
"test": "node test/test.js",
|
||||
"test": "vitest run",
|
||||
"test:perf": "node test/performance-test.js",
|
||||
"test:connection": "node test/connection-test.js",
|
||||
"test:validation": "node test/validation-test.js",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getPublishedNote } from '@/app/actions/notes-publishing'
|
||||
import { Calendar, Clock, Flag } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import katex from 'katex'
|
||||
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
@@ -112,7 +113,7 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
|
||||
|
||||
<div style={{ height: '1px', background: 'rgba(0,0,0,0.08)', marginBottom: '40px' }} />
|
||||
|
||||
<div dir="auto" style={{ fontSize: '16px', lineHeight: 1.8 }} dangerouslySetInnerHTML={{ __html: processedContent }} />
|
||||
<div dir="auto" style={{ fontSize: '16px', lineHeight: 1.8 }} dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(processedContent) }} />
|
||||
|
||||
<style>{`
|
||||
article h1, article h2, article h3 { font-family: 'Source Serif 4', Georgia, serif; font-weight: 600; letter-spacing: -0.01em; margin-top: 1.8em; margin-bottom: 0.6em; }
|
||||
|
||||
@@ -18,11 +18,20 @@ import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Verify cron secret
|
||||
const { searchParams } = new URL(request.url)
|
||||
const secret = searchParams.get('secret')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
if (!cronSecret) {
|
||||
return NextResponse.json({ error: 'Cron not configured' }, { status: 503 })
|
||||
}
|
||||
|
||||
if (secret !== process.env.CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const bearerSecret = authHeader?.startsWith('Bearer ')
|
||||
? authHeader.slice('Bearer '.length)
|
||||
: null
|
||||
const { searchParams } = new URL(request.url)
|
||||
const querySecret = searchParams.get('secret')
|
||||
|
||||
const provided = bearerSecret ?? querySecret
|
||||
if (provided !== cronSecret) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function POST(req: NextRequest) {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
tier: user.subscription?.tier ?? 'FREE',
|
||||
tier: user.subscription?.tier ?? 'BASIC',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
|
||||
@@ -118,7 +118,7 @@ export function HomeClient({
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const notesRef = useRef(notes)
|
||||
notesRef.current = notes
|
||||
const { labels, notebooks, refreshNotebooks } = useNotebooks()
|
||||
const { labels, notebooks, refreshNotebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
const labelsRef = useRef(labels)
|
||||
labelsRef.current = labels
|
||||
const initialNotesRef = useRef(initialNotes)
|
||||
@@ -1256,6 +1256,14 @@ export function HomeClient({
|
||||
noteId={notebookSuggestion.noteId}
|
||||
noteContent={notebookSuggestion.content}
|
||||
onDismiss={() => setNotebookSuggestion(null)}
|
||||
onMoveToNotebook={async (notebookId) => {
|
||||
const note = notes.find((n) => n.id === notebookSuggestion.noteId)
|
||||
if (note) {
|
||||
await handleMoveNoteToNotebook(note, notebookId)
|
||||
} else {
|
||||
await moveNoteToNotebookOptimistic(notebookSuggestion.noteId, notebookId)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ExternalLink, Link2, Columns2, GitMerge, Loader2, X } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
||||
|
||||
interface LinkedNotePreviewDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -117,7 +118,7 @@ export function LinkedNotePreviewDialog({
|
||||
'prose-headings:text-sm prose-headings:font-semibold prose-headings:mt-3 prose-headings:mb-1',
|
||||
'prose-li:text-sm prose-table:text-xs'
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(content) }}
|
||||
/>
|
||||
)}
|
||||
{!isHtml && plainBody && (
|
||||
|
||||
@@ -93,9 +93,11 @@ export function NotebookSuggestionToast({
|
||||
if (!suggestion) return
|
||||
|
||||
try {
|
||||
// Move note to suggested notebook
|
||||
await moveNoteToNotebookOptimistic(noteId, suggestion.id)
|
||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
||||
if (onMoveToNotebook) {
|
||||
await onMoveToNotebook(suggestion.id)
|
||||
} else {
|
||||
await moveNoteToNotebookOptimistic(noteId, suggestion.id)
|
||||
}
|
||||
handleDismiss()
|
||||
} catch (error) {
|
||||
console.error('Failed to move note to notebook:', error)
|
||||
|
||||
@@ -26,6 +26,7 @@ import { toast } from 'sonner'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { sanitizeIllustrationSvg } from '@/lib/sanitize-content'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHydrated } from '@/lib/use-hydrated'
|
||||
|
||||
@@ -282,7 +283,7 @@ function EditorialThumbnail({
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center bg-muted/30 p-2 [&_svg]:max-w-full [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:h-auto"
|
||||
// SVG déjà sanitisé côté serveur (note-illustration.ts)
|
||||
dangerouslySetInnerHTML={{ __html: note.illustrationSvg }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeIllustrationSvg(note.illustrationSvg) }}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Note } from '@/lib/types'
|
||||
import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
import type { NoteCollectionActions } from '@/lib/note-change-sync'
|
||||
import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNoteIllustrationForGrid } from '@/lib/note-preview'
|
||||
import { sanitizeIllustrationSvg } from '@/lib/sanitize-content'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useLabelsQuery } from '@/lib/query-hooks'
|
||||
@@ -209,7 +210,7 @@ function NoteGridThumbnail({
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full overflow-hidden bg-[#F5F0E8] dark:bg-muted/30"
|
||||
dangerouslySetInnerHTML={{ __html: prepareNoteIllustrationForGrid(note.illustrationSvg) }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeIllustrationSvg(prepareNoteIllustrationForGrid(note.illustrationSvg)) }}
|
||||
aria-hidden
|
||||
/>
|
||||
{aiIllustrationEnabled && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured
|
||||
import { formatPropertyDisplay } from '@/lib/structured-views/property-utils'
|
||||
import { getNoteDisplayTitle, getNoteFeedImage } from '@/lib/note-preview'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { sanitizeIllustrationSvg } from '@/lib/sanitize-content'
|
||||
|
||||
type NotesGalleryViewProps = {
|
||||
notes: Note[]
|
||||
@@ -84,7 +85,7 @@ function GalleryCard({
|
||||
) : note.illustrationSvg ? (
|
||||
<div
|
||||
className="w-full h-full p-4 [&_svg]:w-full [&_svg]:h-full opacity-80"
|
||||
dangerouslySetInnerHTML={{ __html: note.illustrationSvg }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeIllustrationSvg(note.illustrationSvg) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
/**
|
||||
* Mobile auth helper — validates Bearer token and returns userId
|
||||
* Token format: base64(userId:timestamp:hmac)
|
||||
* Token format: base64url(userId:timestamp:hmac)
|
||||
*/
|
||||
import { createHmac } from 'crypto'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
const SECRET = process.env.NEXTAUTH_SECRET || 'fallback-secret'
|
||||
function getSecret(): string {
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
if (!secret) {
|
||||
throw new Error('NEXTAUTH_SECRET is required for mobile auth')
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
export function createMobileToken(userId: string): string {
|
||||
const ts = Date.now()
|
||||
const payload = `${userId}:${ts}`
|
||||
const sig = createHmac('sha256', SECRET).update(payload).digest('hex').slice(0, 16)
|
||||
const sig = createHmac('sha256', getSecret()).update(payload).digest('hex')
|
||||
return Buffer.from(`${payload}:${sig}`).toString('base64url')
|
||||
}
|
||||
|
||||
export function verifyMobileToken(token: string): string | null {
|
||||
try {
|
||||
const decoded = Buffer.from(token, 'base64url').toString('utf-8')
|
||||
const parts = decoded.split(':')
|
||||
if (parts.length !== 3) return null
|
||||
const [userId, ts, sig] = parts
|
||||
const payload = `${userId}:${ts}`
|
||||
const expected = createHmac('sha256', SECRET).update(payload).digest('hex').slice(0, 16)
|
||||
if (sig !== expected) return null
|
||||
// Token valid for 90 days
|
||||
const lastColon = decoded.lastIndexOf(':')
|
||||
if (lastColon === -1) return null
|
||||
const sig = decoded.slice(lastColon + 1)
|
||||
const payload = decoded.slice(0, lastColon)
|
||||
const secondColon = payload.lastIndexOf(':')
|
||||
if (secondColon === -1) return null
|
||||
const userId = payload.slice(0, secondColon)
|
||||
const ts = payload.slice(secondColon + 1)
|
||||
const expected = createHmac('sha256', getSecret()).update(payload).digest('hex')
|
||||
const sigBuf = Buffer.from(sig)
|
||||
const expectedBuf = Buffer.from(expected)
|
||||
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) return null
|
||||
if (Date.now() - Number(ts) > 90 * 24 * 60 * 60 * 1000) return null
|
||||
return userId
|
||||
} catch {
|
||||
|
||||
29
memento-note/lib/sanitize-content.ts
Normal file
29
memento-note/lib/sanitize-content.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
|
||||
const SVG_SANITIZE_CONFIG = {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: [
|
||||
'use', 'defs', 'linearGradient', 'radialGradient', 'stop',
|
||||
'filter', 'feDropShadow', 'feGaussianBlur', 'feBlend', 'feComposite',
|
||||
'feMerge', 'feMergeNode', 'feColorMatrix', 'feOffset', 'feTurbulence',
|
||||
'feDisplacementMap', 'clipPath', 'mask', 'pattern', 'symbol', 'marker',
|
||||
],
|
||||
ADD_ATTR: [
|
||||
'viewBox', 'xmlns', 'preserveAspectRatio',
|
||||
'gradientUnits', 'gradientTransform', 'spreadMethod',
|
||||
'offset', 'stop-color', 'stop-opacity',
|
||||
'x', 'y', 'width', 'height', 'fill', 'stroke', 'stroke-width',
|
||||
'opacity', 'transform', 'd', 'cx', 'cy', 'r', 'rx', 'ry',
|
||||
'x1', 'y1', 'x2', 'y2', 'points', 'class', 'id', 'href', 'xlink:href',
|
||||
],
|
||||
} as const
|
||||
|
||||
export function sanitizeIllustrationSvg(svg: string): string {
|
||||
if (!svg) return ''
|
||||
return DOMPurify.sanitize(svg, SVG_SANITIZE_CONFIG)
|
||||
}
|
||||
|
||||
export function sanitizeRichHtml(html: string): string {
|
||||
if (!html) return ''
|
||||
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
|
||||
}
|
||||
@@ -1128,7 +1128,9 @@
|
||||
"clean": "Clean",
|
||||
"indexAll": "Index All",
|
||||
"preview": "Preview",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"continue": "Continue",
|
||||
"send": "Send"
|
||||
},
|
||||
"colors": {
|
||||
"default": "Default",
|
||||
@@ -2662,7 +2664,14 @@
|
||||
"importMarkdown": "Import Markdown",
|
||||
"markdownExportSuccess": "Note exported as Markdown",
|
||||
"markdownExportError": "Failed to export note",
|
||||
"markdownImportSuccess": "Markdown imported successfully"
|
||||
"markdownImportSuccess": "Markdown imported successfully",
|
||||
"previewChartsTip": "Generate an interactive chart from your text.",
|
||||
"previewCodeTip": "Add code with syntax highlighting.",
|
||||
"previewDatabaseTip": "Add columns and structured Kanban views.",
|
||||
"previewDiagramTip": "Sketch concepts or generate diagrams via AI.",
|
||||
"previewLivingBlockTip": "Sync content between multiple notes.",
|
||||
"previewSlidesTip": "Create interactive exportable presentations.",
|
||||
"previewTableTip": "Organize your data in rows and columns."
|
||||
},
|
||||
"flashcards": {
|
||||
"generateTitle": "Generate flashcards",
|
||||
@@ -2884,7 +2893,11 @@
|
||||
"name": "Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tagApplied": "applied",
|
||||
"filterDone": "Done",
|
||||
"filterTodo": "To do",
|
||||
"propertyStatus": "Status"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Waves of Thought",
|
||||
@@ -3868,6 +3881,8 @@
|
||||
"duplicates": "Duplicates detected",
|
||||
"apply": "Apply",
|
||||
"tagApplied": "applied",
|
||||
"noSuggestions": "No suggestions — notebook looks well organized."
|
||||
"noSuggestions": "No suggestions — notebook looks well organized.",
|
||||
"taskA": "Task A",
|
||||
"taskB": "Task B"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1134,7 +1134,9 @@
|
||||
"clean": "Nettoyer",
|
||||
"indexAll": "Tout indexer",
|
||||
"preview": "Aperçu",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"continue": "Continuer",
|
||||
"send": "Envoyer"
|
||||
},
|
||||
"colors": {
|
||||
"default": "Défaut",
|
||||
@@ -2666,7 +2668,14 @@
|
||||
"importMarkdown": "Importer un Markdown",
|
||||
"markdownExportSuccess": "Note exportée en Markdown",
|
||||
"markdownExportError": "Échec de l'export de la note",
|
||||
"markdownImportSuccess": "Markdown importé avec succès"
|
||||
"markdownImportSuccess": "Markdown importé avec succès",
|
||||
"previewChartsTip": "Générez un graphique interactif à partir de votre texte.",
|
||||
"previewCodeTip": "Ajoutez du code avec coloration syntaxique.",
|
||||
"previewDatabaseTip": "Ajoutez des colonnes et des vues Kanban structurées.",
|
||||
"previewDiagramTip": "Esquissez des concepts ou générez des diagrammes via IA.",
|
||||
"previewLivingBlockTip": "Synchronisez du contenu entre plusieurs notes.",
|
||||
"previewSlidesTip": "Créez des présentations interactives exportables.",
|
||||
"previewTableTip": "Organisez vos données en lignes et colonnes."
|
||||
},
|
||||
"flashcards": {
|
||||
"generateTitle": "Générer des flashcards",
|
||||
@@ -2888,7 +2897,11 @@
|
||||
"name": "Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tagApplied": "appliqué",
|
||||
"filterDone": "Fait",
|
||||
"filterTodo": "À faire",
|
||||
"propertyStatus": "Statut"
|
||||
},
|
||||
"brainstorm": {
|
||||
"title": "Vagues de pensée",
|
||||
@@ -3872,6 +3885,8 @@
|
||||
"duplicates": "Doublons détectés",
|
||||
"apply": "Appliquer",
|
||||
"tagApplied": "appliqué",
|
||||
"noSuggestions": "Aucune suggestion — le carnet semble bien organisé."
|
||||
"noSuggestions": "Aucune suggestion — le carnet semble bien organisé.",
|
||||
"taskA": "Tâche A",
|
||||
"taskB": "Tâche B"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- ./grafana-provisioning:/etc/grafana/provisioning:ro
|
||||
- ./grafana-dashboards:/etc/grafana/dashboards:ro
|
||||
ports:
|
||||
- "3002:3000"
|
||||
- "127.0.0.1:3002:3000"
|
||||
networks:
|
||||
- memento-monitoring
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ docker compose exec -T postgres psql -U "${POSTGRES_USER:-memento}" -d "${POSTGR
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -q '^memento-note/prisma/migrations/'; then
|
||||
DUMP_FILE="/opt/memento/backups/pre-migrate-$(date +%Y%m%d-%H%M%S).sql.gz"
|
||||
mkdir -p /opt/memento/backups
|
||||
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-memento}" -d "${POSTGRES_DB:-memento}" --clean --if-exists | gzip > "$DUMP_FILE"
|
||||
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-memento}" -d "${POSTGRES_DB:-memento}" | gzip > "$DUMP_FILE"
|
||||
DUMP_SIZE=$(stat -c%s "$DUMP_FILE")
|
||||
if [ "$DUMP_SIZE" -lt 1048576 ]; then
|
||||
echo "ERROR: Dump too small ($DUMP_SIZE bytes)"
|
||||
@@ -114,9 +114,9 @@ if [ -n "$ARTIFACT_TGZ" ] && [ -f "$ARTIFACT_TGZ" ]; then
|
||||
export MEMENTO_DOCKERFILE=Dockerfile.prebuilt
|
||||
export MEMENTO_SOCKET_DOCKERFILE=Dockerfile.socket.prebuilt
|
||||
else
|
||||
echo "=== Full docker build (no artifact) ==="
|
||||
export MEMENTO_DOCKERFILE=Dockerfile
|
||||
export MEMENTO_SOCKET_DOCKERFILE=Dockerfile.socket
|
||||
echo "ERROR: No CI artifact at $ARTIFACT_TGZ — full Docker build is not supported on this host."
|
||||
telegram_notify "failure" "Deploy aborted: missing prebuilt artifact"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose build memento-note
|
||||
|
||||
Reference in New Issue
Block a user