@xirconsss/zero-mock 1.1.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
@@ -4,11 +4,22 @@
4
4
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue?logo=typescript&logoColor=white)](tsconfig.json)
5
5
  [![npm version](https://img.shields.io/npm/v/@xirconsss/zero-mock.svg)](https://www.npmjs.com/package/@xirconsss/zero-mock)
6
6
 
7
- **zero-mock** is a zero-config Node.js CLI that turns a JSON file into a local REST API. Point it at a file whose top-level keys are **collection names** and whose values are **arrays of records**—it serves full CRUD routes and writes changes back to disk. Built for **frontend developers** who need a quick, realistic backend for prototypes, demos, and integration tests without standing up a database or bespoke server.
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.
8
8
 
9
- ## Demo
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.
10
10
 
11
- ![Terminal demo](docs/demo.gif)
11
+ ## Key Features
12
+
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.
21
+
22
+ ---
12
23
 
13
24
  ## Installation
14
25
 
@@ -18,127 +29,118 @@
18
29
  npm install -g @xirconsss/zero-mock
19
30
  ```
20
31
 
21
- Run the CLI as **`zero-mock`** (see [Usage](#usage)).
32
+ Run the CLI as `zero-mock`.
22
33
 
23
34
  ### One-off with npx
24
35
 
25
36
  ```bash
26
- npx @xirconsss/zero-mock -f ./example/db.json -p 3000
37
+ npx @xirconsss/zero-mock
27
38
  ```
28
39
 
29
- If your shell or npm version forwards extra flags to npm instead of the CLI, insert **`--`** before the first CLI flag (e.g. `npx @xirconsss/zero-mock -- -f ./data.json -p 3000 -w`).
40
+ ---
30
41
 
31
42
  ## Usage
32
43
 
33
- | Flag | Description |
34
- | ---- | ----------- |
35
- | **`-f` / `--file`** | Required. Path to the JSON file. |
36
- | **`-p` / `--port`** | HTTP port (default `3000`, must be 1–65535). |
37
- | **`-d` / `--delay`** | Optional. Delay every request by this many milliseconds. Must be a non-negative integer using digits only (default `0`). |
38
- | **`-w` / `--watch`** | Optional. Watch the JSON file and reload the in-memory data when it changes. If a save produces invalid JSON, the server prints `[watch] Could not reload "<path>": ...` to stderr and keeps the last good data until the file is valid again. Only one reload runs at a time. When watch starts, you also get `[watch] Watching "<path>" for changes.` on stdout. |
39
-
40
- Examples:
41
-
42
- ```bash
43
- zero-mock -f ./data.json -p 3000 -d 200
44
- zero-mock -f ./data.json -w
45
- ```
46
-
47
- ## Quick start
44
+ ### 1. Interactive mode (recommended)
48
45
 
49
- From the repo root (or any directory containing the example file):
46
+ Run the command without arguments to launch the setup wizard:
50
47
 
51
48
  ```bash
52
- npx @xirconsss/zero-mock -f ./example/db.json -p 3000
49
+ zero-mock
53
50
  ```
54
51
 
55
- Optional:
56
-
57
- ```bash
58
- npx @xirconsss/zero-mock -f ./example/db.json -p 3000 -d 200 -w
59
- ```
52
+ ### 2. Manual CLI
60
53
 
61
- In another terminal:
54
+ Pass flags to skip the wizard:
62
55
 
63
56
  ```bash
64
- curl http://localhost:3000/users
65
- curl http://localhost:3000/users/1
57
+ zero-mock -f ./data.json -p 8080 -w -d 200
66
58
  ```
67
59
 
68
- The server logs the exact URLs for each collection when it starts.
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. |
69
68
 
70
- ## Development
69
+ ---
71
70
 
72
- From a clone: `npm install`, then `npm run build` (or `npm run dev` with `ts-node`). Run the built CLI with:
71
+ ## Auto-generated API
73
72
 
74
- ```bash
75
- node dist/index.js -f ./example/db.json -p 3000
76
- ```
73
+ The tool generates full CRUD endpoints for every top-level key in your JSON (e.g., `users`, `posts`).
77
74
 
78
- Add `-d` / `-w` the same way as the published CLI.
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. |
79
83
 
80
- ## Features
84
+ ### Advanced List Features
81
85
 
82
- - **Zero-config** — one JSON file defines your API surface; no schemas or generators to run.
83
- - **Full CRUD REST API** — `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` per collection, with CORS and JSON bodies enabled.
84
- - **Atomic file persistence** — writes go through a temp file and rename, with serialized saves so concurrent requests do not corrupt the file.
85
- - **Smart ID generation** — new rows get the next **numeric** id when existing ids are integers or all-digit strings; otherwise new ids use a **UUID**.
86
- - **Request logging** — each finished request logs as `[METHOD] <path> - <status>` (path is Express `req.path`, no query string).
87
- - **Optional delay** — `-d` adds a fixed pause before route handling (after JSON body parsing).
88
- - **List filtering and pagination** — see [List GET](#list-get) on `GET /{resource}`.
89
- - **Watch mode** — `-w` reloads data from disk on file change without restarting the process (see [Usage](#usage)).
86
+ **Filtering**
90
87
 
91
- ## Auto-generated API
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
92
91
 
93
- Each **top-level key** in your JSON (e.g. `users`, `posts`) becomes a **resource** name. Replace `{resource}` with that key and `{id}` with a row’s `id` (string params match numeric ids loosely).
92
+ **Sorting**
94
93
 
95
- | Method | Path | Description |
96
- | -------- | -------------------- | ----------- |
97
- | `GET` | `/{resource}` | List items (optionally [filtered and paginated](#list-get)). |
98
- | `POST` | `/{resource}` | Create an item; server assigns `id` and returns `201` with the new body. |
99
- | `GET` | `/{resource}/{id}` | Return one item by `id`; `404` if missing. |
100
- | `PUT` | `/{resource}/{id}` | Replace the item; `id` in the URL wins; `404` if missing. |
101
- | `PATCH` | `/{resource}/{id}` | Shallow-merge fields into the item; `404` if missing. |
102
- | `DELETE` | `/{resource}/{id}` | Remove the item; `204` on success; `404` if missing. |
94
+ - `GET /posts?_sort=createdAt&_order=desc`
103
95
 
104
- Invalid JSON bodies (non-objects for write routes) receive **`400`** with a JSON error message. Persistence failures surface as **`500`**.
96
+ **Pagination**
105
97
 
106
- ### List GET
98
+ - `GET /posts?_page=1&_limit=10`
99
+ - Returns an `X-Total-Count` header and a wrapped `{ data, pagination }` object.
107
100
 
108
- **Filtering:** Every query parameter except `_page` and `_limit` is a filter. Only plain-object rows are kept. For each filter key, the row must have that property, and the value must match the query value with loose equality (`==`). Query values are strings (first value wins if repeated). Rows missing a filter key are dropped.
101
+ ---
109
102
 
110
- **Pagination:** If **both** `_page` and `_limit` are present and are positive integers (digit strings only), the list is sliced after filtering. `_page` is 1-based. If either is missing or invalid, the full filtered list is returned (no error).
103
+ ## JSON Structure
111
104
 
112
- Examples using [example/db.json](example/db.json):
105
+ The root must be an object, and every resource must be an array.
113
106
 
114
- ```bash
115
- curl 'http://localhost:3000/users?role=admin'
116
- curl 'http://localhost:3000/users?_page=1&_limit=2'
107
+ ```json
108
+ {
109
+ "users": [
110
+ { "id": 1, "name": "Alice", "role": "admin" },
111
+ { "id": 2, "name": "Bob" }
112
+ ]
113
+ }
117
114
  ```
118
115
 
119
- ## JSON file shape
116
+ Note: zero-mock automatically infers that `role` is an optional string based on the items above.
120
117
 
121
- The root must be a JSON **object**. Each property must be an **array** (your “tables”). Each item you want to address by URL should include an **`id`** field (number or string).
118
+ ---
122
119
 
123
- ## Publishing to npm (maintainers)
120
+ ## Development
124
121
 
125
- **Release flow (recommended):** Automated via `semantic-release`. Commits following the conventional commit format (e.g., `feat:`, `fix:`) pushed to `main` will automatically trigger a version bump, changelog generation, and NPM publish via the [`.github/workflows/publish-npm.yml`](.github/workflows/publish-npm.yml) workflow. Avoid running `npm publish` on your machine.
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
126
 
127
- 1. Use an [npmjs.com](https://www.npmjs.com/) account with **2FA** enabled and permission to publish the **`@xirconsss`** scope (user or org on npm).
128
- 2. **GitHub:** repo → **Settings** → **Environments** → **`NPM_TOKEN`** → add secret **`NPM_TOKEN`** (see token steps below). The workflow uses that environment on each run.
129
- 3. Push conventional commits to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`.
127
+ ---
130
128
 
131
- **Token on npm (required for CI):**
129
+ ## Publishing (Maintainers)
132
130
 
133
- 1. Create a classic **[Automation](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-classic-tokens)** token, **or** a **[granular access token](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-granular-access-tokens)** with **read and write** on **`@xirconsss/zero-mock`** (and org **`xirconsss`** if npm asks).
134
- 2. For granular tokens: turn **Bypass two-factor authentication (2FA)** **on** so CI does not hit **`EOTP`**. Do **not** use **`NPM_OTP`** secrets (codes expire in ~30 seconds).
135
- 3. Paste the token into the **`NPM_TOKEN`** environment secret on GitHub.
131
+ This project uses `semantic-release` for fully automated versioning and npm publishing.
136
132
 
137
- **Optional OIDC trusted publishing:** You can later move to [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) and drop the secret; if you see **`E404`** on `PUT` with OIDC, the Trusted Publisher settings on npm (repo, workflow filename, environment name) do not match this workflow—token auth avoids that until it is configured correctly.
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
138
 
139
139
  ## License
140
140
 
141
- MIT - see [LICENSE](LICENSE).
141
+ MIT see [LICENSE](LICENSE).
142
+
143
+ ---
142
144
 
143
145
  ## Links
144
146
 
@@ -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,13 +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
+ })();
3
36
  var __importDefault = (this && this.__importDefault) || function (mod) {
4
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
38
  };
6
39
  Object.defineProperty(exports, "__esModule", { value: true });
7
- const chokidar_1 = __importDefault(require("chokidar"));
40
+ const chokidar = __importStar(require("chokidar"));
41
+ const picocolors_1 = __importDefault(require("picocolors"));
8
42
  const commander_1 = require("commander");
9
43
  const jsonStore_1 = require("./store/jsonStore");
10
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");
11
48
  function parseDelayMs(raw) {
12
49
  const trimmed = raw.trim();
13
50
  if (!/^\d+$/.test(trimmed)) {
@@ -15,67 +52,95 @@ function parseDelayMs(raw) {
15
52
  }
16
53
  return Number.parseInt(trimmed, 10);
17
54
  }
55
+ const program = new commander_1.Command();
56
+ let watcher = null;
18
57
  function startFileWatcher(filePath) {
19
58
  let reloadTimeout = null;
59
+ if (watcher) {
60
+ watcher.close().catch(() => { });
61
+ watcher = null;
62
+ }
20
63
  const reload = async () => {
21
64
  try {
22
- await jsonStore_1.JsonStore.load(filePath);
23
- console.log(`[watch] Reloaded "${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}`);
69
+ (0, errors_1.printError)('WATCH_RELOAD_FAILED', err.message);
28
70
  }
29
71
  };
30
- chokidar_1.default
31
- .watch(filePath, { persistent: true, ignoreInitial: true })
32
- .on("change", () => {
33
- if (reloadTimeout)
34
- clearTimeout(reloadTimeout);
35
- reloadTimeout = setTimeout(() => void reload(), 100);
36
- })
37
- .on("error", (err) => {
38
- const msg = err instanceof Error ? err.message : String(err);
39
- console.error(`[watch] Watcher error: ${msg}`);
40
- });
41
- console.log(`[watch] Watching "${filePath}" for changes.`);
72
+ try {
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);
83
+ });
84
+ }
85
+ catch (err) {
86
+ (0, errors_1.printError)('WATCH_FAILED', err.message);
87
+ }
42
88
  }
43
- const program = new commander_1.Command();
44
89
  program
45
90
  .name("zero-mock")
46
91
  .description("Generate a REST API from a JSON file")
47
- .requiredOption("-f, --file <path>", "path to the source JSON file")
48
- .option("-p, --port <number>", "HTTP port", "3000")
49
- .option("-d, --delay <ms>", "delay each request by this many ms (0 = off)", "0")
50
- .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")
51
96
  .option("--cors-origin <origins>", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*")
52
97
  .option("--cors-methods <methods>", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
53
98
  .option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false)
99
+ .option("--reset", "clear saved wizard configuration")
54
100
  .action(async (opts) => {
55
- const port = Number.parseInt(opts.port, 10);
56
- if (Number.isNaN(port) || port < 1 || port > 65535) {
57
- console.error("error: --port must be a number between 1 and 65535");
58
- process.exit(1);
59
- return;
101
+ if (process.argv.includes('--reset')) {
102
+ (0, config_store_1.clearSavedConfig)();
103
+ process.exit(0);
104
+ }
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;
60
112
  }
61
- const delayMs = parseDelayMs(opts.delay);
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);
62
125
  if (delayMs === null) {
63
126
  console.error("error: --delay must be a non-negative integer");
64
127
  process.exit(1);
65
128
  return;
66
129
  }
67
- 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);
68
134
  try {
69
135
  await jsonStore_1.JsonStore.load(filePath);
70
136
  }
71
137
  catch (err) {
72
- const message = err instanceof Error ? err.message : String(err);
73
- console.error(message);
74
- process.exit(1);
75
- 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);
76
141
  }
77
142
  try {
78
- await (0, bootstrap_1.bootstrap)(port, {
143
+ await (0, bootstrap_1.bootstrap)(port, filePath, watch, {
79
144
  delayMs,
80
145
  corsOrigin: opts.corsOrigin,
81
146
  corsMethods: opts.corsMethods,
@@ -83,13 +148,18 @@ program
83
148
  });
84
149
  }
85
150
  catch (err) {
86
- const message = err instanceof Error ? err.message : String(err);
87
- console.error(`Failed to start server: ${message}`);
88
- process.exit(1);
89
- return;
151
+ // Bootstrap will handle EADDRINUSE internally, catching just in case.
152
+ (0, errors_1.printFatal)('UNKNOWN', err.message);
90
153
  }
91
- if (opts.watch) {
154
+ if (watch) {
92
155
  startFileWatcher(filePath);
93
156
  }
157
+ const cleanup = () => {
158
+ if (watcher) {
159
+ watcher.close().catch(() => { });
160
+ }
161
+ };
162
+ process.on('SIGINT', cleanup);
163
+ process.on('SIGTERM', cleanup);
94
164
  });
95
165
  program.parse(process.argv);
@@ -7,6 +7,7 @@ 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;
@@ -14,12 +15,6 @@ const errorHandler = (err, _req, res, _next) => {
14
15
  const message = err instanceof Error ? err.message : String(err);
15
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) {
@@ -37,7 +32,7 @@ function createApp(options) {
37
32
  credentials: options.corsCredentials || false,
38
33
  }));
39
34
  app.use(express_1.default.json());
40
- app.use(requestLoggingMiddleware);
35
+ app.use(logger_1.requestLoggingMiddleware);
41
36
  app.use(delayMiddleware(options.delayMs));
42
37
  app.use("/", (0, dynamicRouter_1.buildDynamicRouter)());
43
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
+ }
@@ -109,7 +109,8 @@ function filterCollection(items, query) {
109
109
  else if (op === "like") {
110
110
  if (actual === undefined || actual === null)
111
111
  return false;
112
- if (!String(actual).toLowerCase().includes(String(want).toLowerCase()))
112
+ const escaped = want.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ if (!new RegExp(escaped, 'i').test(String(actual)))
113
114
  return false;
114
115
  }
115
116
  }
@@ -235,10 +236,23 @@ function buildDynamicRouter() {
235
236
  const collection = jsonStore_1.JsonStore.getData()[resource];
236
237
  const query = req.query;
237
238
  let rows = filterCollection(collection, query);
239
+ const total = rows.length;
240
+ res.setHeader("X-Total-Count", total);
238
241
  const pageInfo = parsePagination(query);
239
242
  if (pageInfo !== null) {
240
243
  const start = (pageInfo.page - 1) * pageInfo.limit;
241
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;
242
256
  }
243
257
  res.json(rows);
244
258
  });
@@ -1,78 +1,63 @@
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
- lockPath = null;
35
18
  /** Serializes concurrent save() calls so writes are not interleaved. */
36
19
  saveChain = Promise.resolve();
37
- async load(filePath) {
38
- const lockPath = filePath + ".zero-mock.lock";
20
+ async load(filePath, isReload = false) {
21
+ let raw;
39
22
  try {
40
- await (0, promises_1.access)(lockPath);
41
- console.warn(`[warn] Lock file found: "${lockPath}". ` +
42
- `Another zero-mock process may be running on this file. ` +
43
- `If not, delete the lock file and retry.`);
23
+ raw = await (0, promises_1.readFile)(filePath, "utf8");
44
24
  }
45
- catch {
46
- await (0, promises_1.writeFile)(lockPath, String(process.pid), "utf8");
47
- this.lockPath = lockPath;
48
- this._registerLockCleanup();
25
+ catch (err) {
26
+ if (isReload)
27
+ throw new Error(`Unreadable: ${err.message}`);
28
+ (0, errors_1.printFatal)('FILE_UNREADABLE', err.message);
49
29
  }
50
- let raw;
30
+ let parsed;
51
31
  try {
52
- raw = await (0, promises_1.readFile)(filePath, "utf8");
32
+ parsed = JSON.parse(raw);
53
33
  }
54
34
  catch (err) {
55
- const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
56
- const message = err instanceof Error ? err.message : String(err);
57
- throw new Error(code
58
- ? `Could not read JSON file "${filePath}" (${code}): ${message}`
59
- : `Could not read JSON file "${filePath}": ${message}`);
35
+ if (isReload)
36
+ throw new Error(`Parse error: ${err.message}`);
37
+ (0, errors_1.printFatal)('JSON_INVALID', err.message);
60
38
  }
61
- this.data = parseJsonStorePayload(raw, filePath);
62
- this.backingPath = filePath;
63
- }
64
- _registerLockCleanup() {
65
- const cleanup = () => {
66
- if (this.lockPath) {
67
- try {
68
- require("fs").unlinkSync(this.lockPath);
69
- }
70
- catch (_) { }
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);
71
56
  }
72
- };
73
- process.once("exit", cleanup);
74
- process.once("SIGINT", () => { cleanup(); process.exit(130); });
75
- process.once("SIGTERM", () => { cleanup(); process.exit(143); });
57
+ data[key] = value;
58
+ }
59
+ this.data = data;
60
+ this.backingPath = filePath;
76
61
  }
77
62
  getData() {
78
63
  return this.data;
@@ -92,10 +77,22 @@ class JsonStoreImpl {
92
77
  const payload = `${JSON.stringify(this.data, null, 2)}\n`;
93
78
  try {
94
79
  await (0, promises_1.writeFile)(tmp, payload, "utf8");
95
- 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
+ }
96
92
  }
97
93
  catch (err) {
98
- await (0, promises_1.unlink)(tmp).catch(() => { });
94
+ if (fs_1.default.existsSync(tmp))
95
+ fs_1.default.unlinkSync(tmp);
99
96
  const message = err instanceof Error ? err.message : String(err);
100
97
  throw new Error(`Could not write JSON file "${target}": ${message}`);
101
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xirconsss/zero-mock",
3
- "version": "1.1.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,10 +47,13 @@
47
47
  },
48
48
  "homepage": "https://github.com/xircons/zero-mock#readme",
49
49
  "dependencies": {
50
+ "@inquirer/prompts": "^8.5.2",
50
51
  "chokidar": "^4.0.3",
51
52
  "commander": "^13.1.0",
52
53
  "cors": "^2.8.5",
53
54
  "express": "^4.21.2",
55
+ "gradient-string": "^3.0.0",
56
+ "picocolors": "^1.1.1",
54
57
  "zod": "^4.4.3"
55
58
  },
56
59
  "devDependencies": {
@@ -64,6 +67,7 @@
64
67
  "@semantic-release/release-notes-generator": "^14.1.1",
65
68
  "@types/cors": "^2.8.17",
66
69
  "@types/express": "^4.17.21",
70
+ "@types/gradient-string": "^1.1.6",
67
71
  "@types/node": "^22.14.0",
68
72
  "@vitest/coverage-v8": "^2.1.9",
69
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
- }