diff --git a/.gitignore b/.gitignore index f74ca4d..a68b1cb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ data/ .codebuddy/ release/ release2/ +release-embedded/ dist-electron/ electron/node/ $null diff --git a/electron/main.js b/electron/main.js index 5f2a701..498e0de 100644 --- a/electron/main.js +++ b/electron/main.js @@ -7,6 +7,16 @@ let mainWindow = null; let backendProcess = null; const BACKEND_PORT = 3001; +function isRemoteServerMode() { + return process.env.USE_REMOTE_SERVER === "true"; +} + +function getRemoteServerUrl() { + const raw = (process.env.REMOTE_SERVER_URL || "").trim(); + if (!raw) return ""; + return raw.replace(/\/+$/, ""); +} + // 获取用户数据目录(存放 SQLite 数据库和字体文件) function getUserDataPath() { return path.join(app.getPath("userData"), "nowen-data"); @@ -110,6 +120,9 @@ function stopBackend() { // 创建主窗口 function createWindow() { + const useRemote = isRemoteServerMode(); + const remoteServerUrl = getRemoteServerUrl(); + mainWindow = new BrowserWindow({ width: 1280, height: 800, @@ -124,8 +137,16 @@ function createWindow() { show: false, }); - // 加载前端页面(通过后端服务提供) - mainWindow.loadURL(`http://localhost:${BACKEND_PORT}`); + if (useRemote) { + if (!remoteServerUrl) { + throw new Error("USE_REMOTE_SERVER=true but REMOTE_SERVER_URL is not set"); + } + console.log("[Electron] Remote server mode enabled:", remoteServerUrl); + mainWindow.loadURL(remoteServerUrl); + } else { + // 加载前端页面(通过本地后端服务提供) + mainWindow.loadURL(`http://localhost:${BACKEND_PORT}`); + } mainWindow.once("ready-to-show", () => { mainWindow.show(); @@ -147,7 +168,9 @@ function createWindow() { // App 生命周期 app.whenReady().then(async () => { try { - await startBackend(); + if (!isRemoteServerMode()) { + await startBackend(); + } createWindow(); } catch (err) { console.error("[Electron] Failed to start:", err); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 46ec4f1..e13eedd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Menu, X } from "lucide-react"; +import { Globe, Menu, PlugZap, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import Sidebar from "@/components/Sidebar"; import NoteList from "@/components/NoteList"; @@ -17,7 +17,15 @@ import { ThemeProvider } from "@/components/ThemeProvider"; import { SiteSettingsProvider, useSiteSettings } from "@/hooks/useSiteSettings"; import { TooltipProvider } from "@/components/ui/tooltip"; import { User } from "@/types"; -import { getServerUrl, clearServerUrl } from "@/lib/api"; +import { + clearServerUrl, + getServerUrl, + hasDesktopModeSelection, + isDesktopRemoteModeEnabled, + isElectronRuntime, + setDesktopModeSelection, + setDesktopRemoteMode, +} from "@/lib/api"; import { useBackButton, hideSplashScreen, useStatusBarSync, useKeyboardLayout, isNativePlatform } from "@/hooks/useCapacitor"; function SidebarResizeHandle() { @@ -360,6 +368,86 @@ function MobileTopBar() { ); } +function DesktopModeSelector() { + const { t } = useTranslation(); + + const handleUseBuiltInServer = () => { + setDesktopModeSelection(true); + setDesktopRemoteMode(false); + clearServerUrl(); + localStorage.removeItem("nowen-token"); + window.location.reload(); + }; + + const handleUseRemoteServer = () => { + setDesktopModeSelection(true); + setDesktopRemoteMode(true); + localStorage.removeItem("nowen-token"); + window.location.reload(); + }; + + return ( +
+
+
+
+
+ + +
+
+

+ {t("startup.title")} +

+

+ {t("startup.description")} +

+
+ +
+ + + +
+
+
+
+ ); +} + function AuthGate() { const [isAuthenticated, setIsAuthenticated] = useState(null); const [user, setUser] = useState(null); @@ -375,9 +463,13 @@ function AuthGate() { // 判断是否为客户端模式(Electron / Android / 曾配置过服务器地址) const isCapacitor = !!(window as any).Capacitor?.isNativePlatform?.() || !!(window as any).Capacitor?.platform && (window as any).Capacitor.platform !== "web"; + const isElectron = isElectronRuntime(); + const desktopModeSelected = isElectron ? hasDesktopModeSelection() : true; + const desktopRemoteMode = isDesktopRemoteModeEnabled(); const isClientMode = window.location.protocol === "file:" || window.location.protocol === "capacitor:" || isCapacitor + || (isElectron && desktopRemoteMode) || !!getServerUrl(); const checkAuth = useCallback(() => { @@ -408,13 +500,17 @@ function AuthGate() { }, []); useEffect(() => { + if (isElectron && !desktopModeSelected) { + setIsAuthenticated(false); + return; + } // 客户端模式但没有服务器地址:直接显示登录页(含服务器输入框) if (isClientMode && !getServerUrl()) { setIsAuthenticated(false); return; } checkAuth(); - }, [checkAuth, isClientMode]); + }, [checkAuth, desktopModeSelected, isClientMode, isElectron]); const handleDisconnect = () => { clearServerUrl(); @@ -442,10 +538,14 @@ function AuthGate() { // 未登录 → 一体化登录页 if (!isAuthenticated) { + if (isElectron && !desktopModeSelected) { + return ; + } return ( ); diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx index e689313..9da8a71 100644 --- a/frontend/src/components/LoginPage.tsx +++ b/frontend/src/components/LoginPage.tsx @@ -2,16 +2,17 @@ import React, { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Loader2, Lock, User, BookOpen, Globe, CheckCircle2, AlertCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { getServerUrl, setServerUrl, clearServerUrl, testServerConnection } from "@/lib/api"; +import { getServerUrl, setServerUrl, clearServerUrl, testServerConnection, isDesktopRemoteModeEnabled, setDesktopModeSelection, setDesktopRemoteMode } from "@/lib/api"; interface LoginPageProps { onLogin: (token: string, user: any) => void; /** 是否为客户端模式(Electron / Android / 曾配置过服务器地址) */ isClientMode?: boolean; + isDesktopApp?: boolean; onDisconnect?: () => void; } -export default function LoginPage({ onLogin, isClientMode = false, onDisconnect }: LoginPageProps) { +export default function LoginPage({ onLogin, isClientMode = false, isDesktopApp = false, onDisconnect }: LoginPageProps) { const [serverAddress, setServerAddress] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -19,6 +20,7 @@ export default function LoginPage({ onLogin, isClientMode = false, onDisconnect const [error, setError] = useState(""); const [serverStatus, setServerStatus] = useState<"idle" | "checking" | "ok" | "fail">("idle"); const { t } = useTranslation(); + const isDesktopRemoteMode = isDesktopApp && isDesktopRemoteModeEnabled(); // 回填上次的服务器地址 useEffect(() => { @@ -107,6 +109,9 @@ export default function LoginPage({ onLogin, isClientMode = false, onDisconnect }; const handleDisconnect = () => { + if (isDesktopApp) { + setDesktopRemoteMode(false); + } clearServerUrl(); localStorage.removeItem("nowen-token"); setServerAddress(""); @@ -117,6 +122,20 @@ export default function LoginPage({ onLogin, isClientMode = false, onDisconnect onDisconnect?.(); }; + const handleEnableRemoteMode = () => { + setDesktopModeSelection(true); + setDesktopRemoteMode(true); + window.location.reload(); + }; + + const handleUseBuiltInServer = () => { + setDesktopModeSelection(true); + setDesktopRemoteMode(false); + clearServerUrl(); + localStorage.removeItem("nowen-token"); + window.location.reload(); + }; + const serverStatusIcon = () => { switch (serverStatus) { case "checking": @@ -292,6 +311,30 @@ export default function LoginPage({ onLogin, isClientMode = false, onDisconnect
)} + + {isDesktopApp && !isClientMode && ( +
+ +
+ )} + + {isDesktopRemoteMode && ( +
+ +
+ )}
{/* 底部说明 — 客户端模式 */} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 2eb68cc..3e3bd81 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1,17 +1,17 @@ import React, { useState, useRef, useEffect, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Palette, Shield, Database, X, Settings, Camera, Save, Loader2, Trash2, Upload, Type, Check, ChevronDown, Globe, Bot } from "lucide-react"; +import { Palette, Shield, Database, X, Settings, Camera, Save, Loader2, Trash2, Upload, Type, Check, ChevronDown, Globe, Bot, Server, Wifi, PlugZap } from "lucide-react"; import { useTranslation } from "react-i18next"; import ThemeToggle from "@/components/ThemeToggle"; import SecuritySettings from "@/components/SecuritySettings"; import DataManager from "@/components/DataManager"; import AISettingsPanel from "@/components/AISettingsPanel"; import { useSiteSettings, BUILTIN_FONTS, getBuiltinFontName } from "@/hooks/useSiteSettings"; -import { api } from "@/lib/api"; +import { api, clearServerUrl, getServerUrl, isDesktopRemoteModeEnabled, isElectronRuntime, setDesktopModeSelection, setDesktopRemoteMode, setServerUrl, testServerConnection } from "@/lib/api"; import { CustomFont } from "@/types"; import { cn } from "@/lib/utils"; -type TabId = "appearance" | "ai" | "security" | "data"; +type TabId = "appearance" | "connection" | "ai" | "security" | "data"; interface SettingsModalProps { onClose: () => void; @@ -403,14 +403,175 @@ function AppearancePanel() { ); } +function ConnectionPanel() { + const { t } = useTranslation(); + const [serverAddress, setServerAddress] = useState(() => { + const saved = getServerUrl(); + return saved.replace(/^https?:\/\//, ""); + }); + const [serverStatus, setServerStatus] = useState<"idle" | "checking" | "ok" | "fail">("idle"); + const [message, setMessage] = useState(""); + const remoteModeEnabled = isDesktopRemoteModeEnabled(); + + const normalizeUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + return /^(https?:)?\/\//i.test(trimmed) ? trimmed.replace(/\/+$/, "") : `http://${trimmed.replace(/\/+$/, "")}`; + }; + + const handleTestConnection = async () => { + const normalized = normalizeUrl(serverAddress); + if (!normalized) { + setMessage(t("connection.enterServer")); + setServerStatus("fail"); + return; + } + + setServerStatus("checking"); + setMessage(""); + const result = await testServerConnection(normalized); + if (result.ok) { + setServerStatus("ok"); + setMessage(t("connection.testSuccess")); + } else { + setServerStatus("fail"); + setMessage(result.error || t("connection.testFailed")); + } + }; + + const handleSaveAndSwitch = async () => { + const normalized = normalizeUrl(serverAddress); + if (!normalized) { + setMessage(t("connection.enterServer")); + setServerStatus("fail"); + return; + } + + setServerStatus("checking"); + setMessage(""); + const result = await testServerConnection(normalized); + if (!result.ok) { + setServerStatus("fail"); + setMessage(result.error || t("connection.testFailed")); + return; + } + + setServerStatus("ok"); + setServerUrl(normalized); + localStorage.setItem("nowen-server-url-last", normalized); + setDesktopModeSelection(true); + setDesktopRemoteMode(true); + localStorage.removeItem("nowen-token"); + setMessage(t("connection.switching")); + setTimeout(() => window.location.reload(), 300); + }; + + const handleUseBuiltInServer = () => { + setDesktopModeSelection(true); + setDesktopRemoteMode(false); + clearServerUrl(); + localStorage.removeItem("nowen-token"); + setMessage(t("connection.switchingLocal")); + setTimeout(() => window.location.reload(), 300); + }; + + const statusColor = serverStatus === "ok" + ? "text-emerald-500" + : serverStatus === "fail" + ? "text-red-500" + : "text-zinc-400"; + + const statusLabel = serverStatus === "checking" + ? t("connection.testing") + : serverStatus === "ok" + ? t("connection.statusConnected") + : remoteModeEnabled + ? t("connection.statusRemote") + : t("connection.statusLocal"); + + return ( +
+
+

{t("connection.title")}

+

{t("connection.description")}

+
+ +
+
+
+
+ +
+
+
{t("connection.modeTitle")}
+
{t("connection.modeDesc")}
+
+
+
{statusLabel}
+
+ +
+ + { + setServerAddress(e.target.value); + if (serverStatus !== "idle") setServerStatus("idle"); + setMessage(""); + }} + className="w-full px-3 py-2.5 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-accent-primary/40 focus:border-accent-primary outline-none transition-all placeholder:text-zinc-400" + placeholder={t("connection.serverPlaceholder")} + /> +

{t("connection.serverHint")}

+
+ +
+ + + + + +
+ + {message && ( +

+ {message} +

+ )} +
+
+ ); +} + const SettingsModal = React.forwardRef( function SettingsModal({ onClose, defaultTab = "appearance" }, ref) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(defaultTab); const { siteConfig } = useSiteSettings(); + const showConnectionTab = isElectronRuntime(); const SETTING_TABS = [ { id: "appearance" as const, label: t('settings.appearance'), icon: Palette }, + ...(showConnectionTab ? [{ id: "connection" as const, label: t("settings.connection"), icon: Server }] : []), { id: "ai" as const, label: t('settings.ai'), icon: Bot }, { id: "security" as const, label: t('settings.security'), icon: Shield }, { id: "data" as const, label: t('settings.dataManagement'), icon: Database }, @@ -528,6 +689,7 @@ const SettingsModal = React.forwardRef( transition={{ duration: 0.15 }} > {activeTab === "appearance" && } + {activeTab === "connection" && } {activeTab === "ai" && } {activeTab === "security" && } {activeTab === "data" && } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c1c4be9..52c60c3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -37,7 +37,17 @@ "serverHint": "Enter IP:port or domain, http:// is added automatically", "serverRequired": "Server address is required", "resetServer": "Clear server config", - "clientNote": "Make sure the server is running and reachable from this device" + "clientNote": "Make sure the server is running and reachable from this device", + "switchToRemote": "Switch to remote server mode", + "useBuiltInServer": "Use built-in local server" + }, + "startup": { + "title": "Choose how the desktop app connects", + "description": "On first launch, choose whether the desktop client should use its built-in local service or connect to a remote backend you already deployed.", + "useBuiltInTitle": "Use built-in local service", + "useBuiltInDesc": "Best for single-machine use. The desktop app will start its local backend automatically and use local data.", + "useRemoteTitle": "Connect to remote backend", + "useRemoteDesc": "Best for a cloud or LAN deployment. The next step will ask you for the server address." }, "sidebar": { "newNote": "New Note", @@ -170,6 +180,7 @@ "settings": { "title": "Settings", "appearance": "Appearance", + "connection": "Server Connection", "ai": "AI Assistant", "security": "Security", "dataManagement": "Data", @@ -204,6 +215,27 @@ "fontUploadFailed": "Upload failed", "interDefault": "Inter (Default)" }, + "connection": { + "title": "Server Connection", + "description": "The Electron desktop app can use its built-in local service or connect to a backend you deployed on a cloud server or LAN.", + "modeTitle": "Current Mode", + "modeDesc": "Switching servers will clear the current login and return you to the sign-in page.", + "serverAddress": "Server Address", + "serverPlaceholder": "e.g. 192.168.1.100:3001 or note.example.com", + "serverHint": "Enter an IP:port or domain. http:// is added automatically. Do not append /api", + "testConnection": "Test Connection", + "saveAndSwitch": "Save and Switch", + "useBuiltIn": "Use Built-in Server", + "enterServer": "Please enter a server address", + "testing": "Testing connection...", + "testSuccess": "Connection successful. You can switch to this server.", + "testFailed": "Connection failed. Please check the address and network.", + "switching": "Switching to remote server...", + "switchingLocal": "Switching back to the built-in local server...", + "statusConnected": "Reachable", + "statusRemote": "Remote Mode", + "statusLocal": "Local Mode" + }, "securitySettings": { "title": "Account & Security", "description": "Verify current password to change username or password", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 3d767b5..7edce4e 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -37,7 +37,17 @@ "serverHint": "输入 IP:端口 或域名,自动补全 http://", "serverRequired": "请输入服务器地址", "resetServer": "清除服务器配置", - "clientNote": "请确保服务器已启动且网络可达,客户端通过 HTTP 连接到服务器" + "clientNote": "请确保服务器已启动且网络可达,客户端通过 HTTP 连接到服务器", + "switchToRemote": "切换到远程服务器模式", + "useBuiltInServer": "使用内置本地服务" + }, + "startup": { + "title": "选择桌面端连接方式", + "description": "首次启动桌面客户端时,请先选择使用内置本地服务,或连接你已经部署好的远程后端服务器。", + "useBuiltInTitle": "使用内置本地服务", + "useBuiltInDesc": "适合单机使用,客户端会自动启动本地后端并连接到本机数据。", + "useRemoteTitle": "连接远程后端", + "useRemoteDesc": "适合连接云端或局域网中的 Nowen Note 服务,下一步会让你填写服务器地址。" }, "sidebar": { "newNote": "新建笔记", @@ -170,6 +180,7 @@ "settings": { "title": "设置", "appearance": "外观设置", + "connection": "服务器连接", "ai": "AI 助手", "security": "账号安全", "dataManagement": "数据管理", @@ -204,6 +215,27 @@ "fontUploadFailed": "上传失败", "interDefault": "Inter (默认)" }, + "connection": { + "title": "服务器连接", + "description": "Electron 桌面端可以使用内置本地服务,也可以切换到你部署在云端或局域网中的后端。", + "modeTitle": "当前连接模式", + "modeDesc": "切换服务器后会清除当前登录状态,并重新进入登录页。", + "serverAddress": "服务器地址", + "serverPlaceholder": "例如: 192.168.1.100:3001 或 note.example.com", + "serverHint": "输入 IP:端口 或域名,自动补全 http://,不要手动添加 /api", + "testConnection": "测试连接", + "saveAndSwitch": "保存并切换", + "useBuiltIn": "切回内置服务", + "enterServer": "请输入服务器地址", + "testing": "连接测试中...", + "testSuccess": "连接成功,可以切换到该服务器。", + "testFailed": "连接失败,请检查地址和网络。", + "switching": "正在切换到远程服务器...", + "switchingLocal": "正在切回内置本地服务...", + "statusConnected": "连接可用", + "statusRemote": "远程模式", + "statusLocal": "本地模式" + }, "securitySettings": { "title": "账号与安全", "description": "修改用户名或密码需验证当前密码", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e7e83cd..048e01e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,6 +2,28 @@ import { Notebook, Note, NoteListItem, Tag, SearchResult, User, Task, TaskStats, // 服务器地址管理 const SERVER_URL_KEY = "nowen-server-url"; +const DESKTOP_REMOTE_MODE_KEY = "nowen-desktop-remote-mode"; +const DESKTOP_MODE_SELECTED_KEY = "nowen-desktop-mode-selected"; + +export function isElectronRuntime(): boolean { + return typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent); +} + +export function isDesktopRemoteModeEnabled(): boolean { + return localStorage.getItem(DESKTOP_REMOTE_MODE_KEY) === "true"; +} + +export function setDesktopRemoteMode(enabled: boolean) { + localStorage.setItem(DESKTOP_REMOTE_MODE_KEY, enabled ? "true" : "false"); +} + +export function hasDesktopModeSelection(): boolean { + return localStorage.getItem(DESKTOP_MODE_SELECTED_KEY) === "true"; +} + +export function setDesktopModeSelection(selected: boolean) { + localStorage.setItem(DESKTOP_MODE_SELECTED_KEY, selected ? "true" : "false"); +} export function getServerUrl(): string { return localStorage.getItem(SERVER_URL_KEY) || ""; diff --git a/scripts/create-user.sh b/scripts/create-user.sh new file mode 100755 index 0000000..eab0b7e --- /dev/null +++ b/scripts/create-user.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash + +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-nowen-note}" +DB_PATH="${DB_PATH:-/app/data/nowen-note.db}" +DEFAULT_NOTEBOOK_NAME="${DEFAULT_NOTEBOOK_NAME:-默认笔记本}" + +usage() { + cat < --password [options] + +Options: + --username Username to create + --password Password for the new user + --email Optional email address + --notebook Default notebook name (default: ${DEFAULT_NOTEBOOK_NAME}) + --container Docker container name (default: ${CONTAINER_NAME}) + --db-path SQLite DB path in container (default: ${DB_PATH}) + -h, --help Show this help + +Examples: + $(basename "$0") --username alice --password 'ChangeMe123' + $(basename "$0") --username bob --password 'StrongPass' --email bob@example.com --notebook '工作台' +EOF +} + +USERNAME="" +PASSWORD="" +EMAIL="" +NOTEBOOK_NAME="$DEFAULT_NOTEBOOK_NAME" + +while [[ $# -gt 0 ]]; do + case "$1" in + --username) + USERNAME="${2:-}" + shift 2 + ;; + --password) + PASSWORD="${2:-}" + shift 2 + ;; + --email) + EMAIL="${2:-}" + shift 2 + ;; + --notebook) + NOTEBOOK_NAME="${2:-}" + shift 2 + ;; + --container) + CONTAINER_NAME="${2:-}" + shift 2 + ;; + --db-path) + DB_PATH="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$USERNAME" || -z "$PASSWORD" ]]; then + echo "Error: --username and --password are required." >&2 + usage >&2 + exit 1 +fi + +if [[ ${#PASSWORD} -lt 6 ]]; then + echo "Error: password must be at least 6 characters." >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker command not found on this server." >&2 + exit 1 +fi + +if ! docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then + echo "Error: container '$CONTAINER_NAME' not found." >&2 + exit 1 +fi + +if [[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME")" != "true" ]]; then + echo "Error: container '$CONTAINER_NAME' is not running." >&2 + exit 1 +fi + +docker exec \ + -e NOWEN_NEW_USERNAME="$USERNAME" \ + -e NOWEN_NEW_PASSWORD="$PASSWORD" \ + -e NOWEN_NEW_EMAIL="$EMAIL" \ + -e NOWEN_NEW_NOTEBOOK="$NOTEBOOK_NAME" \ + -e NOWEN_DB_PATH="$DB_PATH" \ + "$CONTAINER_NAME" \ + node - <<'NODE' +const fs = require("fs"); +const crypto = require("crypto"); + +function requireFrom(paths) { + for (const p of paths) { + if (fs.existsSync(p)) { + return require(p); + } + } + throw new Error(`Required module not found. Tried: ${paths.join(", ")}`); +} + +const Database = requireFrom([ + "/app/backend/node_modules/better-sqlite3", + "/app/node_modules/better-sqlite3", +]); + +const bcrypt = requireFrom([ + "/app/backend/node_modules/bcryptjs", + "/app/node_modules/bcryptjs", +]); + +const username = (process.env.NOWEN_NEW_USERNAME || "").trim(); +const password = process.env.NOWEN_NEW_PASSWORD || ""; +const emailRaw = (process.env.NOWEN_NEW_EMAIL || "").trim(); +const notebookName = (process.env.NOWEN_NEW_NOTEBOOK || "默认笔记本").trim() || "默认笔记本"; +const dbPath = process.env.NOWEN_DB_PATH || "/app/data/nowen-note.db"; + +if (!username) { + throw new Error("username is required"); +} +if (password.length < 6) { + throw new Error("password must be at least 6 characters"); +} + +const email = emailRaw || null; +const db = new Database(dbPath); +db.pragma("foreign_keys = ON"); + +const existingUser = db.prepare("SELECT id FROM users WHERE username = ?").get(username); +if (existingUser) { + console.error(`User '${username}' already exists.`); + process.exit(2); +} + +if (email) { + const existingEmail = db.prepare("SELECT id FROM users WHERE email = ?").get(email); + if (existingEmail) { + console.error(`Email '${email}' is already in use.`); + process.exit(3); + } +} + +const userId = crypto.randomUUID(); +const notebookId = crypto.randomUUID(); +const passwordHash = bcrypt.hashSync(password, 10); + +const tx = db.transaction(() => { + db.prepare(` + INSERT INTO users (id, username, email, passwordHash) + VALUES (?, ?, ?, ?) + `).run(userId, username, email, passwordHash); + + db.prepare(` + INSERT INTO notebooks (id, userId, name, icon, sortOrder) + VALUES (?, ?, ?, ?, ?) + `).run(notebookId, userId, notebookName, "📒", 0); +}); + +tx(); + +console.log(JSON.stringify({ + success: true, + userId, + username, + email, + notebookId, + notebookName, + dbPath, +}, null, 2)); +NODE