@dtdyq/restbase 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ bun install -g @dtdyq/restbase
24
24
  安装后在任意目录使用 `restbase` 命令。所有配置通过 `.env` 文件读取:
25
25
 
26
26
  ```bash
27
- # 生成 .env 配置模板
27
+ # 交互式配置 .env(已存在则用当前值作为默认)
28
28
  restbase env
29
29
 
30
30
  # 前台启动(读取当前目录 .env)
@@ -36,11 +36,11 @@ restbase start
36
36
  # 查看运行中的实例(含健康状态)
37
37
  restbase status
38
38
 
39
- # 查看某个实例的实时日志
40
- restbase log <pid>
39
+ # 查看某个实例的实时日志(支持 PID 或 SVR_NAME)
40
+ restbase log <pid|name>
41
41
 
42
- # 停止
43
- restbase stop <pid> # 停止指定实例
42
+ # 停止(支持 PID、SVR_NAME 或 all)
43
+ restbase stop <pid|name> # 停止指定实例
44
44
  restbase stop all # 停止所有实例
45
45
  ```
46
46
 
@@ -60,9 +60,9 @@ bun run src/server.ts
60
60
  ### 验证
61
61
 
62
62
  ```bash
63
- # 健康检查(返回实例状态 + 资源占用)
63
+ # 健康检查(返回实例状态 + 资源占用 + cwd + logFile)
64
64
  curl http://localhost:3333/api/health
65
- # → { "code": "OK", "data": { "status": "healthy", "port": 3333, "pid": 12345, "uptime": 60, "memory": {...}, "cpu": {...} } }
65
+ # → { "code": "OK", "data": { "status": "healthy", "port": 3333, "pid": 12345, "cwd": "/path/to/dir", "uptime": 60, "memory": {...}, "cpu": {...} } }
66
66
 
67
67
  # Basic Auth 查询
68
68
  curl -u admin:admin http://localhost:3333/api/data/products
@@ -82,6 +82,10 @@ import RestBase, { gt } from "@dtdyq/restbase-client";
82
82
  const rb = new RestBase(); // 同源部署不传参
83
83
  await rb.auth.login("admin", "admin");
84
84
  const data = await rb.table("products").query().where(gt("price", 100)).data();
85
+
86
+ // 多节点负载均衡(所有节点连同一 DB)
87
+ const rb2 = new RestBase(["http://node1:3333", "http://node2:3333"]);
88
+ await rb2.auth.login("admin", "admin"); // 一次登录,token 共享
85
89
  ```
86
90
 
87
91
  详见 [client/README.md](client/README.md)。
@@ -139,28 +143,18 @@ restbase <command> [arguments]
139
143
  Commands:
140
144
  run 前台启动服务(读取当前目录 .env)
141
145
  start 后台启动(daemon 模式)
142
- stop <pid|all> 停止后台实例
143
- status 查看运行中的实例(含健康检查)
144
- log <pid> 实时查看实例日志
145
- env 在当前目录生成 .env 配置模板
146
+ stop <pid|name|all> 停止后台实例(支持 PID、SVR_NAME 或 all)
147
+ status 查看运行中的实例(含健康检查)
148
+ log <pid|name> 实时查看实例日志(支持 PID 或 SVR_NAME)
149
+ env 交互式 .env 配置(创建或重新配置)
146
150
  version 显示版本号
147
151
  help 显示帮助
148
152
  ```
149
153
 
150
154
  所有配置通过 `.env` 文件管理,可在 `.env` 中设置 `SVR_NAME` 为实例命名。
151
155
 
152
- 实例元数据和默认日志存放在 `~/.restbase/`:
153
-
154
- ```
155
- ~/.restbase/
156
- ├── instances/ # 实例元数据(name/port/pid/logPath)
157
- │ ├── 12345.json
158
- │ └── 23456.json
159
- └── logs/ # daemon 默认日志目录
160
- ├── 3333.log
161
- ├── 3333.out
162
- └── 8080.log
163
- ```
156
+ 实例管理完全无状态:通过 `ps` 自动发现运行中的实例,通过 `/api/health` 获取实例详情。
157
+ daemon 模式默认日志存放在 `~/.restbase/logs/`。
164
158
 
165
159
  ## 文件结构
166
160
 
package/bin/restbase.ts CHANGED
@@ -1,91 +1,73 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * bin/restbase.ts — CLI entry point
3
+ * bin/restbase.ts — CLI entry point (stateless management)
4
4
  *
5
5
  * Commands:
6
6
  * restbase run Start server in foreground (reads .env)
7
7
  * restbase start Start server in background (daemon)
8
- * restbase stop <pid|all> Stop instance(s)
9
- * restbase status Show running instances (table + health check)
10
- * restbase log <pid> Tail log of a background instance
8
+ * restbase stop <pid|name|all> Stop instance(s) by PID, name or all
9
+ * restbase status Show running instances (table + health check)
10
+ * restbase log <pid|name> Tail log of a background instance
11
11
  * restbase env Generate .env template in current directory
12
12
  * restbase version Show version
13
13
  * restbase help Show help
14
14
  *
15
15
  * All configuration is read from .env in the current working directory.
16
- * Instance metadata is stored in ~/.restbase/ for global access.
16
+ *
17
+ * Instance discovery is stateless:
18
+ * - `restbase start` spawns the child with a `--port=N` marker arg
19
+ * - Management commands use `ps` to find processes matching `restbase.*--port=`
20
+ * - Detailed info (name, logFile, cwd, uptime…) is fetched via /api/health
17
21
  */
18
22
 
19
- import {spawn} from "child_process";
23
+ import {spawn, execSync} from "child_process";
20
24
  import {
21
25
  existsSync,
22
26
  mkdirSync,
23
- readFileSync,
24
- unlinkSync,
25
- readdirSync,
26
27
  openSync,
27
28
  writeFileSync,
28
29
  } from "fs";
29
30
  import {resolve, join} from "path";
31
+ import {readFileSync} from "fs";
30
32
  import {homedir} from "os";
33
+ import * as p from "@clack/prompts";
31
34
 
32
35
  /* ═══════════ Global paths ═══════════ */
33
36
 
34
37
  const RESTBASE_HOME = resolve(homedir(), ".restbase");
35
- const INSTANCES_DIR = join(RESTBASE_HOME, "instances");
36
38
  const LOGS_DIR = join(RESTBASE_HOME, "logs");
37
39
 
38
- function ensureDirs() {
39
- mkdirSync(INSTANCES_DIR, {recursive: true});
40
+ function ensureLogDir() {
40
41
  mkdirSync(LOGS_DIR, {recursive: true});
41
42
  }
42
43
 
43
- /* ═══════════ Instance metadata ═══════════ */
44
+ /* ═══════════ Process discovery via ps ═══════════ */
44
45
 
45
- interface InstanceMeta {
46
+ interface PsInstance {
46
47
  pid: number;
47
- name: string;
48
48
  port: number;
49
- logPath: string;
50
- cwd: string;
51
- startedAt: string;
52
- }
53
-
54
- function instanceFile(pid: number) {
55
- return join(INSTANCES_DIR, `${pid}.json`);
56
- }
57
-
58
- function saveInstance(meta: InstanceMeta) {
59
- writeFileSync(instanceFile(meta.pid), JSON.stringify(meta, null, 2));
60
49
  }
61
50
 
62
- function loadInstance(pid: number): InstanceMeta | null {
63
- const f = instanceFile(pid);
64
- if (!existsSync(f)) return null;
51
+ /**
52
+ * Scan running processes for `restbase.*--port=N` and extract PID + port.
53
+ * Works on macOS and Linux.
54
+ */
55
+ function discoverInstances(): PsInstance[] {
65
56
  try {
66
- return JSON.parse(readFileSync(f, "utf-8")) as InstanceMeta;
57
+ const out = execSync("ps -eo pid,args", {encoding: "utf-8"});
58
+ const results: PsInstance[] = [];
59
+ for (const line of out.split("\n")) {
60
+ const match = line.match(/^\s*(\d+)\s+.*restbase.*--port=(\d+)/);
61
+ if (match) {
62
+ results.push({pid: Number(match[1]), port: Number(match[2])});
63
+ }
64
+ }
65
+ return results;
67
66
  } catch {
68
- return null;
67
+ return [];
69
68
  }
70
69
  }
71
70
 
72
- function loadAllInstances(): InstanceMeta[] {
73
- ensureDirs();
74
- const files = readdirSync(INSTANCES_DIR).filter((f) => f.endsWith(".json"));
75
- const result: InstanceMeta[] = [];
76
- for (const f of files) {
77
- try {
78
- result.push(JSON.parse(readFileSync(join(INSTANCES_DIR, f), "utf-8")));
79
- } catch { /* corrupted, skip */ }
80
- }
81
- return result;
82
- }
83
-
84
- function removeInstance(pid: number) {
85
- const f = instanceFile(pid);
86
- if (existsSync(f)) unlinkSync(f);
87
- }
88
-
89
71
  /* ═══════════ Process helpers ═══════════ */
90
72
 
91
73
  function isAlive(pid: number): boolean {
@@ -102,6 +84,7 @@ interface HealthInfo {
102
84
  name?: string;
103
85
  port?: number;
104
86
  pid?: number;
87
+ cwd?: string;
105
88
  logFile?: string;
106
89
  startedAt?: string;
107
90
  uptime?: number;
@@ -141,31 +124,25 @@ async function cmdRun() {
141
124
 
142
125
  /** start — daemon mode */
143
126
  async function cmdStart() {
144
- ensureDirs();
127
+ ensureLogDir();
145
128
 
146
129
  const port = Number(process.env.SVR_PORT) || 3333;
147
- const name = process.env.SVR_NAME || "";
148
130
 
149
131
  // Reject if this port is already running
150
- for (const inst of loadAllInstances()) {
151
- if (inst.port === port && isAlive(inst.pid)) {
132
+ for (const inst of discoverInstances()) {
133
+ if (inst.port === port) {
152
134
  console.log(`RestBase already running on port ${port} (PID: ${inst.pid})`);
153
135
  return;
154
136
  }
155
137
  }
156
138
 
157
- // Determine log path
158
- const logPath = process.env.LOG_FILE
159
- ? resolve(process.cwd(), process.env.LOG_FILE)
160
- : join(LOGS_DIR, `${port}.log`);
161
-
162
- // Build child env: silence console, ensure LOG_FILE
139
+ // Build child env: silence console, ensure LOG_FILE for daemon
163
140
  const env: Record<string, string> = {
164
141
  ...(process.env as Record<string, string>),
165
142
  LOG_CONSOLE: "false",
166
143
  };
167
144
  if (!process.env.LOG_FILE) {
168
- env.LOG_FILE = logPath;
145
+ env.LOG_FILE = join(LOGS_DIR, `${port}.log`);
169
146
  }
170
147
 
171
148
  // stdout/stderr → separate crash log (safety net for non-pino output)
@@ -174,7 +151,7 @@ async function cmdStart() {
174
151
  const err = openSync(stdoutPath, "a");
175
152
 
176
153
  const self = getSelfCommand();
177
- const child = spawn(self[0]!, [...self.slice(1), "run"], {
154
+ const child = spawn(self[0]!, [...self.slice(1), "run", `--port=${port}`], {
178
155
  detached: true,
179
156
  stdio: ["ignore", out, err],
180
157
  env,
@@ -182,52 +159,61 @@ async function cmdStart() {
182
159
  });
183
160
  child.unref();
184
161
 
185
- // Save instance metadata
186
- saveInstance({
187
- pid: child.pid!,
188
- name,
189
- port,
190
- logPath,
191
- cwd: process.cwd(),
192
- startedAt: new Date().toISOString(),
193
- });
194
-
195
162
  console.log(`RestBase started (PID: ${child.pid}, port: ${port})`);
196
163
  }
197
164
 
198
- /** stop — by PID or "all" */
199
- function cmdStop(target: string) {
200
- if (target === "all") {
201
- const instances = loadAllInstances();
202
- if (instances.length === 0) {
203
- console.log("No RestBase instances found");
204
- return;
205
- }
206
- for (const inst of instances) stopOne(inst.pid);
207
- return;
165
+ /**
166
+ * Resolve a target identifier to matching instances.
167
+ * Supports: "all", numeric PID, or SVR_NAME (fetched from health endpoint).
168
+ */
169
+ async function resolveInstances(target: string): Promise<{ pid: number; port: number; name?: string }[]> {
170
+ const all = discoverInstances();
171
+ if (all.length === 0) return [];
172
+ if (target === "all") return all;
173
+
174
+ // Try numeric PID first
175
+ const num = parseInt(target, 10);
176
+ if (!isNaN(num)) {
177
+ const byPid = all.find((i) => i.pid === num);
178
+ if (byPid) return [byPid];
179
+ // Maybe it's a port number?
180
+ const byPort = all.find((i) => i.port === num);
181
+ if (byPort) return [byPort];
182
+ return [];
208
183
  }
209
184
 
210
- const pid = parseInt(target, 10);
211
- if (isNaN(pid)) {
212
- console.error(`Invalid PID: ${target}`);
213
- process.exit(1);
185
+ // Otherwise treat as SVR_NAME — need to fetch health for each to match
186
+ const matched: { pid: number; port: number; name?: string }[] = [];
187
+ for (const inst of all) {
188
+ const health = await fetchHealth(inst.port);
189
+ if (health?.name === target) {
190
+ matched.push({...inst, name: health.name});
191
+ }
214
192
  }
215
- stopOne(pid);
193
+ return matched;
216
194
  }
217
195
 
218
- function stopOne(pid: number) {
219
- const meta = loadInstance(pid);
220
- if (!meta) {
221
- console.log(`No instance found with PID ${pid}`);
196
+ /** stop by PID, name, or "all" */
197
+ async function cmdStop(target: string) {
198
+ const instances = await resolveInstances(target);
199
+ if (instances.length === 0) {
200
+ console.log(target === "all" ? "No RestBase instances found" : `No instance found matching "${target}"`);
222
201
  return;
223
202
  }
203
+ for (const inst of instances) stopOne(inst.pid, inst.port, inst.name);
204
+ }
205
+
206
+ function stopOne(pid: number, port?: number, name?: string) {
207
+ const label = [name, port ? `port ${port}` : ""].filter(Boolean).join(", ");
224
208
  try {
225
- if (isAlive(pid)) process.kill(pid, "SIGTERM");
226
- removeInstance(pid);
227
- console.log(`Stopped PID ${pid} (port ${meta.port})`);
209
+ if (isAlive(pid)) {
210
+ process.kill(pid, "SIGTERM");
211
+ console.log(`Stopped PID ${pid}${label ? ` (${label})` : ""}`);
212
+ } else {
213
+ console.log(`Process ${pid} is not running`);
214
+ }
228
215
  } catch {
229
- removeInstance(pid);
230
- console.log(`Process ${pid} already gone, cleaned up`);
216
+ console.log(`Failed to stop PID ${pid}`);
231
217
  }
232
218
  }
233
219
 
@@ -271,7 +257,7 @@ function fmtTime(iso: string): string {
271
257
 
272
258
  /** status — table with health check */
273
259
  async function cmdStatus() {
274
- const instances = loadAllInstances();
260
+ const instances = discoverInstances();
275
261
  if (instances.length === 0) {
276
262
  console.log("No RestBase instances found");
277
263
  return;
@@ -294,24 +280,18 @@ async function cmdStatus() {
294
280
  console.log("─".repeat(nW + pW + sW + ptW + stW + uW + mW + cW + 30));
295
281
 
296
282
  for (const inst of instances) {
297
- // Dead process → clean up silently
298
- if (!isAlive(inst.pid)) {
299
- removeInstance(inst.pid);
300
- continue;
301
- }
302
-
303
283
  // Fetch live info from health endpoint
304
284
  const health = await fetchHealth(inst.port);
305
285
 
306
- const name = health?.name || inst.name || "-";
286
+ const name = health?.name || "-";
307
287
  const state = health?.status ?? "unreachable";
308
- const started = health?.startedAt ? fmtTime(health.startedAt) : fmtTime(inst.startedAt);
288
+ const started = health?.startedAt ? fmtTime(health.startedAt) : "-";
309
289
  const uptime = health?.uptime !== undefined ? fmtUptime(health.uptime) : "-";
310
290
  const mem = health?.memory ? fmtBytes(health.memory.rss) : "-";
311
291
  const cpu = (health?.cpu && health?.uptime)
312
292
  ? fmtCpu(health.cpu.user, health.cpu.system, health.uptime)
313
293
  : "-";
314
- const logPath = health?.logFile || inst.logPath;
294
+ const logPath = health?.logFile || "-";
315
295
 
316
296
  console.log(
317
297
  name.padEnd(nW) +
@@ -327,55 +307,36 @@ async function cmdStatus() {
327
307
  }
328
308
  }
329
309
 
330
- /**
331
- * Find the latest log file matching the base path.
332
- * pino-roll creates files like: app.2026-02-11.1.log (date + sequence inserted before extension)
333
- * So for base "log/app.log", we look for "app*.log" in "log/".
334
- */
335
- function findLatestLog(basePath: string): string | null {
336
- // If exact file exists, use it
337
- if (existsSync(basePath)) return basePath;
338
-
339
- const dir = resolve(basePath, "..");
340
- if (!existsSync(dir)) return null;
341
-
342
- const base = basePath.split("/").pop()!; // "app.log"
343
- const dot = base.lastIndexOf(".");
344
- const stem = dot > 0 ? base.slice(0, dot) : base; // "app"
345
- const ext = dot > 0 ? base.slice(dot) : ""; // ".log"
346
-
347
- // Find all matching files, sort by mtime descending
348
- const files = readdirSync(dir)
349
- .filter((f) => f.startsWith(stem) && f.endsWith(ext) && f !== base)
350
- .map((f) => ({name: f, mtime: Bun.file(join(dir, f)).lastModified}))
351
- .sort((a, b) => b.mtime - a.mtime);
352
-
353
- return files.length > 0 ? join(dir, files[0]!.name) : null;
354
- }
355
-
356
- /** log — tail -f the log file of an instance */
310
+ /** log — tail -f the log file of an instance (by PID or name) */
357
311
  async function cmdLog(target: string) {
358
- const pid = parseInt(target, 10);
359
- if (isNaN(pid)) {
360
- console.error(`Invalid PID: ${target}`);
312
+ const instances = await resolveInstances(target);
313
+ if (instances.length === 0) {
314
+ console.error(`No running RestBase instance found matching "${target}"`);
315
+ process.exit(1);
316
+ }
317
+ if (instances.length > 1) {
318
+ console.error(`Multiple instances match "${target}". Use PID to be specific:`);
319
+ for (const i of instances) console.error(` PID ${i.pid} (port ${i.port})`);
361
320
  process.exit(1);
362
321
  }
363
322
 
364
- const meta = loadInstance(pid);
365
- if (!meta) {
366
- console.error(`No instance found with PID ${pid}`);
323
+ const inst = instances[0]!;
324
+
325
+ // Fetch log file path from health endpoint (points to current.log symlink)
326
+ const health = await fetchHealth(inst.port);
327
+ if (!health?.logFile) {
328
+ console.error(`Cannot determine log file for PID ${inst.pid} (health endpoint unreachable or logFile not configured)`);
367
329
  process.exit(1);
368
330
  }
369
331
 
370
- const logFile = findLatestLog(meta.logPath);
371
- if (!logFile) {
372
- console.error(`Log file not found for base path: ${meta.logPath}`);
332
+ if (!existsSync(health.logFile)) {
333
+ console.error(`Log file not found: ${health.logFile}`);
373
334
  process.exit(1);
374
335
  }
375
336
 
376
- console.log(`Tailing ${logFile} (Ctrl+C to stop)\n`);
337
+ console.log(`Tailing ${health.logFile} (Ctrl+C to stop)\n`);
377
338
 
378
- const tail = spawn("tail", ["-f", "-n", "100", logFile], {
339
+ const tail = spawn("tail", ["-f", "-n", "100", health.logFile], {
379
340
  stdio: "inherit",
380
341
  });
381
342
 
@@ -387,54 +348,195 @@ async function cmdLog(target: string) {
387
348
  await new Promise<void>((resolve) => tail.on("close", () => resolve()));
388
349
  }
389
350
 
390
- /** env generate .env template */
391
- function cmdEnv() {
392
- const envPath = resolve(process.cwd(), ".env");
393
- if (existsSync(envPath)) {
394
- console.log(`.env already exists at ${envPath}`);
395
- console.log("Remove it first if you want to regenerate.");
396
- return;
351
+ /* ═══════════ Interactive env configuration ═══════════ */
352
+
353
+ /** Parse an existing .env file into a key→value map */
354
+ function parseEnvFile(path: string): Record<string, string> {
355
+ const map: Record<string, string> = {};
356
+ if (!existsSync(path)) return map;
357
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
358
+ const trimmed = line.trim();
359
+ if (!trimmed || trimmed.startsWith("#")) continue;
360
+ const eq = trimmed.indexOf("=");
361
+ if (eq < 0) continue;
362
+ const key = trimmed.slice(0, eq).trim();
363
+ let val = trimmed.slice(eq + 1).trim();
364
+ // strip surrounding quotes
365
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
366
+ val = val.slice(1, -1);
367
+ }
368
+ map[key] = val;
397
369
  }
370
+ return map;
371
+ }
372
+
373
+ /** Interactive env — prompt user for each config parameter */
374
+ async function cmdEnv() {
375
+ const envPath = resolve(process.cwd(), ".env");
376
+ const existing = parseEnvFile(envPath);
377
+ const isUpdate = Object.keys(existing).length > 0;
398
378
 
399
- const content = `# ═══════════════════════════════════════════════════════════
400
- # RestBase Configuration
401
- # ═══════════════════════════════════════════════════════════
402
- # Uncomment and modify the values you need.
403
- # All variables have sensible defaults — you can start with
404
- # an empty .env and only override what you need.
405
- # ═══════════════════════════════════════════════════════════
379
+ p.intro(isUpdate ? "Reconfigure .env (current values as defaults)" : "Initialize .env configuration");
380
+
381
+ /* helper: get default — existing value > built-in default */
382
+ const d = (key: string, fallback: string) => existing[key] ?? fallback;
383
+
384
+ /* helper: wrap clack text returns string (empty if user skipped) */
385
+ const ask = async (opts: { message: string; key: string; fallback: string; placeholder?: string }) => {
386
+ const result = await p.text({
387
+ message: opts.message,
388
+ initialValue: d(opts.key, opts.fallback),
389
+ placeholder: opts.placeholder,
390
+ });
391
+ if (p.isCancel(result)) { p.cancel("Cancelled"); process.exit(0); }
392
+ return (result as string).trim();
393
+ };
406
394
 
407
- # ── Server ────────────────────────────────────────────────
395
+ /* helper: select */
396
+ const choose = async (opts: { message: string; key: string; fallback: string; options: { value: string; label: string }[] }) => {
397
+ const result = await p.select({
398
+ message: opts.message,
399
+ initialValue: d(opts.key, opts.fallback),
400
+ options: opts.options,
401
+ });
402
+ if (p.isCancel(result)) { p.cancel("Cancelled"); process.exit(0); }
403
+ return result as string;
404
+ };
408
405
 
409
- # SVR_NAME= # Instance name (shown in 'restbase status')
410
- # SVR_PORT=3333 # Server port
411
- # SVR_STATIC= # Static file directory for SPA hosting
412
- # SVR_API_LIMIT=100 # Rate limit: max requests per second per API
406
+ // ── Server ──
407
+ p.log.step("Server");
413
408
 
414
- # ── Database ──────────────────────────────────────────────
409
+ const SVR_NAME = await ask({
410
+ message: "Instance name (shown in 'restbase status')",
411
+ key: "SVR_NAME", fallback: "", placeholder: "optional",
412
+ });
413
+ const SVR_PORT = await ask({
414
+ message: "Server port",
415
+ key: "SVR_PORT", fallback: "3333",
416
+ });
417
+ const SVR_STATIC = await ask({
418
+ message: "Static file directory for SPA hosting",
419
+ key: "SVR_STATIC", fallback: "", placeholder: "optional, e.g. dist",
420
+ });
421
+ const SVR_API_LIMIT = await ask({
422
+ message: "Rate limit (max requests per second per API, 0 = off)",
423
+ key: "SVR_API_LIMIT", fallback: "100",
424
+ });
415
425
 
416
- # DB_URL=sqlite://:memory: # sqlite://<path> or mysql://user:pass@host/db
417
- # DB_AUTH_TABLE=users # Auth table name
418
- # DB_AUTH_FIELD=owner # Owner field name for tenant isolation
419
- # DB_AUTH_FIELD_NULL_OPEN=false # Treat owner=NULL rows as public data
420
- # DB_INIT_SQL= # SQL file to run on startup
426
+ // ── Database ──
427
+ p.log.step("Database");
421
428
 
422
- # ── Auth ──────────────────────────────────────────────────
429
+ const DB_URL = await ask({
430
+ message: "Database URL (sqlite://<path> or mysql://user:pass@host/db)",
431
+ key: "DB_URL", fallback: "sqlite://:memory:",
432
+ });
433
+ const DB_AUTH_TABLE = await ask({
434
+ message: "Auth table name",
435
+ key: "DB_AUTH_TABLE", fallback: "users",
436
+ });
437
+ const DB_AUTH_FIELD = await ask({
438
+ message: "Owner field name (tenant isolation)",
439
+ key: "DB_AUTH_FIELD", fallback: "owner",
440
+ });
441
+ const DB_AUTH_FIELD_NULL_OPEN = await choose({
442
+ message: "Treat owner=NULL rows as public data?",
443
+ key: "DB_AUTH_FIELD_NULL_OPEN", fallback: "false",
444
+ options: [
445
+ {value: "false", label: "false — NULL rows are hidden"},
446
+ {value: "true", label: "true — NULL rows are visible to everyone"},
447
+ ],
448
+ });
449
+ const DB_INIT_SQL = await ask({
450
+ message: "SQL file to run on startup",
451
+ key: "DB_INIT_SQL", fallback: "", placeholder: "optional, e.g. init.sql",
452
+ });
423
453
 
424
- # AUTH_JWT_SECRET=restbase # JWT secret — CHANGE THIS IN PRODUCTION!
425
- # AUTH_JWT_EXP=43200 # JWT expiry in seconds (default: 12 hours)
426
- # AUTH_BASIC_OPEN=true # Enable Basic Auth
454
+ // ── Auth ──
455
+ p.log.step("Auth");
427
456
 
428
- # ── Logging ───────────────────────────────────────────────
457
+ const AUTH_JWT_SECRET = await ask({
458
+ message: "JWT secret (⚠ change in production!)",
459
+ key: "AUTH_JWT_SECRET", fallback: "restbase",
460
+ });
461
+ const AUTH_JWT_EXP = await ask({
462
+ message: "JWT expiry in seconds (default: 12 hours)",
463
+ key: "AUTH_JWT_EXP", fallback: "43200",
464
+ });
465
+ const AUTH_BASIC_OPEN = await choose({
466
+ message: "Enable Basic Auth?",
467
+ key: "AUTH_BASIC_OPEN", fallback: "true",
468
+ options: [
469
+ {value: "true", label: "true — Basic Auth enabled"},
470
+ {value: "false", label: "false — JWT only"},
471
+ ],
472
+ });
429
473
 
430
- # LOG_LEVEL=INFO # ERROR / INFO / DEBUG
431
- # LOG_CONSOLE=true # Console output (auto-disabled in daemon mode)
432
- # LOG_FILE= # Log file path (auto-configured in daemon mode)
433
- # LOG_RETAIN_DAYS=7 # Log file retention days
434
- `;
474
+ // ── Logging ──
475
+ p.log.step("Logging");
476
+
477
+ const LOG_LEVEL = await choose({
478
+ message: "Log level",
479
+ key: "LOG_LEVEL", fallback: "INFO",
480
+ options: [
481
+ {value: "ERROR", label: "ERROR — errors only"},
482
+ {value: "INFO", label: "INFO — requests + errors"},
483
+ {value: "DEBUG", label: "DEBUG — verbose (headers, body, SQL)"},
484
+ ],
485
+ });
486
+ const LOG_CONSOLE = await choose({
487
+ message: "Console output? (auto-disabled in daemon mode)",
488
+ key: "LOG_CONSOLE", fallback: "true",
489
+ options: [
490
+ {value: "true", label: "true — print to console"},
491
+ {value: "false", label: "false — silent"},
492
+ ],
493
+ });
494
+ const LOG_FILE = await ask({
495
+ message: "Log file path (auto-configured in daemon mode)",
496
+ key: "LOG_FILE", fallback: "", placeholder: "optional, e.g. log/app.log",
497
+ });
498
+ const LOG_RETAIN_DAYS = await ask({
499
+ message: "Log file retention days",
500
+ key: "LOG_RETAIN_DAYS", fallback: "7",
501
+ });
435
502
 
436
- writeFileSync(envPath, content);
437
- console.log(`Created ${envPath}`);
503
+ // ── Build .env content ──
504
+ const line = (key: string, val: string, comment: string) =>
505
+ val ? `${key}=${val}${comment ? ` # ${comment}` : ""}` : `# ${key}=${comment ? ` # ${comment}` : ""}`;
506
+
507
+ const lines = [
508
+ "# ═══════════════════════════════════════════════════════════",
509
+ "# RestBase Configuration",
510
+ "# ═══════════════════════════════════════════════════════════",
511
+ "",
512
+ "# ── Server ────────────────────────────────────────────────",
513
+ line("SVR_NAME", SVR_NAME, "Instance name"),
514
+ line("SVR_PORT", SVR_PORT, "Server port"),
515
+ line("SVR_STATIC", SVR_STATIC, "Static file directory"),
516
+ line("SVR_API_LIMIT", SVR_API_LIMIT, "Rate limit per API"),
517
+ "",
518
+ "# ── Database ──────────────────────────────────────────────",
519
+ line("DB_URL", DB_URL, ""),
520
+ line("DB_AUTH_TABLE", DB_AUTH_TABLE, "Auth table name"),
521
+ line("DB_AUTH_FIELD", DB_AUTH_FIELD, "Owner field"),
522
+ line("DB_AUTH_FIELD_NULL_OPEN", DB_AUTH_FIELD_NULL_OPEN, ""),
523
+ line("DB_INIT_SQL", DB_INIT_SQL, "SQL file on startup"),
524
+ "",
525
+ "# ── Auth ──────────────────────────────────────────────────",
526
+ line("AUTH_JWT_SECRET", AUTH_JWT_SECRET, "⚠ change in production"),
527
+ line("AUTH_JWT_EXP", AUTH_JWT_EXP, "seconds"),
528
+ line("AUTH_BASIC_OPEN", AUTH_BASIC_OPEN, ""),
529
+ "",
530
+ "# ── Logging ───────────────────────────────────────────────",
531
+ line("LOG_LEVEL", LOG_LEVEL, "ERROR / INFO / DEBUG"),
532
+ line("LOG_CONSOLE", LOG_CONSOLE, ""),
533
+ line("LOG_FILE", LOG_FILE, "auto-configured in daemon mode"),
534
+ line("LOG_RETAIN_DAYS", LOG_RETAIN_DAYS, "days"),
535
+ "",
536
+ ];
537
+
538
+ writeFileSync(envPath, lines.join("\n"));
539
+ p.outro(`Saved to ${envPath}`);
438
540
  }
439
541
 
440
542
  /** version */
@@ -454,10 +556,10 @@ Usage:
454
556
  Commands:
455
557
  run Start server in foreground (reads .env)
456
558
  start Start server in background (daemon mode)
457
- stop <pid|all> Stop a background instance by PID, or stop all
458
- status Show all running background instances
459
- log <pid> Tail the log of a background instance
460
- env Generate a .env template in current directory
559
+ stop <pid|name|all> Stop instance(s) by PID, SVR_NAME, or all
560
+ status Show all running background instances
561
+ log <pid|name> Tail the log of an instance by PID or SVR_NAME
562
+ env Interactive .env configuration (create or reconfigure)
461
563
  version Show version
462
564
  help Show this help
463
565
 
@@ -465,16 +567,20 @@ Examples:
465
567
  restbase run Start in foreground
466
568
  restbase start Start in background (daemon)
467
569
  restbase status List running instances with health status
468
- restbase log 12345 Tail log for instance with PID 12345
469
- restbase stop 12345 Stop instance with PID 12345
570
+ restbase log 12345 Tail log by PID
571
+ restbase log my-api Tail log by SVR_NAME
572
+ restbase stop 12345 Stop instance by PID
573
+ restbase stop my-api Stop instance by SVR_NAME
470
574
  restbase stop all Stop all background instances
471
- restbase env Create a documented .env template
575
+ restbase env Interactive .env setup (reconfigure if exists)
472
576
 
473
577
  Configuration:
474
578
  All settings are read from .env in the current working directory.
475
- Run 'restbase env' to generate a documented template with all options.
579
+ Run 'restbase env' for interactive configuration (create or update .env).
476
580
 
477
- Instance data is stored in ~/.restbase/ (PID files, logs, metadata).
581
+ Instance discovery:
582
+ Background instances are discovered via 'ps' — no PID files needed.
583
+ Instance details (name, log path, uptime…) are fetched from /api/health.
478
584
  `);
479
585
  }
480
586
 
@@ -495,10 +601,10 @@ switch (command) {
495
601
 
496
602
  case "stop":
497
603
  if (!arg1) {
498
- console.error("Usage: restbase stop <pid|all>");
604
+ console.error("Usage: restbase stop <pid|name|all>");
499
605
  process.exit(1);
500
606
  }
501
- cmdStop(arg1);
607
+ await cmdStop(arg1);
502
608
  process.exit(0);
503
609
  break;
504
610
 
@@ -509,14 +615,14 @@ switch (command) {
509
615
 
510
616
  case "log":
511
617
  if (!arg1) {
512
- console.error("Usage: restbase log <pid>");
618
+ console.error("Usage: restbase log <pid|name>");
513
619
  process.exit(1);
514
620
  }
515
621
  await cmdLog(arg1);
516
622
  break;
517
623
 
518
624
  case "env":
519
- cmdEnv();
625
+ await cmdEnv();
520
626
  process.exit(0);
521
627
  break;
522
628
 
package/client/README.md CHANGED
@@ -51,7 +51,7 @@ import RestBase, {
51
51
  ```ts
52
52
  import RestBase, { eq, gt, or, agg, sel } from "@dtdyq/restbase-client";
53
53
 
54
- // 同源部署(Vite proxy / 静态托管)— 不需要传 endpoint
54
+ // 同源部署 不需要传 endpoint
55
55
  const rb = new RestBase();
56
56
 
57
57
  // 或指定后端地址
@@ -67,6 +67,16 @@ const list = await products.query()
67
67
  .orderDesc("price")
68
68
  .page(1, 20)
69
69
  .data();
70
+
71
+ // ── 多节点负载均衡 ──
72
+ // 所有节点连同一个数据库,请求随机分发
73
+ const rb2 = new RestBase([
74
+ "http://node1:3333",
75
+ "http://node2:3333",
76
+ "http://node3:3333",
77
+ ]);
78
+ await rb2.auth.login("admin", "admin"); // 登录一次,token 共享
79
+ const list2 = await rb2.table("products").query().data(); // 随机打到某个节点
70
80
  ```
71
81
 
72
82
  ---
@@ -74,20 +84,32 @@ const list = await products.query()
74
84
  ## RestBase — 主入口
75
85
 
76
86
  ```ts
77
- const rb = new RestBase(); // 同源
78
- const rb = new RestBase("http://localhost:3333"); // 跨域
87
+ // 同源部署(Vite proxy / 静态托管)
88
+ const rb = new RestBase();
89
+
90
+ // 单个 endpoint
91
+ const rb = new RestBase("http://localhost:3333");
92
+
93
+ // 多个 endpoint — 负载均衡(每次请求随机选一个节点)
94
+ const rb = new RestBase([
95
+ "http://localhost:3333",
96
+ "http://localhost:8080",
97
+ "http://localhost:9090",
98
+ ]);
79
99
  ```
80
100
 
81
- | 方法 | 返回类型 | 说明 |
82
- |:----------------------|:------------------------------------------|:--------------------------|
83
- | `rb.auth` | `AuthClient` | 鉴权客户端 |
84
- | `rb.table<T>(name)` | `TableClient<T>` | 获取表操作客户端(可指定泛型) |
85
- | `rb.health()` | `Promise<ApiResponse>` | 健康检查 |
86
- | `rb.tables()` | `Promise<ApiResponse<TableMeta[]>>` | 获取所有表元数据(不含 users) |
87
- | `rb.tableMeta(name)` | `Promise<ApiResponse<TableMeta \| null>>` | 获取单表元数据(不存在返回 null |
88
- | `rb.syncMeta()` | `Promise<ApiResponse<TableMeta[]>>` | 运行时同步 DB 表结构 |
89
- | `rb.setHeader(k, v)` | `this` | 设置自定义请求头 |
90
- | `rb.setRequestId(id)` | `this` | 设置请求追踪 ID(`X-Request-Id`) |
101
+ > endpoint 模式要求所有服务端实例连接同一个数据库。客户端共享同一套 auth 状态(token / Basic Auth),每次请求随机分发到不同节点,分散单节点压力。
102
+
103
+ | 方法 | 返回类型 | 说明 |
104
+ |:----------------------|:------------------------------------------|:------------------------------------|
105
+ | `rb.auth` | `AuthClient` | 鉴权客户端 |
106
+ | `rb.table<T>(name)` | `TableClient<T>` | 获取表操作客户端 |
107
+ | `rb.health()` | `Promise<ApiResponse>` | 健康检查(返回 name/port/pid/uptime/mem/cpu |
108
+ | `rb.tables()` | `Promise<ApiResponse<TableMeta[]>>` | 获取所有表元数据(不含 users) |
109
+ | `rb.tableMeta(name)` | `Promise<ApiResponse<TableMeta \| null>>` | 获取单表元数据 |
110
+ | `rb.syncMeta()` | `Promise<ApiResponse<TableMeta[]>>` | 运行时同步 DB 表结构 |
111
+ | `rb.setHeader(k, v)` | `this` | 设置自定义请求头 |
112
+ | `rb.setRequestId(id)` | `this` | 设置请求追踪 ID(`X-Request-Id`) |
91
113
 
92
114
  **TableMeta 结构:**
93
115
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtdyq/restbase-client",
3
- "version": "1.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Type-safe, zero-dependency client for RestBase API — works in Browser / Node / Bun / Deno",
5
5
  "keywords": ["restbase", "rest", "api", "client", "typescript", "query-builder"],
6
6
  "license": "MIT",
@@ -451,14 +451,20 @@ export class AuthClient {
451
451
  ══════════════════════════════════════════════════════════════ */
452
452
 
453
453
  class HttpClient {
454
- private _baseUrl: string;
454
+ private _urls: string[];
455
455
  private _token: string | null = null;
456
456
  private _basicAuth: string | null = null;
457
457
  private _requestId?: string;
458
458
  private _headers: Record<string, string> = {};
459
459
 
460
- constructor(baseUrl: string) {
461
- this._baseUrl = baseUrl.replace(/\/+$/, "");
460
+ constructor(urls: string | string[]) {
461
+ const list = Array.isArray(urls) ? urls : [urls];
462
+ this._urls = list.map((u) => u.replace(/\/+$/, ""));
463
+ }
464
+
465
+ /** Pick a random base URL (load balancing) */
466
+ private _pickUrl(): string {
467
+ return this._urls[Math.floor(Math.random() * this._urls.length)]!;
462
468
  }
463
469
 
464
470
  setToken(token: string | null): void {
@@ -511,7 +517,7 @@ class HttpClient {
511
517
  }
512
518
 
513
519
  private async _fetch<T>(method: string, path: string, params?: Record<string, string>, body?: unknown): Promise<ApiResponse<T>> {
514
- let url = `${this._baseUrl}${path}`;
520
+ let url = `${this._pickUrl()}${path}`;
515
521
  if (params && Object.keys(params).length > 0) {
516
522
  url += `?${new URLSearchParams(params).toString()}`;
517
523
  }
@@ -526,12 +532,35 @@ class HttpClient {
526
532
  RestBase — 主入口
527
533
  ══════════════════════════════════════════════════════════════ */
528
534
 
535
+ /**
536
+ * RestBase — 前端客户端主入口
537
+ *
538
+ * 支持三种构造方式:
539
+ *
540
+ * ```ts
541
+ * // 1. 同源部署(无参数)
542
+ * const rb = new RestBase();
543
+ *
544
+ * // 2. 单个 endpoint
545
+ * const rb = new RestBase("http://localhost:3333");
546
+ *
547
+ * // 3. 多个 endpoint(负载均衡,每次请求随机选一个)
548
+ * const rb = new RestBase([
549
+ * "http://localhost:3333",
550
+ * "http://localhost:8080",
551
+ * "http://localhost:9090",
552
+ * ]);
553
+ * ```
554
+ *
555
+ * 多 endpoint 模式下,所有服务端实例应连接同一个数据库。
556
+ * 客户端共享同一套 auth 状态,每次请求随机分发到不同节点。
557
+ */
529
558
  export class RestBase {
530
559
  readonly auth: AuthClient;
531
560
  private _http: HttpClient;
532
561
 
533
- constructor(endpoint: string = "") {
534
- this._http = new HttpClient(endpoint);
562
+ constructor(endpoint?: string | string[]) {
563
+ this._http = new HttpClient(endpoint ?? "");
535
564
  this.auth = new AuthClient(this._http);
536
565
  }
537
566
 
@@ -541,8 +570,8 @@ export class RestBase {
541
570
  }
542
571
 
543
572
  /** 健康检查 */
544
- async health(): Promise<ApiResponse<{ status: string }>> {
545
- return this._http.get<{ status: string }>("/api/health");
573
+ async health(): Promise<ApiResponse> {
574
+ return this._http.get("/api/health");
546
575
  }
547
576
 
548
577
  /** 获取所有表元数据(不含 users 表) */
@@ -560,13 +589,13 @@ export class RestBase {
560
589
  return this._http.get<TableMeta[]>("/api/meta/sync");
561
590
  }
562
591
 
563
- /** 设置自定义请求头 */
592
+ /** 设置自定义请求头(当前 endpoint) */
564
593
  setHeader(key: string, value: string): this {
565
594
  this._http.setHeader(key, value);
566
595
  return this;
567
596
  }
568
597
 
569
- /** 设置请求追踪 ID */
598
+ /** 设置请求追踪 ID(当前 endpoint) */
570
599
  setRequestId(id: string): this {
571
600
  this._http.setRequestId(id);
572
601
  return this;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtdyq/restbase",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Zero-code REST API server for SQLite / MySQL — CLI with daemon mode, health monitoring, built-in auth & tenant isolation",
5
5
  "keywords": ["rest", "api", "crud", "sqlite", "mysql", "hono", "bun", "zero-code", "cli", "daemon"],
6
6
  "license": "MIT",
@@ -18,10 +18,12 @@
18
18
  },
19
19
  "scripts": {
20
20
  "dev": "bun run --watch src/server.ts",
21
+ "start": "bun run src/server.ts",
21
22
  "test": "bun test src/rest.test.ts",
22
23
  "build": "bun build --compile --minify --sourcemap ./bin/restbase.ts --outfile restbase"
23
24
  },
24
25
  "dependencies": {
26
+ "@clack/prompts": "^1.0.0",
25
27
  "@hono/zod-validator": "^0.4.0",
26
28
  "hono": "^4.6.0",
27
29
  "pino": "^10.3.1",
package/src/logger.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * - 日志保留天数(LOG_RETAIN_DAYS,默认 7)
8
8
  * - 日志等级(LOG_LEVEL)
9
9
  *
10
+ * pino-roll 启用 symlink,当前日志始终通过 current.log 软链接访问。
11
+ *
10
12
  * 导出 log 实例,全局使用 log.info / log.debug / log.error / log.fatal
11
13
  */
12
14
  import pino from "pino";
@@ -57,7 +59,7 @@ if (cfg.logConsole) {
57
59
  });
58
60
  }
59
61
 
60
- /* 文件(pino-roll 滚动写入 NDJSON */
62
+ /* 文件(pino-roll 滚动写入 NDJSON,symlink 指向当前文件) */
61
63
  if (cfg.logFile) {
62
64
  const {default: buildRollStream} = await import("pino-roll");
63
65
  const rollStream = await buildRollStream({
@@ -67,6 +69,7 @@ if (cfg.logFile) {
67
69
  dateFormat: "yyyy-MM-dd",
68
70
  limit: {count: cfg.logRetainDays},
69
71
  mkdir: true,
72
+ symlink: true,
70
73
  });
71
74
 
72
75
  streams.push({
package/src/server.ts CHANGED
@@ -21,6 +21,7 @@ import {Hono} from "hono";
21
21
  import {requestId} from "hono/request-id";
22
22
  import {cors} from "hono/cors";
23
23
  import {serveStatic} from "hono/bun";
24
+ import {resolve, dirname} from "path";
24
25
  import {type ApiRes, type AppEnv, AppError, cfg, ok, reqStore} from "./types.ts";
25
26
  import {log} from "./logger.ts";
26
27
  import {getTableMetaByName, getTablesMeta, initDb, syncTablesMeta} from "./db.ts";
@@ -173,7 +174,8 @@ app.get("/api/health", (c) => {
173
174
  name: cfg.name || undefined,
174
175
  port: cfg.port,
175
176
  pid: process.pid,
176
- logFile: cfg.logFile || undefined,
177
+ cwd: process.cwd(),
178
+ logFile: cfg.logFile ? resolve(dirname(cfg.logFile), "current.log") : undefined,
177
179
  startedAt,
178
180
  uptime: uptimeSec,
179
181
  memory: {