@dtdyq/restbase 1.0.0 → 2.0.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/bin/restbase.ts CHANGED
@@ -1,2 +1,541 @@
1
1
  #!/usr/bin/env bun
2
- import "../server.ts";
2
+ /**
3
+ * bin/restbase.ts — CLI entry point
4
+ *
5
+ * Commands:
6
+ * restbase run Start server in foreground (reads .env)
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
11
+ * restbase env Generate .env template in current directory
12
+ * restbase version Show version
13
+ * restbase help Show help
14
+ *
15
+ * All configuration is read from .env in the current working directory.
16
+ * Instance metadata is stored in ~/.restbase/ for global access.
17
+ */
18
+
19
+ import {spawn} from "child_process";
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readFileSync,
24
+ unlinkSync,
25
+ readdirSync,
26
+ openSync,
27
+ writeFileSync,
28
+ } from "fs";
29
+ import {resolve, join} from "path";
30
+ import {homedir} from "os";
31
+
32
+ /* ═══════════ Global paths ═══════════ */
33
+
34
+ const RESTBASE_HOME = resolve(homedir(), ".restbase");
35
+ const INSTANCES_DIR = join(RESTBASE_HOME, "instances");
36
+ const LOGS_DIR = join(RESTBASE_HOME, "logs");
37
+
38
+ function ensureDirs() {
39
+ mkdirSync(INSTANCES_DIR, {recursive: true});
40
+ mkdirSync(LOGS_DIR, {recursive: true});
41
+ }
42
+
43
+ /* ═══════════ Instance metadata ═══════════ */
44
+
45
+ interface InstanceMeta {
46
+ pid: number;
47
+ name: string;
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
+ }
61
+
62
+ function loadInstance(pid: number): InstanceMeta | null {
63
+ const f = instanceFile(pid);
64
+ if (!existsSync(f)) return null;
65
+ try {
66
+ return JSON.parse(readFileSync(f, "utf-8")) as InstanceMeta;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
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
+ /* ═══════════ Process helpers ═══════════ */
90
+
91
+ function isAlive(pid: number): boolean {
92
+ try {
93
+ process.kill(pid, 0);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ interface HealthInfo {
101
+ status: string;
102
+ name?: string;
103
+ port?: number;
104
+ pid?: number;
105
+ logFile?: string;
106
+ startedAt?: string;
107
+ uptime?: number;
108
+ memory?: { rss: number; heapUsed: number; heapTotal: number; external: number };
109
+ cpu?: { user: number; system: number };
110
+ }
111
+
112
+ async function fetchHealth(port: number): Promise<HealthInfo | null> {
113
+ try {
114
+ const res = await fetch(`http://localhost:${port}/api/health`, {
115
+ signal: AbortSignal.timeout(2000),
116
+ });
117
+ const body = (await res.json()) as any;
118
+ return body?.code === "OK" ? (body.data as HealthInfo) : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** Build the command to re-spawn ourselves */
125
+ function getSelfCommand(): string[] {
126
+ if (
127
+ process.argv[1] &&
128
+ (process.argv[1].endsWith(".ts") || process.argv[1].endsWith(".js"))
129
+ ) {
130
+ return [process.execPath, process.argv[1]];
131
+ }
132
+ return [process.execPath];
133
+ }
134
+
135
+ /* ═══════════ Commands ═══════════ */
136
+
137
+ /** run — foreground, reads .env as-is */
138
+ async function cmdRun() {
139
+ await import("../src/server.ts");
140
+ }
141
+
142
+ /** start — daemon mode */
143
+ async function cmdStart() {
144
+ ensureDirs();
145
+
146
+ const port = Number(process.env.SVR_PORT) || 3333;
147
+ const name = process.env.SVR_NAME || "";
148
+
149
+ // Reject if this port is already running
150
+ for (const inst of loadAllInstances()) {
151
+ if (inst.port === port && isAlive(inst.pid)) {
152
+ console.log(`RestBase already running on port ${port} (PID: ${inst.pid})`);
153
+ return;
154
+ }
155
+ }
156
+
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
163
+ const env: Record<string, string> = {
164
+ ...(process.env as Record<string, string>),
165
+ LOG_CONSOLE: "false",
166
+ };
167
+ if (!process.env.LOG_FILE) {
168
+ env.LOG_FILE = logPath;
169
+ }
170
+
171
+ // stdout/stderr → separate crash log (safety net for non-pino output)
172
+ const stdoutPath = join(LOGS_DIR, `${port}.out`);
173
+ const out = openSync(stdoutPath, "a");
174
+ const err = openSync(stdoutPath, "a");
175
+
176
+ const self = getSelfCommand();
177
+ const child = spawn(self[0]!, [...self.slice(1), "run"], {
178
+ detached: true,
179
+ stdio: ["ignore", out, err],
180
+ env,
181
+ cwd: process.cwd(),
182
+ });
183
+ child.unref();
184
+
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
+ console.log(`RestBase started (PID: ${child.pid}, port: ${port})`);
196
+ }
197
+
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;
208
+ }
209
+
210
+ const pid = parseInt(target, 10);
211
+ if (isNaN(pid)) {
212
+ console.error(`Invalid PID: ${target}`);
213
+ process.exit(1);
214
+ }
215
+ stopOne(pid);
216
+ }
217
+
218
+ function stopOne(pid: number) {
219
+ const meta = loadInstance(pid);
220
+ if (!meta) {
221
+ console.log(`No instance found with PID ${pid}`);
222
+ return;
223
+ }
224
+ try {
225
+ if (isAlive(pid)) process.kill(pid, "SIGTERM");
226
+ removeInstance(pid);
227
+ console.log(`Stopped PID ${pid} (port ${meta.port})`);
228
+ } catch {
229
+ removeInstance(pid);
230
+ console.log(`Process ${pid} already gone, cleaned up`);
231
+ }
232
+ }
233
+
234
+ /** Format seconds into human readable uptime */
235
+ function fmtUptime(sec: number): string {
236
+ if (sec < 60) return `${sec}s`;
237
+ if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
238
+ const h = Math.floor(sec / 3600);
239
+ const m = Math.floor((sec % 3600) / 60);
240
+ if (h < 24) return `${h}h${m}m`;
241
+ const d = Math.floor(h / 24);
242
+ return `${d}d${h % 24}h`;
243
+ }
244
+
245
+ /** Format bytes into human readable size */
246
+ function fmtBytes(bytes: number): string {
247
+ if (bytes < 1024) return `${bytes}B`;
248
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
249
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
250
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
251
+ }
252
+
253
+ /** Format CPU microseconds into human readable percentage (approx) */
254
+ function fmtCpu(userUs: number, systemUs: number, uptimeSec: number): string {
255
+ if (uptimeSec <= 0) return "-";
256
+ const totalUs = userUs + systemUs;
257
+ const pct = (totalUs / (uptimeSec * 1_000_000)) * 100;
258
+ return `${pct.toFixed(1)}%`;
259
+ }
260
+
261
+ /** Format ISO timestamp to local short form: MM-DD HH:mm:ss */
262
+ function fmtTime(iso: string): string {
263
+ const d = new Date(iso);
264
+ const MM = String(d.getMonth() + 1).padStart(2, "0");
265
+ const DD = String(d.getDate()).padStart(2, "0");
266
+ const hh = String(d.getHours()).padStart(2, "0");
267
+ const mm = String(d.getMinutes()).padStart(2, "0");
268
+ const ss = String(d.getSeconds()).padStart(2, "0");
269
+ return `${MM}-${DD} ${hh}:${mm}:${ss}`;
270
+ }
271
+
272
+ /** status — table with health check */
273
+ async function cmdStatus() {
274
+ const instances = loadAllInstances();
275
+ if (instances.length === 0) {
276
+ console.log("No RestBase instances found");
277
+ return;
278
+ }
279
+
280
+ // Column widths
281
+ const nW = 14, pW = 9, sW = 14, ptW = 7, stW = 17, uW = 10, mW = 10, cW = 8;
282
+
283
+ console.log(
284
+ "NAME".padEnd(nW) +
285
+ "PID".padEnd(pW) +
286
+ "STATE".padEnd(sW) +
287
+ "PORT".padEnd(ptW) +
288
+ "STARTED".padEnd(stW) +
289
+ "UPTIME".padEnd(uW) +
290
+ "MEM".padEnd(mW) +
291
+ "CPU".padEnd(cW) +
292
+ "LOG",
293
+ );
294
+ console.log("─".repeat(nW + pW + sW + ptW + stW + uW + mW + cW + 30));
295
+
296
+ 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
+ // Fetch live info from health endpoint
304
+ const health = await fetchHealth(inst.port);
305
+
306
+ const name = health?.name || inst.name || "-";
307
+ const state = health?.status ?? "unreachable";
308
+ const started = health?.startedAt ? fmtTime(health.startedAt) : fmtTime(inst.startedAt);
309
+ const uptime = health?.uptime !== undefined ? fmtUptime(health.uptime) : "-";
310
+ const mem = health?.memory ? fmtBytes(health.memory.rss) : "-";
311
+ const cpu = (health?.cpu && health?.uptime)
312
+ ? fmtCpu(health.cpu.user, health.cpu.system, health.uptime)
313
+ : "-";
314
+ const logPath = health?.logFile || inst.logPath;
315
+
316
+ console.log(
317
+ name.padEnd(nW) +
318
+ String(inst.pid).padEnd(pW) +
319
+ state.padEnd(sW) +
320
+ String(inst.port).padEnd(ptW) +
321
+ started.padEnd(stW) +
322
+ uptime.padEnd(uW) +
323
+ mem.padEnd(mW) +
324
+ cpu.padEnd(cW) +
325
+ logPath,
326
+ );
327
+ }
328
+ }
329
+
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 */
357
+ async function cmdLog(target: string) {
358
+ const pid = parseInt(target, 10);
359
+ if (isNaN(pid)) {
360
+ console.error(`Invalid PID: ${target}`);
361
+ process.exit(1);
362
+ }
363
+
364
+ const meta = loadInstance(pid);
365
+ if (!meta) {
366
+ console.error(`No instance found with PID ${pid}`);
367
+ process.exit(1);
368
+ }
369
+
370
+ const logFile = findLatestLog(meta.logPath);
371
+ if (!logFile) {
372
+ console.error(`Log file not found for base path: ${meta.logPath}`);
373
+ process.exit(1);
374
+ }
375
+
376
+ console.log(`Tailing ${logFile} (Ctrl+C to stop)\n`);
377
+
378
+ const tail = spawn("tail", ["-f", "-n", "100", logFile], {
379
+ stdio: "inherit",
380
+ });
381
+
382
+ process.on("SIGINT", () => {
383
+ tail.kill();
384
+ process.exit(0);
385
+ });
386
+
387
+ await new Promise<void>((resolve) => tail.on("close", () => resolve()));
388
+ }
389
+
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;
397
+ }
398
+
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
+ # ═══════════════════════════════════════════════════════════
406
+
407
+ # ── Server ────────────────────────────────────────────────
408
+
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
413
+
414
+ # ── Database ──────────────────────────────────────────────
415
+
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
421
+
422
+ # ── Auth ──────────────────────────────────────────────────
423
+
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
427
+
428
+ # ── Logging ───────────────────────────────────────────────
429
+
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
+ `;
435
+
436
+ writeFileSync(envPath, content);
437
+ console.log(`Created ${envPath}`);
438
+ }
439
+
440
+ /** version */
441
+ async function cmdVersion() {
442
+ const pkg = (await import("../package.json")).default;
443
+ console.log(`RestBase v${pkg.version}`);
444
+ }
445
+
446
+ /** help */
447
+ function printHelp() {
448
+ console.log(`
449
+ RestBase — Zero-code REST API server for SQLite / MySQL
450
+
451
+ Usage:
452
+ restbase <command> [arguments]
453
+
454
+ Commands:
455
+ run Start server in foreground (reads .env)
456
+ 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
461
+ version Show version
462
+ help Show this help
463
+
464
+ Examples:
465
+ restbase run Start in foreground
466
+ restbase start Start in background (daemon)
467
+ 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
470
+ restbase stop all Stop all background instances
471
+ restbase env Create a documented .env template
472
+
473
+ Configuration:
474
+ All settings are read from .env in the current working directory.
475
+ Run 'restbase env' to generate a documented template with all options.
476
+
477
+ Instance data is stored in ~/.restbase/ (PID files, logs, metadata).
478
+ `);
479
+ }
480
+
481
+ /* ═══════════ Main ═══════════ */
482
+
483
+ const command = process.argv[2] || "run";
484
+ const arg1 = process.argv[3];
485
+
486
+ switch (command) {
487
+ case "run":
488
+ await cmdRun();
489
+ break;
490
+
491
+ case "start":
492
+ await cmdStart();
493
+ process.exit(0);
494
+ break;
495
+
496
+ case "stop":
497
+ if (!arg1) {
498
+ console.error("Usage: restbase stop <pid|all>");
499
+ process.exit(1);
500
+ }
501
+ cmdStop(arg1);
502
+ process.exit(0);
503
+ break;
504
+
505
+ case "status":
506
+ await cmdStatus();
507
+ process.exit(0);
508
+ break;
509
+
510
+ case "log":
511
+ if (!arg1) {
512
+ console.error("Usage: restbase log <pid>");
513
+ process.exit(1);
514
+ }
515
+ await cmdLog(arg1);
516
+ break;
517
+
518
+ case "env":
519
+ cmdEnv();
520
+ process.exit(0);
521
+ break;
522
+
523
+ case "version":
524
+ case "-v":
525
+ case "--version":
526
+ await cmdVersion();
527
+ process.exit(0);
528
+ break;
529
+
530
+ case "help":
531
+ case "-h":
532
+ case "--help":
533
+ printHelp();
534
+ process.exit(0);
535
+ break;
536
+
537
+ default:
538
+ console.error(`Unknown command: ${command}`);
539
+ console.error("Run 'restbase help' for usage.");
540
+ process.exit(1);
541
+ }