@xirconsss/zero-mock 1.0.0 → 1.2.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
@@ -1,37 +1,148 @@
1
- # Frontend Consumer Test
1
+ # zero-mock
2
2
 
3
- This branch tests `zero-mock` as an end-user using the published npm package.
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue?logo=typescript&logoColor=white)](tsconfig.json)
5
+ [![npm version](https://img.shields.io/npm/v/@xirconsss/zero-mock.svg)](https://www.npmjs.com/package/@xirconsss/zero-mock)
4
6
 
5
- ## How to run
7
+ **zero-mock** is a zero-config Node.js CLI that turns any JSON file into a persistent, production-grade REST API in seconds. It includes an interactive setup wizard, a high-contrast brutalist UI, and smart schema inference with automatic validation.
6
8
 
7
- 1. **Install the CLI globally (or run via npx):**
8
- ```bash
9
- npm install -g @xirconsss/zero-mock
10
- ```
9
+ Built for frontend developers and architects who need a realistic, stable backend for prototypes, demos, and integration tests without the overhead of database orchestration.
11
10
 
12
- 2. **Start the mock server:**
13
- ```bash
14
- npx @xirconsss/zero-mock -- --file ./data.json --port 3000
15
- ```
11
+ ## Key Features
16
12
 
17
- With simulated delay (2s) and watch, use **long flags** so `npx`/`npm` never treats `-d` as its own debug flag:
13
+ - **Zero-config** your JSON file is your database and API definition.
14
+ - **Interactive wizard** — run `zero-mock` to start the setup guide.
15
+ - **Smart validation** — automatically infers Zod schemas from your data to validate `POST`, `PUT`, and `PATCH` requests.
16
+ - **Atomic and secure** — cross-platform atomic writes (Windows-safe) with file-locking to prevent data corruption.
17
+ - **Advanced REST** — supports filtering (`_gte`, `_lte`, `_like`), sorting (`_sort`, `_order`), and pagination (`_page`, `_limit`).
18
+ - **Realistic simulation** — simulate network latency and configure custom CORS origins/methods.
19
+ - **Watch mode** — hot-reloads data from disk on manual file changes without a server restart.
20
+ - **Session memory** — remembers your last used configuration for a faster workflow.
18
21
 
19
- ```bash
20
- npx @xirconsss/zero-mock -- --file ./data.json --port 3000 --delay 2000 --watch
21
- ```
22
+ ---
22
23
 
23
- If you still see `required option '-f, --file' not specified`, use **`npm exec`** instead:
24
+ ## Installation
24
25
 
25
- ```bash
26
- npm exec -- @xirconsss/zero-mock -- --file ./data.json --port 3000 --delay 2000
27
- ```
26
+ ### Global
28
27
 
29
- Or install globally and avoid `npx` parsing entirely:
28
+ ```bash
29
+ npm install -g @xirconsss/zero-mock
30
+ ```
30
31
 
31
- ```bash
32
- zero-mock --file ./data.json --port 3000 --delay 2000
33
- ```
32
+ Run the CLI as `zero-mock`.
34
33
 
35
- 3. **Run the Frontend:**
36
- Simply open `index.html` in your browser.
34
+ ### One-off with npx
37
35
 
36
+ ```bash
37
+ npx @xirconsss/zero-mock
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Usage
43
+
44
+ ### 1. Interactive mode (recommended)
45
+
46
+ Run the command without arguments to launch the setup wizard:
47
+
48
+ ```bash
49
+ zero-mock
50
+ ```
51
+
52
+ ### 2. Manual CLI
53
+
54
+ Pass flags to skip the wizard:
55
+
56
+ ```bash
57
+ zero-mock -f ./data.json -p 8080 -w -d 200
58
+ ```
59
+
60
+ | Flag | Description |
61
+ | ---- | ----------- |
62
+ | `-f`, `--file` | Path to the source JSON file. |
63
+ | `-p`, `--port` | HTTP port (default `3000`, must be 1024–65535). |
64
+ | `-d`, `--delay` | Delay every request by X milliseconds (default `0`). |
65
+ | `-w`, `--watch` | Enable hot-reloading on manual file changes. |
66
+ | `--cors-origin` | Comma-separated allowed origins (default `*`). |
67
+ | `--reset` | Clear saved wizard configuration and exit. |
68
+
69
+ ---
70
+
71
+ ## Auto-generated API
72
+
73
+ The tool generates full CRUD endpoints for every top-level key in your JSON (e.g., `users`, `posts`).
74
+
75
+ | Method | Path | Description |
76
+ | ------ | ---- | ----------- |
77
+ | `GET` | `/{resource}` | List items (supports filtering, sorting, paging). |
78
+ | `POST` | `/{resource}` | Create item (auto-id + schema validation). |
79
+ | `GET` | `/{resource}/{id}` | Return a single item by `id`. |
80
+ | `PUT` | `/{resource}/{id}` | Replace item (schema validation). |
81
+ | `PATCH` | `/{resource}/{id}` | Partial update (schema validation). |
82
+ | `DELETE` | `/{resource}/{id}` | Remove item. |
83
+
84
+ ### Advanced List Features
85
+
86
+ **Filtering**
87
+
88
+ - `GET /posts?category=tech` — exact match
89
+ - `GET /posts?views_gte=100` — greater than or equal
90
+ - `GET /posts?title_like=hello` — case-insensitive search
91
+
92
+ **Sorting**
93
+
94
+ - `GET /posts?_sort=createdAt&_order=desc`
95
+
96
+ **Pagination**
97
+
98
+ - `GET /posts?_page=1&_limit=10`
99
+ - Returns an `X-Total-Count` header and a wrapped `{ data, pagination }` object.
100
+
101
+ ---
102
+
103
+ ## JSON Structure
104
+
105
+ The root must be an object, and every resource must be an array.
106
+
107
+ ```json
108
+ {
109
+ "users": [
110
+ { "id": 1, "name": "Alice", "role": "admin" },
111
+ { "id": 2, "name": "Bob" }
112
+ ]
113
+ }
114
+ ```
115
+
116
+ Note: zero-mock automatically infers that `role` is an optional string based on the items above.
117
+
118
+ ---
119
+
120
+ ## Development
121
+
122
+ 1. Clone the repo.
123
+ 2. `npm install`
124
+ 3. `npm run dev` (runs with `ts-node`)
125
+ 4. `npm run build` (compiles to `dist/`)
126
+
127
+ ---
128
+
129
+ ## Publishing (Maintainers)
130
+
131
+ This project uses `semantic-release` for fully automated versioning and npm publishing.
132
+
133
+ 1. Ensure your commits follow the [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat:`, `fix:`, `chore:`).
134
+ 2. Push or open a PR against the `main` branch.
135
+ 3. GitHub Actions handles testing, version bumping, changelog generation, and publishing to npm.
136
+
137
+ ---
138
+
139
+ ## License
140
+
141
+ MIT — see [LICENSE](LICENSE).
142
+
143
+ ---
144
+
145
+ ## Links
146
+
147
+ - **Repository:** [github.com/xircons/zero-mock](https://github.com/xircons/zero-mock)
148
+ - **Issues:** [github.com/xircons/zero-mock/issues](https://github.com/xircons/zero-mock/issues)
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PANEL_W = void 0;
7
+ exports.stripAnsi = stripAnsi;
8
+ exports.centerPad = centerPad;
9
+ exports.sectionHeader = sectionHeader;
10
+ exports.renderConfigPanel = renderConfigPanel;
11
+ exports.runWizard = runWizard;
12
+ const prompts_1 = require("@inquirer/prompts");
13
+ const picocolors_1 = __importDefault(require("picocolors"));
14
+ const config_store_1 = require("./config-store");
15
+ exports.PANEL_W = 42;
16
+ function stripAnsi(s) {
17
+ return s.replace(/\x1B\[[0-9;]*[mGKHF]/g, '');
18
+ }
19
+ function centerPad(text, width = 42) {
20
+ const stripped = stripAnsi(text);
21
+ const padding = Math.max(0, Math.floor((width - stripped.length) / 2));
22
+ return ' '.repeat(padding) + text;
23
+ }
24
+ function sectionHeader(title) {
25
+ const header = `${picocolors_1.default.blue('◆')} ${picocolors_1.default.bold(picocolors_1.default.white(title))}`;
26
+ const rule = picocolors_1.default.dim('─'.repeat(exports.PANEL_W));
27
+ return `${header}\n${rule}`;
28
+ }
29
+ function renderConfigPanel(config, saved) {
30
+ let out = picocolors_1.default.dim('╭' + '─'.repeat(exports.PANEL_W) + '╮\n');
31
+ const rows = [
32
+ { label: 'Target File', value: config.file },
33
+ { label: 'Default Port', value: config.port },
34
+ { label: 'Latency', value: `${config.delay}ms` },
35
+ { label: 'Watch Mode', value: config.watch ? 'Active' : 'Inactive' },
36
+ ];
37
+ for (const row of rows) {
38
+ const labelPadded = ` · ${row.label} `.padEnd(20, ' ');
39
+ const valRaw = row.value;
40
+ const spaceCount = Math.max(0, exports.PANEL_W - labelPadded.length - valRaw.length - 3);
41
+ const spaces = ' '.repeat(spaceCount);
42
+ out += picocolors_1.default.dim('│') + picocolors_1.default.dim(labelPadded) + picocolors_1.default.white(row.value) + spaces + ' ' + picocolors_1.default.green('✓') + ' ' + picocolors_1.default.dim('│') + '\n';
43
+ }
44
+ if (saved) {
45
+ out += picocolors_1.default.dim('├' + '─'.repeat(exports.PANEL_W) + '┤\n');
46
+ const footerText = `last used ${(0, config_store_1.formatSavedAt)(saved.savedAt)}`;
47
+ const footerPadded = ` · ${footerText} `.padEnd(exports.PANEL_W, ' ');
48
+ out += picocolors_1.default.dim('│') + picocolors_1.default.dim(footerPadded) + picocolors_1.default.dim('│') + '\n';
49
+ }
50
+ out += picocolors_1.default.dim('╰' + '─'.repeat(exports.PANEL_W) + '╯');
51
+ return out;
52
+ }
53
+ const hex = (hexCode, text) => {
54
+ const r = parseInt(hexCode.slice(1, 3), 16);
55
+ const g = parseInt(hexCode.slice(3, 5), 16);
56
+ const b = parseInt(hexCode.slice(5, 7), 16);
57
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
58
+ };
59
+ const FALLBACK = {
60
+ file: './example/db.json',
61
+ port: '8080',
62
+ delay: '200',
63
+ watch: true,
64
+ };
65
+ async function runWizard() {
66
+ console.clear();
67
+ const saved = (0, config_store_1.loadSavedConfig)();
68
+ const defaults = saved ?? FALLBACK;
69
+ const isReturning = saved !== null;
70
+ const logoTop = hex('#bfdbfe', ' ▗▄▄▄▄▖▗▄▄▄▖▗▄▄▖ ▗▄▖ ▗▖ ▗▖ ▗▄▖ ▗▄▄▖▗▖ ▗▖');
71
+ const logoMid1 = hex('#60a5fa', ' ▗▞▘▐▌ ▐▌ ▐▌▐▌ ▐▌▐▛▚▞▜▌▐▌ ▐▌▐▌ ▐▌▗▞▘');
72
+ const logoMid2 = hex('#2563eb', ' ▗▞▘ ▐▛▀▀▘▐▛▀▚▖▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▛▚▖ ');
73
+ const logoBottom = hex('#1e3a8a', ' ▐▙▄▄▄▖▐▙▄▄▖▐▌ ▐▌▝▚▄▞▘▐▌ ▐▌▝▚▄▞▘▝▚▄▄▖▐▌ ▐▌');
74
+ console.log(`\n${logoTop}\n${logoMid1}\n${logoMid2}\n${logoBottom}\n`);
75
+ console.log(centerPad(picocolors_1.default.bold(picocolors_1.default.white("Zero-Mock By Xircons"))));
76
+ console.log(centerPad(picocolors_1.default.dim("Zero-config REST API setup in seconds")));
77
+ if (isReturning && saved) {
78
+ console.log(centerPad(picocolors_1.default.dim(`last session ${(0, config_store_1.formatSavedAt)(saved.savedAt)}`)));
79
+ }
80
+ console.log('\n' + renderConfigPanel(defaults, saved) + '\n');
81
+ const promptTheme = { prefix: picocolors_1.default.blue('◆') };
82
+ const choice = await (0, prompts_1.select)({
83
+ message: 'What would you like to do?',
84
+ theme: promptTheme,
85
+ choices: [
86
+ { name: `${picocolors_1.default.green('▶')} Continue`, value: 'continue', short: 'Continue' },
87
+ { name: `${picocolors_1.default.yellow('◈')} Change configuration (pick manually)`, value: 'change', short: 'Change configuration' },
88
+ { name: `${picocolors_1.default.dim('✕')} Cancel (exit wizard)`, value: 'cancel', short: 'Cancel' },
89
+ ],
90
+ });
91
+ if (choice === 'cancel') {
92
+ console.clear();
93
+ console.log(picocolors_1.default.dim('Setup cancelled. zero-mock aborted.'));
94
+ process.exit(0);
95
+ }
96
+ if (choice === 'continue') {
97
+ console.clear();
98
+ (0, config_store_1.saveConfig)(defaults, saved);
99
+ return defaults;
100
+ }
101
+ // Change configuration
102
+ console.clear();
103
+ console.log('\n' + sectionHeader('CUSTOM CONFIGURATION'));
104
+ if (isReturning) {
105
+ console.log(picocolors_1.default.dim('Pre-filled from last session — press Enter to keep\n'));
106
+ }
107
+ const file = await (0, prompts_1.input)({
108
+ message: 'Target File Path:',
109
+ default: defaults.file,
110
+ theme: promptTheme,
111
+ validate: (value) => {
112
+ if (!value.trim())
113
+ return "File path cannot be empty.";
114
+ if (!value.endsWith('.json'))
115
+ return "File must be a JSON file (.json).";
116
+ return true;
117
+ }
118
+ });
119
+ const port = await (0, prompts_1.input)({
120
+ message: 'Port Number:',
121
+ default: defaults.port,
122
+ theme: promptTheme,
123
+ validate: (value) => {
124
+ const p = parseInt(value, 10);
125
+ if (isNaN(p) || p < 1 || p > 65535)
126
+ return "Port must be a number between 1 and 65535.";
127
+ return true;
128
+ }
129
+ });
130
+ const delay = await (0, prompts_1.input)({
131
+ message: 'Simulated Latency (ms):',
132
+ default: defaults.delay,
133
+ theme: promptTheme,
134
+ validate: (value) => {
135
+ const d = parseInt(value, 10);
136
+ if (isNaN(d) || d < 0)
137
+ return "Latency must be a non-negative integer.";
138
+ return true;
139
+ }
140
+ });
141
+ const watch = await (0, prompts_1.confirm)({
142
+ message: 'Enable Watch Mode (auto-reload on save)?',
143
+ default: defaults.watch,
144
+ theme: promptTheme,
145
+ });
146
+ const newConfig = { file, port, delay, watch };
147
+ (0, config_store_1.saveConfig)(newConfig, saved);
148
+ console.log(`\n ${picocolors_1.default.green('✓')} Configuration updated.`);
149
+ console.clear();
150
+ return newConfig;
151
+ }
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadSavedConfig = loadSavedConfig;
7
+ exports.saveConfig = saveConfig;
8
+ exports.clearSavedConfig = clearSavedConfig;
9
+ exports.formatSavedAt = formatSavedAt;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const os_1 = __importDefault(require("os"));
13
+ const picocolors_1 = __importDefault(require("picocolors"));
14
+ const getConfigPath = () => path_1.default.join(os_1.default.homedir(), '.config', 'zero-mock', 'config.json');
15
+ function loadSavedConfig() {
16
+ try {
17
+ const configPath = getConfigPath();
18
+ if (!fs_1.default.existsSync(configPath)) {
19
+ return null;
20
+ }
21
+ const raw = fs_1.default.readFileSync(configPath, 'utf8');
22
+ const parsed = JSON.parse(raw);
23
+ if (typeof parsed === 'object' &&
24
+ parsed !== null &&
25
+ typeof parsed.file === 'string' &&
26
+ typeof parsed.port === 'string' &&
27
+ typeof parsed.delay === 'string' &&
28
+ typeof parsed.watch === 'boolean') {
29
+ return parsed;
30
+ }
31
+ return null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function saveConfig(config, prev) {
38
+ try {
39
+ const configPath = getConfigPath();
40
+ const configDir = path_1.default.dirname(configPath);
41
+ fs_1.default.mkdirSync(configDir, { recursive: true });
42
+ const savedConfig = {
43
+ ...config,
44
+ savedAt: new Date().toISOString(),
45
+ useCount: Math.min((prev?.useCount || 0) + 1, 99999),
46
+ };
47
+ fs_1.default.writeFileSync(configPath, JSON.stringify(savedConfig, null, 2), 'utf8');
48
+ }
49
+ catch {
50
+ // Non-fatal, do nothing
51
+ }
52
+ }
53
+ function clearSavedConfig() {
54
+ try {
55
+ const configPath = getConfigPath();
56
+ if (fs_1.default.existsSync(configPath)) {
57
+ fs_1.default.unlinkSync(configPath);
58
+ console.log(`${picocolors_1.default.green('✓')} Cleared saved configuration.`);
59
+ }
60
+ else {
61
+ console.log(`${picocolors_1.default.dim('✕')} No saved configuration found.`);
62
+ }
63
+ }
64
+ catch (err) {
65
+ console.log(`${picocolors_1.default.red('✕')} Failed to clear config: ${err.message}`);
66
+ }
67
+ process.exit(0);
68
+ }
69
+ function formatSavedAt(iso) {
70
+ const date = new Date(iso);
71
+ const now = new Date();
72
+ const diffMs = now.getTime() - date.getTime();
73
+ const mins = Math.floor(diffMs / 60000);
74
+ if (mins < 2)
75
+ return "just now";
76
+ const hours = Math.floor(mins / 60);
77
+ if (hours < 1)
78
+ return `${mins}m ago`;
79
+ const days = Math.floor(hours / 24);
80
+ if (days < 1)
81
+ return `${hours}h ago`;
82
+ if (days < 7)
83
+ return `${days}d ago`;
84
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
85
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.printError = printError;
7
+ exports.printFatal = printFatal;
8
+ exports.validateConfig = validateConfig;
9
+ exports.acquireLock = acquireLock;
10
+ const picocolors_1 = __importDefault(require("picocolors"));
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const ERROR_MESSAGES = {
14
+ FILE_NOT_FOUND: { msg: 'Target JSON file not found.', hint: 'Check if the file path is correct.' },
15
+ FILE_NOT_JSON: { msg: 'Target file is not a JSON file.', hint: 'Ensure the file ends with .json.' },
16
+ FILE_UNREADABLE: { msg: 'Cannot read the target file.', hint: 'Check file permissions.' },
17
+ JSON_INVALID: { msg: 'Invalid JSON format.', hint: 'Check the file for syntax errors.' },
18
+ JSON_NOT_OBJECT: { msg: 'JSON root is not an object.', hint: 'The JSON file must have an object at its root.' },
19
+ JSON_EMPTY: { msg: 'JSON file is empty.', hint: 'The API will start with no resources. Add arrays to the root object.' },
20
+ PORT_IN_USE: { msg: 'Port is already in use.', hint: 'Choose a different port or kill the process using it.' },
21
+ PORT_INVALID: { msg: 'Invalid port number.', hint: 'Port must be an integer between 1024 and 65535.' },
22
+ PORT_PRIVILEGED: { msg: 'Port requires root privileges.', hint: 'Use a port number >= 1024.' },
23
+ LOCK_STALE: { msg: 'Found a stale lock file.', hint: 'The lock file will be automatically removed.' },
24
+ LOCK_CONFLICT: { msg: 'Another instance is running.', hint: 'Stop the other zero-mock instance or use a different file.' },
25
+ LOCK_WRITE_FAILED: { msg: 'Failed to write lock file.', hint: 'Check directory permissions.' },
26
+ WATCH_FAILED: { msg: 'File watcher failed to start.', hint: 'Hot-reloading will not work. Check OS file watch limits.' },
27
+ WATCH_RELOAD_FAILED: { msg: 'Failed to reload JSON file.', hint: 'Fix the JSON syntax error. Serving last good snapshot.' },
28
+ SERVER_CRASH: { msg: 'Server crashed unexpectedly.', hint: 'Check the error details below.' },
29
+ UNKNOWN: { msg: 'An unknown error occurred.', hint: 'Please report this issue.' },
30
+ };
31
+ function formatErrorBlock(type, code, detail) {
32
+ const badge = type === 'ERROR'
33
+ ? picocolors_1.default.red(picocolors_1.default.bold(picocolors_1.default.inverse(' ERROR ')))
34
+ : picocolors_1.default.yellow(picocolors_1.default.bold(picocolors_1.default.inverse(' WARN ')));
35
+ const { msg, hint } = ERROR_MESSAGES[code];
36
+ let out = `\n ${badge} ${picocolors_1.default.bold(picocolors_1.default.white(msg))}\n`;
37
+ if (detail) {
38
+ out += ` ${picocolors_1.default.dim('│')} ${picocolors_1.default.dim(detail)}\n`;
39
+ }
40
+ out += ` ${picocolors_1.default.dim('╰─')} ${picocolors_1.default.blue(hint)}\n`;
41
+ return out;
42
+ }
43
+ function printError(code, detail) {
44
+ console.error(formatErrorBlock('WARN', code, detail));
45
+ }
46
+ function printFatal(code, detail) {
47
+ console.error(formatErrorBlock('ERROR', code, detail));
48
+ process.exit(1);
49
+ }
50
+ function validateConfig(file, port) {
51
+ if (path_1.default.extname(file) !== '.json') {
52
+ printFatal('FILE_NOT_JSON', `Path: ${file}`);
53
+ }
54
+ if (!fs_1.default.existsSync(file)) {
55
+ printFatal('FILE_NOT_FOUND', `Path: ${file}`);
56
+ }
57
+ try {
58
+ fs_1.default.accessSync(file, fs_1.default.constants.R_OK);
59
+ }
60
+ catch (e) {
61
+ printFatal('FILE_UNREADABLE', e.message);
62
+ }
63
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
64
+ printFatal('PORT_INVALID', `Given: ${port}`);
65
+ }
66
+ if (port < 1024) {
67
+ printFatal('PORT_PRIVILEGED', `Given: ${port}`);
68
+ }
69
+ }
70
+ function acquireLock(file) {
71
+ const lockFilePath = `${file}.zero-mock.lock`;
72
+ if (fs_1.default.existsSync(lockFilePath)) {
73
+ try {
74
+ const lockData = fs_1.default.readFileSync(lockFilePath, 'utf8');
75
+ const { pid, startedAt } = JSON.parse(lockData);
76
+ try {
77
+ process.kill(pid, 0); // test liveness
78
+ // Alive
79
+ printFatal('LOCK_CONFLICT', `PID ${pid} since ${startedAt}`);
80
+ }
81
+ catch (e) {
82
+ if (e.code === 'ESRCH') {
83
+ // Stale
84
+ printError('LOCK_STALE', `PID ${pid} is dead.`);
85
+ fs_1.default.unlinkSync(lockFilePath);
86
+ }
87
+ else {
88
+ // No permission or other error
89
+ printFatal('LOCK_CONFLICT', `Unable to check PID ${pid}.`);
90
+ }
91
+ }
92
+ }
93
+ catch (e) {
94
+ // Unreadable or invalid lock file format
95
+ printError('LOCK_STALE', `Invalid lock file format.`);
96
+ fs_1.default.unlinkSync(lockFilePath);
97
+ }
98
+ }
99
+ try {
100
+ fs_1.default.writeFileSync(lockFilePath, JSON.stringify({
101
+ pid: process.pid,
102
+ startedAt: new Date().toISOString()
103
+ }), { flag: 'wx' });
104
+ }
105
+ catch (e) {
106
+ if (e.code === 'EEXIST') {
107
+ printFatal('LOCK_CONFLICT', 'Lock file appeared during race condition.');
108
+ }
109
+ else {
110
+ printFatal('LOCK_WRITE_FAILED', e.message);
111
+ }
112
+ }
113
+ const cleanup = () => {
114
+ try {
115
+ if (fs_1.default.existsSync(lockFilePath)) {
116
+ fs_1.default.unlinkSync(lockFilePath);
117
+ }
118
+ }
119
+ catch (_) { /* ignore cleanup errors */ }
120
+ };
121
+ process.on('exit', cleanup);
122
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
123
+ process.on('SIGTERM', () => { cleanup(); process.exit(143); });
124
+ process.on('uncaughtException', (err) => {
125
+ cleanup();
126
+ printFatal('SERVER_CRASH', err.message);
127
+ });
128
+ }
package/dist/index.js CHANGED
@@ -1,10 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
3
39
  Object.defineProperty(exports, "__esModule", { value: true });
4
- const fs_1 = require("fs");
40
+ const chokidar = __importStar(require("chokidar"));
41
+ const picocolors_1 = __importDefault(require("picocolors"));
5
42
  const commander_1 = require("commander");
6
43
  const jsonStore_1 = require("./store/jsonStore");
7
44
  const bootstrap_1 = require("./server/bootstrap");
45
+ const cli_wizard_1 = require("./cli-wizard");
46
+ const errors_1 = require("./errors");
47
+ const config_store_1 = require("./config-store");
8
48
  function parseDelayMs(raw) {
9
49
  const trimmed = raw.trim();
10
50
  if (!/^\d+$/.test(trimmed)) {
@@ -12,77 +52,114 @@ function parseDelayMs(raw) {
12
52
  }
13
53
  return Number.parseInt(trimmed, 10);
14
54
  }
55
+ const program = new commander_1.Command();
56
+ let watcher = null;
15
57
  function startFileWatcher(filePath) {
16
- let reloadInFlight = false;
58
+ let reloadTimeout = null;
59
+ if (watcher) {
60
+ watcher.close().catch(() => { });
61
+ watcher = null;
62
+ }
17
63
  const reload = async () => {
18
- if (reloadInFlight) {
19
- return;
20
- }
21
- reloadInFlight = true;
22
64
  try {
23
- await jsonStore_1.JsonStore.load(filePath);
65
+ await jsonStore_1.JsonStore.load(filePath, true);
66
+ console.log(`\n ${picocolors_1.default.dim('│')} ${picocolors_1.default.green('✓')} ${picocolors_1.default.dim('Hot reloaded data from')} ${picocolors_1.default.white(filePath)}`);
24
67
  }
25
68
  catch (err) {
26
- const message = err instanceof Error ? err.message : String(err);
27
- console.error(`[watch] Could not reload "${filePath}": ${message}`);
28
- }
29
- finally {
30
- reloadInFlight = false;
69
+ (0, errors_1.printError)('WATCH_RELOAD_FAILED', err.message);
31
70
  }
32
71
  };
33
72
  try {
34
- (0, fs_1.watch)(filePath, { persistent: true }, () => {
35
- void reload();
73
+ watcher = chokidar.watch(filePath, { persistent: true, ignoreInitial: true });
74
+ watcher
75
+ .on("change", () => {
76
+ if (reloadTimeout)
77
+ clearTimeout(reloadTimeout);
78
+ reloadTimeout = setTimeout(() => void reload(), 100);
79
+ })
80
+ .on("error", (err) => {
81
+ const msg = err instanceof Error ? err.message : String(err);
82
+ (0, errors_1.printError)('WATCH_FAILED', msg);
36
83
  });
37
- console.log(`[watch] Watching "${filePath}" for changes.`);
38
84
  }
39
85
  catch (err) {
40
- const message = err instanceof Error ? err.message : String(err);
41
- console.error(`[watch] Failed to watch "${filePath}": ${message}`);
86
+ (0, errors_1.printError)('WATCH_FAILED', err.message);
42
87
  }
43
88
  }
44
- const program = new commander_1.Command();
45
89
  program
46
90
  .name("zero-mock")
47
91
  .description("Generate a REST API from a JSON file")
48
- .requiredOption("-f, --file <path>", "path to the source JSON file")
49
- .option("-p, --port <number>", "HTTP port", "3000")
50
- .option("-d, --delay <ms>", "delay each request by this many ms (0 = off)", "0")
51
- .option("-w, --watch", "reload JSON from disk when the file changes", false)
92
+ .option("-f, --file <path>", "path to the source JSON file")
93
+ .option("-p, --port <number>", "HTTP port")
94
+ .option("-d, --delay <ms>", "delay each request by this many ms (0 = off)")
95
+ .option("-w, --watch", "reload JSON from disk when the file changes")
96
+ .option("--cors-origin <origins>", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*")
97
+ .option("--cors-methods <methods>", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
98
+ .option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false)
99
+ .option("--reset", "clear saved wizard configuration")
52
100
  .action(async (opts) => {
53
- const port = Number.parseInt(opts.port, 10);
54
- if (Number.isNaN(port) || port < 1 || port > 65535) {
55
- console.error("error: --port must be a number between 1 and 65535");
56
- process.exit(1);
57
- return;
101
+ if (process.argv.includes('--reset')) {
102
+ (0, config_store_1.clearSavedConfig)();
103
+ process.exit(0);
58
104
  }
59
- const delayMs = parseDelayMs(opts.delay);
105
+ let { file, port: portRaw, delay: delayRaw, watch } = opts;
106
+ if (!file && !portRaw && !delayRaw && watch === undefined) {
107
+ const wizardConfig = await (0, cli_wizard_1.runWizard)();
108
+ file = wizardConfig.file;
109
+ portRaw = wizardConfig.port;
110
+ delayRaw = wizardConfig.delay;
111
+ watch = wizardConfig.watch;
112
+ }
113
+ else {
114
+ if (!file) {
115
+ console.error("error: required option '-f, --file <path>' not specified");
116
+ process.exit(1);
117
+ return;
118
+ }
119
+ portRaw = portRaw ?? "3000";
120
+ delayRaw = delayRaw ?? "0";
121
+ watch = watch ?? false;
122
+ }
123
+ const port = Number.parseInt(portRaw, 10);
124
+ const delayMs = parseDelayMs(delayRaw);
60
125
  if (delayMs === null) {
61
126
  console.error("error: --delay must be a non-negative integer");
62
127
  process.exit(1);
63
128
  return;
64
129
  }
65
- const filePath = opts.file;
130
+ const filePath = file;
131
+ // Ordered Validation Step 1 & 2
132
+ (0, errors_1.validateConfig)(filePath, port);
133
+ (0, errors_1.acquireLock)(filePath);
66
134
  try {
67
135
  await jsonStore_1.JsonStore.load(filePath);
68
136
  }
69
137
  catch (err) {
70
- const message = err instanceof Error ? err.message : String(err);
71
- console.error(message);
72
- process.exit(1);
73
- return;
138
+ // JsonStore.load handles its own printFatal internally,
139
+ // but we catch to ensure we don't crash without formatting.
140
+ (0, errors_1.printFatal)('UNKNOWN', err.message);
74
141
  }
75
142
  try {
76
- await (0, bootstrap_1.bootstrap)(port, { delayMs });
143
+ await (0, bootstrap_1.bootstrap)(port, filePath, watch, {
144
+ delayMs,
145
+ corsOrigin: opts.corsOrigin,
146
+ corsMethods: opts.corsMethods,
147
+ corsCredentials: opts.corsCredentials,
148
+ });
77
149
  }
78
150
  catch (err) {
79
- const message = err instanceof Error ? err.message : String(err);
80
- console.error(`Failed to start server: ${message}`);
81
- process.exit(1);
82
- return;
151
+ // Bootstrap will handle EADDRINUSE internally, catching just in case.
152
+ (0, errors_1.printFatal)('UNKNOWN', err.message);
83
153
  }
84
- if (opts.watch) {
154
+ if (watch) {
85
155
  startFileWatcher(filePath);
86
156
  }
157
+ const cleanup = () => {
158
+ if (watcher) {
159
+ watcher.close().catch(() => { });
160
+ }
161
+ };
162
+ process.on('SIGINT', cleanup);
163
+ process.on('SIGTERM', cleanup);
87
164
  });
88
165
  program.parse(process.argv);
@@ -7,19 +7,14 @@ exports.createApp = createApp;
7
7
  const cors_1 = __importDefault(require("cors"));
8
8
  const express_1 = __importDefault(require("express"));
9
9
  const dynamicRouter_1 = require("./routes/dynamicRouter");
10
+ const logger_1 = require("./logger");
10
11
  const errorHandler = (err, _req, res, _next) => {
11
12
  if (res.headersSent) {
12
13
  return;
13
14
  }
14
15
  const message = err instanceof Error ? err.message : String(err);
15
- res.status(500).json({ error: message });
16
+ res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message, statusCode: 500 });
16
17
  };
17
- function requestLoggingMiddleware(req, res, next) {
18
- res.on("finish", () => {
19
- console.log(`[${req.method}] ${req.path} - ${res.statusCode}`);
20
- });
21
- next();
22
- }
23
18
  function delayMiddleware(delayMs) {
24
19
  return (_req, _res, next) => {
25
20
  if (delayMs <= 0) {
@@ -31,9 +26,13 @@ function delayMiddleware(delayMs) {
31
26
  }
32
27
  function createApp(options) {
33
28
  const app = (0, express_1.default)();
34
- app.use((0, cors_1.default)());
29
+ app.use((0, cors_1.default)({
30
+ origin: options.corsOrigin && options.corsOrigin !== "*" ? options.corsOrigin.split(",") : "*",
31
+ methods: options.corsMethods || "GET,HEAD,PUT,PATCH,POST,DELETE",
32
+ credentials: options.corsCredentials || false,
33
+ }));
35
34
  app.use(express_1.default.json());
36
- app.use(requestLoggingMiddleware);
35
+ app.use(logger_1.requestLoggingMiddleware);
37
36
  app.use(delayMiddleware(options.delayMs));
38
37
  app.use("/", (0, dynamicRouter_1.buildDynamicRouter)());
39
38
  app.use(errorHandler);
@@ -3,20 +3,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.bootstrap = bootstrap;
4
4
  const app_1 = require("./app");
5
5
  const jsonStore_1 = require("../store/jsonStore");
6
- async function bootstrap(port, appOptions) {
6
+ const logger_1 = require("./logger");
7
+ const errors_1 = require("../errors");
8
+ async function bootstrap(port, file, watch, appOptions) {
7
9
  const app = (0, app_1.createApp)(appOptions);
8
10
  await new Promise((resolve, reject) => {
9
11
  const server = app.listen(port, () => {
10
12
  resolve();
11
13
  });
12
- server.on("error", reject);
14
+ server.on("error", (e) => {
15
+ if (e.code === 'EADDRINUSE') {
16
+ (0, errors_1.printFatal)('PORT_IN_USE', `Port ${port} — ${e.message}`);
17
+ }
18
+ else {
19
+ (0, errors_1.printFatal)('UNKNOWN', e.message);
20
+ }
21
+ reject(e);
22
+ });
13
23
  });
14
- const base = `http://localhost:${port}`;
15
- console.log(`🚀 Server is running at ${base}`);
16
- console.log("Read/write API: GET, POST, PUT, PATCH, DELETE are active for each resource below.\n");
17
- for (const resource of Object.keys(jsonStore_1.JsonStore.getData())) {
18
- console.log(` ${resource}`);
19
- console.log(` GET POST ${base}/${resource}`);
20
- console.log(` GET PUT PATCH DELETE ${base}/${resource}/:id`);
21
- }
24
+ const resources = Object.keys(jsonStore_1.JsonStore.getData());
25
+ (0, logger_1.printStartupBanner)(file, port, watch, appOptions.delayMs, resources);
22
26
  }
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getMethodBadge = getMethodBadge;
7
+ exports.requestLoggingMiddleware = requestLoggingMiddleware;
8
+ exports.getMonochromeBadge = getMonochromeBadge;
9
+ exports.printStartupBanner = printStartupBanner;
10
+ const picocolors_1 = __importDefault(require("picocolors"));
11
+ const cli_wizard_1 = require("../cli-wizard");
12
+ function getMethodBadge(method) {
13
+ const m = method.toUpperCase();
14
+ const padded = m.padEnd(6, ' ');
15
+ const inner = `[${padded}]`;
16
+ switch (m) {
17
+ case 'GET': return picocolors_1.default.bold(picocolors_1.default.cyan(inner));
18
+ case 'POST': return picocolors_1.default.bold(picocolors_1.default.green(inner));
19
+ case 'PUT': return picocolors_1.default.bold(picocolors_1.default.yellow(inner));
20
+ case 'PATCH': return picocolors_1.default.bold(picocolors_1.default.magenta(inner));
21
+ case 'DELETE': return picocolors_1.default.bold(picocolors_1.default.red(inner));
22
+ default: return picocolors_1.default.bold(picocolors_1.default.white(inner));
23
+ }
24
+ }
25
+ function requestLoggingMiddleware(req, res, next) {
26
+ const startMs = Date.now();
27
+ const start = new Date();
28
+ res.on("finish", () => {
29
+ const timeStr = start.toLocaleTimeString('en-US', { hour12: true });
30
+ const duration = Date.now() - startMs;
31
+ const badge = getMethodBadge(req.method);
32
+ let statusStr = String(res.statusCode);
33
+ if (res.statusCode >= 200 && res.statusCode < 300)
34
+ statusStr = picocolors_1.default.green(statusStr);
35
+ else if (res.statusCode >= 300 && res.statusCode < 400)
36
+ statusStr = picocolors_1.default.cyan(statusStr);
37
+ else if (res.statusCode >= 400 && res.statusCode < 500)
38
+ statusStr = picocolors_1.default.yellow(statusStr);
39
+ else if (res.statusCode >= 500)
40
+ statusStr = picocolors_1.default.red(statusStr);
41
+ const pathPadded = req.path.length > 20 ? req.path : req.path.padEnd(20, ' ');
42
+ console.log(`${picocolors_1.default.dim(`[${timeStr}]`)} ${badge} ${picocolors_1.default.white(pathPadded)} ${statusStr} ${picocolors_1.default.dim(`${duration}ms`)}`);
43
+ });
44
+ next();
45
+ }
46
+ function getMonochromeBadge(method) {
47
+ const m = method.toUpperCase();
48
+ const padded = m.padEnd(6, ' ');
49
+ return `[${padded}]`;
50
+ }
51
+ function printStartupBanner(file, port, isWatch, delayMs, resources) {
52
+ console.log('\n' + (0, cli_wizard_1.sectionHeader)('SERVER READY'));
53
+ console.log((0, cli_wizard_1.renderConfigPanel)({
54
+ file,
55
+ port: String(port),
56
+ delay: String(delayMs),
57
+ watch: isWatch
58
+ }) + '\n');
59
+ console.log((0, cli_wizard_1.sectionHeader)('AVAILABLE RESOURCES'));
60
+ const base = `http://localhost:${port}`;
61
+ const get = getMonochromeBadge('GET');
62
+ const post = getMonochromeBadge('POST');
63
+ const put = getMonochromeBadge('PUT');
64
+ const patch = getMonochromeBadge('PATCH');
65
+ const del = getMonochromeBadge('DELETE');
66
+ for (const resource of resources) {
67
+ console.log(picocolors_1.default.bold(picocolors_1.default.white(`■ /${resource}`)));
68
+ const methodsLeft1 = ` ${get} ${post}`;
69
+ const methodsLeft2 = ` ${get} ${put} ${patch} ${del}`;
70
+ // Align URLs at column 45
71
+ const pad1 = ' '.repeat(Math.max(1, 45 - (0, cli_wizard_1.stripAnsi)(methodsLeft1).length));
72
+ const pad2 = ' '.repeat(Math.max(1, 45 - (0, cli_wizard_1.stripAnsi)(methodsLeft2).length));
73
+ console.log(`${methodsLeft1}${pad1}${picocolors_1.default.white(`${base}/${resource}`)}`);
74
+ console.log(`${methodsLeft2}${pad2}${picocolors_1.default.white(`${base}/${resource}/:id`)}\n`);
75
+ }
76
+ console.log((0, cli_wizard_1.sectionHeader)('REQUEST LOGS'));
77
+ console.log(`${picocolors_1.default.green('✓')} ${picocolors_1.default.dim('Ready on')} ${picocolors_1.default.white(`${base}`)}\n`);
78
+ }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.buildDynamicRouter = buildDynamicRouter;
7
7
  const crypto_1 = require("crypto");
8
8
  const express_1 = __importDefault(require("express"));
9
+ const zod_1 = require("zod");
9
10
  const jsonStore_1 = require("../../store/jsonStore");
10
11
  function isPlainObject(value) {
11
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -44,27 +45,74 @@ function parsePagination(query) {
44
45
  return { page, limit };
45
46
  }
46
47
  function filterCollection(items, query) {
47
- const filterEntries = Object.entries(query).filter(([k]) => k !== "_page" && k !== "_limit");
48
+ let result = items;
49
+ // Sorting
50
+ const sort = firstQueryValue(query["_sort"]);
51
+ const order = firstQueryValue(query["_order"])?.toLowerCase() === "desc" ? -1 : 1;
52
+ if (sort) {
53
+ result = [...result].sort((a, b) => {
54
+ if (!isPlainObject(a) || !isPlainObject(b))
55
+ return 0;
56
+ const valA = a[sort];
57
+ const valB = b[sort];
58
+ if (valA === valB)
59
+ return 0;
60
+ if (valA === undefined)
61
+ return order;
62
+ if (valB === undefined)
63
+ return -order;
64
+ if (typeof valA === "number" && typeof valB === "number")
65
+ return (valA - valB) * order;
66
+ return String(valA).localeCompare(String(valB)) * order;
67
+ });
68
+ }
69
+ const filterEntries = Object.entries(query).filter(([k]) => !["_page", "_limit", "_sort", "_order"].includes(k));
48
70
  if (filterEntries.length === 0) {
49
- return items;
71
+ return result;
50
72
  }
51
- return items.filter((item) => {
73
+ return result.filter((item) => {
52
74
  if (!isPlainObject(item)) {
53
75
  return false;
54
76
  }
55
77
  const rec = item;
56
78
  for (const [key, qVal] of filterEntries) {
57
79
  const want = firstQueryValue(qVal);
58
- if (want === undefined) {
80
+ if (want === undefined)
59
81
  continue;
82
+ let field = key;
83
+ let op = "eq";
84
+ if (key.endsWith("_gte")) {
85
+ field = key.slice(0, -4);
86
+ op = "gte";
60
87
  }
61
- if (!Object.prototype.hasOwnProperty.call(rec, key)) {
62
- return false;
88
+ else if (key.endsWith("_lte")) {
89
+ field = key.slice(0, -4);
90
+ op = "lte";
63
91
  }
64
- if (rec[key] == want) {
65
- continue;
92
+ else if (key.endsWith("_like")) {
93
+ field = key.slice(0, -5);
94
+ op = "like";
95
+ }
96
+ const actual = rec[field];
97
+ if (op === "eq") {
98
+ if (actual != want)
99
+ return false;
100
+ }
101
+ else if (op === "gte") {
102
+ if (Number(actual) < Number(want))
103
+ return false;
104
+ }
105
+ else if (op === "lte") {
106
+ if (Number(actual) > Number(want))
107
+ return false;
108
+ }
109
+ else if (op === "like") {
110
+ if (actual === undefined || actual === null)
111
+ return false;
112
+ const escaped = want.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ if (!new RegExp(escaped, 'i').test(String(actual)))
114
+ return false;
66
115
  }
67
- return false;
68
116
  }
69
117
  return true;
70
118
  });
@@ -98,6 +146,43 @@ function itemNotFound(res, resource, id) {
98
146
  function badBody(res) {
99
147
  res.status(400).json({ error: "Request body must be a JSON object." });
100
148
  }
149
+ /**
150
+ * Infer a zod schema from the first item in a collection.
151
+ * Returns null if the collection is empty (skip validation).
152
+ * The schema is "partial" so that POST/PATCH with missing optional fields still pass —
153
+ * only fields that ARE present are type-checked.
154
+ */
155
+ function inferSchema(collection) {
156
+ const first = collection.find(isPlainObject);
157
+ if (!first)
158
+ return null;
159
+ const shape = {};
160
+ for (const [key, value] of Object.entries(first)) {
161
+ if (key === "id")
162
+ continue;
163
+ if (typeof value === "string")
164
+ shape[key] = zod_1.z.string();
165
+ else if (typeof value === "number")
166
+ shape[key] = zod_1.z.number();
167
+ else if (typeof value === "boolean")
168
+ shape[key] = zod_1.z.boolean();
169
+ else
170
+ shape[key] = zod_1.z.unknown();
171
+ }
172
+ return zod_1.z.object(shape).partial();
173
+ }
174
+ function validateBody(res, collection, body) {
175
+ const schema = inferSchema(collection);
176
+ if (!schema)
177
+ return true; // empty collection → skip
178
+ const result = schema.safeParse(body);
179
+ if (!result.success) {
180
+ const messages = result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`);
181
+ res.status(400).json({ error: "Validation failed.", details: messages });
182
+ return false;
183
+ }
184
+ return true;
185
+ }
101
186
  function numericIdValue(id) {
102
187
  if (typeof id === "number" && Number.isFinite(id) && Number.isInteger(id)) {
103
188
  return id;
@@ -151,10 +236,23 @@ function buildDynamicRouter() {
151
236
  const collection = jsonStore_1.JsonStore.getData()[resource];
152
237
  const query = req.query;
153
238
  let rows = filterCollection(collection, query);
239
+ const total = rows.length;
240
+ res.setHeader("X-Total-Count", total);
154
241
  const pageInfo = parsePagination(query);
155
242
  if (pageInfo !== null) {
156
243
  const start = (pageInfo.page - 1) * pageInfo.limit;
157
244
  rows = rows.slice(start, start + pageInfo.limit);
245
+ const totalPages = Math.ceil(total / pageInfo.limit);
246
+ res.json({
247
+ data: rows,
248
+ pagination: {
249
+ total,
250
+ page: pageInfo.page,
251
+ limit: pageInfo.limit,
252
+ totalPages
253
+ }
254
+ });
255
+ return;
158
256
  }
159
257
  res.json(rows);
160
258
  });
@@ -164,6 +262,8 @@ function buildDynamicRouter() {
164
262
  return;
165
263
  }
166
264
  const collection = jsonStore_1.JsonStore.getData()[resource];
265
+ if (!validateBody(res, collection, req.body))
266
+ return;
167
267
  const newId = nextIdForCollection(collection);
168
268
  const rawBody = req.body;
169
269
  const rest = { ...rawBody };
@@ -180,6 +280,8 @@ function buildDynamicRouter() {
180
280
  }
181
281
  const { id: idParam } = req.params;
182
282
  const collection = jsonStore_1.JsonStore.getData()[resource];
283
+ if (!validateBody(res, collection, req.body))
284
+ return;
183
285
  const index = findIndexById(collection, idParam);
184
286
  if (index === -1) {
185
287
  itemNotFound(res, resource, idParam);
@@ -202,6 +304,8 @@ function buildDynamicRouter() {
202
304
  }
203
305
  const { id: idParam } = req.params;
204
306
  const collection = jsonStore_1.JsonStore.getData()[resource];
307
+ if (!validateBody(res, collection, req.body))
308
+ return;
205
309
  const index = findIndexById(collection, idParam);
206
310
  if (index === -1) {
207
311
  itemNotFound(res, resource, idParam);
@@ -1,51 +1,62 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.JsonStore = void 0;
4
7
  const crypto_1 = require("crypto");
5
8
  const path_1 = require("path");
9
+ const fs_1 = __importDefault(require("fs"));
6
10
  const promises_1 = require("fs/promises");
11
+ const errors_1 = require("../errors");
7
12
  function isPlainObject(value) {
8
13
  return typeof value === "object" && value !== null && !Array.isArray(value);
9
14
  }
10
- function parseJsonStorePayload(raw, filePath) {
11
- let parsed;
12
- try {
13
- parsed = JSON.parse(raw);
14
- }
15
- catch (err) {
16
- const message = err instanceof Error ? err.message : String(err);
17
- throw new Error(`Invalid JSON in "${filePath}": ${message}`);
18
- }
19
- if (!isPlainObject(parsed)) {
20
- throw new Error(`JSON root in "${filePath}" must be a non-null object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}).`);
21
- }
22
- const data = {};
23
- for (const [key, value] of Object.entries(parsed)) {
24
- if (!Array.isArray(value)) {
25
- throw new Error(`Invalid mock database shape in "${filePath}": property "${key}" must be an array.`);
26
- }
27
- data[key] = value;
28
- }
29
- return data;
30
- }
31
15
  class JsonStoreImpl {
32
16
  data = {};
33
17
  backingPath = null;
34
18
  /** Serializes concurrent save() calls so writes are not interleaved. */
35
19
  saveChain = Promise.resolve();
36
- async load(filePath) {
20
+ async load(filePath, isReload = false) {
37
21
  let raw;
38
22
  try {
39
23
  raw = await (0, promises_1.readFile)(filePath, "utf8");
40
24
  }
41
25
  catch (err) {
42
- const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
43
- const message = err instanceof Error ? err.message : String(err);
44
- throw new Error(code
45
- ? `Could not read JSON file "${filePath}" (${code}): ${message}`
46
- : `Could not read JSON file "${filePath}": ${message}`);
26
+ if (isReload)
27
+ throw new Error(`Unreadable: ${err.message}`);
28
+ (0, errors_1.printFatal)('FILE_UNREADABLE', err.message);
29
+ }
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(raw);
33
+ }
34
+ catch (err) {
35
+ if (isReload)
36
+ throw new Error(`Parse error: ${err.message}`);
37
+ (0, errors_1.printFatal)('JSON_INVALID', err.message);
38
+ }
39
+ if (!isPlainObject(parsed)) {
40
+ const msg = `got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}`;
41
+ if (isReload)
42
+ throw new Error(`Root not an object (${msg})`);
43
+ (0, errors_1.printFatal)('JSON_NOT_OBJECT', msg);
44
+ }
45
+ const keys = Object.keys(parsed);
46
+ if (keys.length === 0) {
47
+ (0, errors_1.printError)('JSON_EMPTY');
48
+ }
49
+ const data = {};
50
+ for (const [key, value] of Object.entries(parsed)) {
51
+ if (!Array.isArray(value)) {
52
+ const msg = `property "${key}" must be an array.`;
53
+ if (isReload)
54
+ throw new Error(msg);
55
+ (0, errors_1.printFatal)('JSON_INVALID', msg);
56
+ }
57
+ data[key] = value;
47
58
  }
48
- this.data = parseJsonStorePayload(raw, filePath);
59
+ this.data = data;
49
60
  this.backingPath = filePath;
50
61
  }
51
62
  getData() {
@@ -66,10 +77,22 @@ class JsonStoreImpl {
66
77
  const payload = `${JSON.stringify(this.data, null, 2)}\n`;
67
78
  try {
68
79
  await (0, promises_1.writeFile)(tmp, payload, "utf8");
69
- await (0, promises_1.rename)(tmp, target);
80
+ try {
81
+ fs_1.default.renameSync(tmp, target);
82
+ }
83
+ catch (e) {
84
+ if (e.code === 'EXDEV') {
85
+ fs_1.default.copyFileSync(tmp, target);
86
+ fs_1.default.unlinkSync(tmp);
87
+ }
88
+ else {
89
+ throw e;
90
+ }
91
+ }
70
92
  }
71
93
  catch (err) {
72
- await (0, promises_1.unlink)(tmp).catch(() => { });
94
+ if (fs_1.default.existsSync(tmp))
95
+ fs_1.default.unlinkSync(tmp);
73
96
  const message = err instanceof Error ? err.message : String(err);
74
97
  throw new Error(`Could not write JSON file "${target}": ${message}`);
75
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xirconsss/zero-mock",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Zero-config CLI that generates REST APIs from JSON files",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -47,9 +47,14 @@
47
47
  },
48
48
  "homepage": "https://github.com/xircons/zero-mock#readme",
49
49
  "dependencies": {
50
+ "@inquirer/prompts": "^8.5.2",
51
+ "chokidar": "^4.0.3",
50
52
  "commander": "^13.1.0",
51
53
  "cors": "^2.8.5",
52
- "express": "^4.21.2"
54
+ "express": "^4.21.2",
55
+ "gradient-string": "^3.0.0",
56
+ "picocolors": "^1.1.1",
57
+ "zod": "^4.4.3"
53
58
  },
54
59
  "devDependencies": {
55
60
  "@commitlint/cli": "^21.0.2",
@@ -62,6 +67,7 @@
62
67
  "@semantic-release/release-notes-generator": "^14.1.1",
63
68
  "@types/cors": "^2.8.17",
64
69
  "@types/express": "^4.17.21",
70
+ "@types/gradient-string": "^1.1.6",
65
71
  "@types/node": "^22.14.0",
66
72
  "@vitest/coverage-v8": "^2.1.9",
67
73
  "husky": "^9.1.7",
package/example/db.json DELETED
@@ -1,45 +0,0 @@
1
- {
2
- "users": [
3
- {
4
- "id": 1,
5
- "name": "Ada Lovelace",
6
- "email": "ada@example.com",
7
- "role": "admin"
8
- },
9
- {
10
- "id": 2,
11
- "name": "Grace Hopper",
12
- "email": "grace@example.com",
13
- "role": "editor"
14
- },
15
- {
16
- "id": 3,
17
- "name": "Margaret Hamilton",
18
- "email": "margaret@example.com",
19
- "role": "viewer"
20
- }
21
- ],
22
- "posts": [
23
- {
24
- "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
25
- "title": "Shipping a mock API in minutes",
26
- "body": "Point zero-mock at a JSON file and iterate on your UI without waiting on backend tickets.",
27
- "userId": 1,
28
- "published": true
29
- },
30
- {
31
- "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
32
- "title": "Why atomic writes matter",
33
- "body": "Your JSON file stays the source of truth—even when multiple tabs hit the API at once.",
34
- "userId": 2,
35
- "published": false
36
- },
37
- {
38
- "id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f",
39
- "title": "Frontend-first workflows",
40
- "body": "Prototype with realistic CRUD, then swap the mock for a production API when you are ready.",
41
- "userId": 1,
42
- "published": true
43
- }
44
- ]
45
- }