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