diff --git a/data.json b/data.json index b6e938d..cd34dd8 100644 --- a/data.json +++ b/data.json @@ -1,7 +1,7 @@ { "todos": [ { - "title": "TEST KUBBB", + "title": "TEST KUB", "completed": false, "id": 2 }, diff --git a/example/db.json b/example/db.json deleted file mode 100644 index f9507c8..0000000 --- a/example/db.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "users": [ - { - "id": 1, - "name": "Ada Lovelace", - "email": "ada@example.com", - "role": "admin" - }, - { - "id": 2, - "name": "Grace Hopper", - "email": "grace@example.com", - "role": "editor" - }, - { - "id": 3, - "name": "Margaret Hamilton", - "email": "margaret@example.com", - "role": "viewer" - } - ], - "posts": [ - { - "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", - "title": "Shipping a mock API in minutes", - "body": "Point zero-mock at a JSON file and iterate on your UI without waiting on backend tickets.", - "userId": 1, - "published": true - }, - { - "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", - "title": "Why atomic writes matter", - "body": "Your JSON file stays the source of truth—even when multiple tabs hit the API at once.", - "userId": 2, - "published": false - }, - { - "id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", - "title": "Frontend-first workflows", - "body": "Prototype with realistic CRUD, then swap the mock for a production API when you are ready.", - "userId": 1, - "published": true - } - ] -} diff --git a/package-lock.json b/package-lock.json index e2006c3..99d4f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,13 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@inquirer/prompts": "^8.5.2", "chokidar": "^4.0.3", "commander": "^13.1.0", "cors": "^2.8.5", "express": "^4.21.2", + "gradient-string": "^3.0.0", + "picocolors": "^1.1.1", "zod": "^4.4.3" }, "bin": { @@ -29,6 +32,7 @@ "@semantic-release/release-notes-generator": "^14.1.1", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/gradient-string": "^1.1.6", "@types/node": "^22.14.0", "@vitest/coverage-v8": "^2.1.9", "husky": "^9.1.7", @@ -876,6 +880,362 @@ "node": ">=12" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2483,6 +2843,16 @@ "@types/send": "*" } }, + "node_modules/@types/gradient-string": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@types/gradient-string/-/gradient-string-1.1.6.tgz", + "integrity": "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2501,7 +2871,7 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2562,6 +2932,12 @@ "@types/node": "*" } }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "license": "MIT" + }, "node_modules/@vitest/coverage-v8": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", @@ -3059,7 +3435,6 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -3078,6 +3453,12 @@ "node": ">=10" } }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3243,6 +3624,15 @@ "@colors/colors": "1.5.0" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -4151,6 +4541,21 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -4168,6 +4573,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -4563,6 +4977,19 @@ "dev": true, "license": "ISC" }, + "node_modules/gradient-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/gradient-string/-/gradient-string-3.0.0.tgz", + "integrity": "sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "tinygradient": "^1.1.5" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -5610,6 +6037,15 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -8575,7 +9011,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -11929,6 +12364,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -11988,6 +12429,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -12179,7 +12630,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index 3ee06ae..224743b 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,13 @@ }, "homepage": "https://github.com/xircons/zero-mock#readme", "dependencies": { + "@inquirer/prompts": "^8.5.2", "chokidar": "^4.0.3", "commander": "^13.1.0", "cors": "^2.8.5", "express": "^4.21.2", + "gradient-string": "^3.0.0", + "picocolors": "^1.1.1", "zod": "^4.4.3" }, "devDependencies": { @@ -64,6 +67,7 @@ "@semantic-release/release-notes-generator": "^14.1.1", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/gradient-string": "^1.1.6", "@types/node": "^22.14.0", "@vitest/coverage-v8": "^2.1.9", "husky": "^9.1.7", diff --git a/src/cli-wizard.ts b/src/cli-wizard.ts new file mode 100644 index 0000000..51f85e8 --- /dev/null +++ b/src/cli-wizard.ts @@ -0,0 +1,175 @@ +import { select, input, confirm } from '@inquirer/prompts'; +import pc from 'picocolors'; +import { loadSavedConfig, saveConfig, formatSavedAt, SavedConfig } from './config-store'; + +export const PANEL_W = 42; + +export function stripAnsi(s: string): string { + return s.replace(/\x1B\[[0-9;]*[mGKHF]/g, ''); +} + +export function centerPad(text: string, width = 42): string { + const stripped = stripAnsi(text); + const padding = Math.max(0, Math.floor((width - stripped.length) / 2)); + return ' '.repeat(padding) + text; +} + +export function sectionHeader(title: string): string { + const header = `${pc.blue('◆')} ${pc.bold(pc.white(title))}`; + const rule = pc.dim('─'.repeat(PANEL_W)); + return `${header}\n${rule}`; +} + +export type WizardConfig = { + file: string; + port: string; + delay: string; + watch: boolean; +}; + +export function renderConfigPanel(config: WizardConfig, saved?: SavedConfig | null): string { + let out = pc.dim('╭' + '─'.repeat(PANEL_W) + '╮\n'); + + const rows = [ + { label: 'Target File', value: config.file }, + { label: 'Default Port', value: config.port }, + { label: 'Latency', value: `${config.delay}ms` }, + { label: 'Watch Mode', value: config.watch ? 'Active' : 'Inactive' }, + ]; + + for (const row of rows) { + const labelPadded = ` · ${row.label} `.padEnd(20, ' '); + const valRaw = row.value; + const spaceCount = Math.max(0, PANEL_W - labelPadded.length - valRaw.length - 3); + const spaces = ' '.repeat(spaceCount); + + out += pc.dim('│') + pc.dim(labelPadded) + pc.white(row.value) + spaces + ' ' + pc.green('✓') + ' ' + pc.dim('│') + '\n'; + } + + if (saved) { + out += pc.dim('├' + '─'.repeat(PANEL_W) + '┤\n'); + const footerText = `last used ${formatSavedAt(saved.savedAt)}`; + const footerPadded = ` · ${footerText} `.padEnd(PANEL_W, ' '); + out += pc.dim('│') + pc.dim(footerPadded) + pc.dim('│') + '\n'; + } + + out += pc.dim('╰' + '─'.repeat(PANEL_W) + '╯'); + return out; +} + +const hex = (hexCode: string, text: string) => { + const r = parseInt(hexCode.slice(1, 3), 16); + const g = parseInt(hexCode.slice(3, 5), 16); + const b = parseInt(hexCode.slice(5, 7), 16); + return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`; +}; + +const FALLBACK: WizardConfig = { + file: './example/db.json', + port: '8080', + delay: '200', + watch: true, +}; + +export async function runWizard(): Promise { + console.clear(); + + const saved = loadSavedConfig(); + const defaults = saved ?? FALLBACK; + const isReturning = saved !== null; + + const logoTop = hex('#bfdbfe', ' ▗▄▄▄▄▖▗▄▄▄▖▗▄▄▖ ▗▄▖ ▗▖ ▗▖ ▗▄▖ ▗▄▄▖▗▖ ▗▖'); + const logoMid1 = hex('#60a5fa', ' ▗▞▘▐▌ ▐▌ ▐▌▐▌ ▐▌▐▛▚▞▜▌▐▌ ▐▌▐▌ ▐▌▗▞▘'); + const logoMid2 = hex('#2563eb', ' ▗▞▘ ▐▛▀▀▘▐▛▀▚▖▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▛▚▖ '); + const logoBottom = hex('#1e3a8a', ' ▐▙▄▄▄▖▐▙▄▄▖▐▌ ▐▌▝▚▄▞▘▐▌ ▐▌▝▚▄▞▘▝▚▄▄▖▐▌ ▐▌'); + + console.log(`\n${logoTop}\n${logoMid1}\n${logoMid2}\n${logoBottom}\n`); + + console.log(centerPad(pc.bold(pc.white("Zero-Mock By Xircons")))); + console.log(centerPad(pc.dim("Zero-config REST API setup in seconds"))); + + if (isReturning && saved) { + console.log(centerPad(pc.dim(`last session ${formatSavedAt(saved.savedAt)}`))); + } + + console.log('\n' + renderConfigPanel(defaults, saved) + '\n'); + + const promptTheme = { prefix: pc.blue('◆') }; + + const choice = await select({ + message: 'What would you like to do?', + theme: promptTheme, + choices: [ + { name: `${pc.green('▶')} Continue`, value: 'continue', short: 'Continue' }, + { name: `${pc.yellow('◈')} Change configuration (pick manually)`, value: 'change', short: 'Change configuration' }, + { name: `${pc.dim('✕')} Cancel (exit wizard)`, value: 'cancel', short: 'Cancel' }, + ], + }); + + if (choice === 'cancel') { + console.clear(); + console.log(pc.dim('Setup cancelled. zero-mock aborted.')); + process.exit(0); + } + + if (choice === 'continue') { + console.clear(); + saveConfig(defaults, saved); + return defaults; + } + + // Change configuration + console.clear(); + console.log('\n' + sectionHeader('CUSTOM CONFIGURATION')); + + if (isReturning) { + console.log(pc.dim('Pre-filled from last session — press Enter to keep\n')); + } + + const file = await input({ + message: 'Target File Path:', + default: defaults.file, + theme: promptTheme, + validate: (value) => { + if (!value.trim()) return "File path cannot be empty."; + if (!value.endsWith('.json')) return "File must be a JSON file (.json)."; + return true; + } + }); + + const port = await input({ + message: 'Port Number:', + default: defaults.port, + theme: promptTheme, + validate: (value) => { + const p = parseInt(value, 10); + if (isNaN(p) || p < 1 || p > 65535) return "Port must be a number between 1 and 65535."; + return true; + } + }); + + const delay = await input({ + message: 'Simulated Latency (ms):', + default: defaults.delay, + theme: promptTheme, + validate: (value) => { + const d = parseInt(value, 10); + if (isNaN(d) || d < 0) return "Latency must be a non-negative integer."; + return true; + } + }); + + const watch = await confirm({ + message: 'Enable Watch Mode (auto-reload on save)?', + default: defaults.watch, + theme: promptTheme, + }); + + const newConfig = { file, port, delay, watch }; + saveConfig(newConfig, saved); + + console.log(`\n ${pc.green('✓')} Configuration updated.`); + console.clear(); + + return newConfig; +} \ No newline at end of file diff --git a/src/config-store.ts b/src/config-store.ts new file mode 100644 index 0000000..70221de --- /dev/null +++ b/src/config-store.ts @@ -0,0 +1,90 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import pc from 'picocolors'; +import type { WizardConfig } from './cli-wizard'; + +export type SavedConfig = WizardConfig & { + savedAt: string; + useCount: number; +}; + +const getConfigPath = () => path.join(os.homedir(), '.config', 'zero-mock', 'config.json'); + +export function loadSavedConfig(): SavedConfig | null { + try { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + return null; + } + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw); + + if ( + typeof parsed === 'object' && + parsed !== null && + typeof parsed.file === 'string' && + typeof parsed.port === 'string' && + typeof parsed.delay === 'string' && + typeof parsed.watch === 'boolean' + ) { + return parsed as SavedConfig; + } + return null; + } catch { + return null; + } +} + +export function saveConfig(config: WizardConfig, prev: SavedConfig | null): void { + try { + const configPath = getConfigPath(); + const configDir = path.dirname(configPath); + + fs.mkdirSync(configDir, { recursive: true }); + + const savedConfig: SavedConfig = { + ...config, + savedAt: new Date().toISOString(), + useCount: (prev?.useCount || 0) + 1, + }; + + fs.writeFileSync(configPath, JSON.stringify(savedConfig, null, 2), 'utf8'); + } catch { + // Non-fatal, do nothing + } +} + +export function clearSavedConfig(): void { + try { + const configPath = getConfigPath(); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + console.log(`${pc.green('✓')} Cleared saved configuration.`); + } else { + console.log(`${pc.dim('✕')} No saved configuration found.`); + } + } catch (err: any) { + console.log(`${pc.red('✕')} Failed to clear config: ${err.message}`); + } + process.exit(0); +} + +export function formatSavedAt(iso: string): string { + const date = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + + const mins = Math.floor(diffMs / 60000); + if (mins < 2) return "just now"; + + const hours = Math.floor(mins / 60); + if (hours < 1) return `${mins}m ago`; + + const days = Math.floor(hours / 24); + if (days < 1) return `${hours}h ago`; + + if (days < 7) return `${days}d ago`; + + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} \ No newline at end of file diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..232e315 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,145 @@ +import pc from 'picocolors'; +import fs from 'fs'; +import path from 'path'; + +export type ErrorCode = + | 'FILE_NOT_FOUND' + | 'FILE_NOT_JSON' + | 'FILE_UNREADABLE' + | 'JSON_INVALID' + | 'JSON_NOT_OBJECT' + | 'JSON_EMPTY' + | 'PORT_IN_USE' + | 'PORT_INVALID' + | 'PORT_PRIVILEGED' + | 'LOCK_STALE' + | 'LOCK_CONFLICT' + | 'LOCK_WRITE_FAILED' + | 'WATCH_FAILED' + | 'WATCH_RELOAD_FAILED' + | 'SERVER_CRASH' + | 'UNKNOWN'; + +const ERROR_MESSAGES: Record = { + FILE_NOT_FOUND: { msg: 'Target JSON file not found.', hint: 'Check if the file path is correct.' }, + FILE_NOT_JSON: { msg: 'Target file is not a JSON file.', hint: 'Ensure the file ends with .json.' }, + FILE_UNREADABLE: { msg: 'Cannot read the target file.', hint: 'Check file permissions.' }, + JSON_INVALID: { msg: 'Invalid JSON format.', hint: 'Check the file for syntax errors.' }, + JSON_NOT_OBJECT: { msg: 'JSON root is not an object.', hint: 'The JSON file must have an object at its root.' }, + JSON_EMPTY: { msg: 'JSON file is empty.', hint: 'The API will start with no resources. Add arrays to the root object.' }, + PORT_IN_USE: { msg: 'Port is already in use.', hint: 'Choose a different port or kill the process using it.' }, + PORT_INVALID: { msg: 'Invalid port number.', hint: 'Port must be an integer between 1024 and 65535.' }, + PORT_PRIVILEGED: { msg: 'Port requires root privileges.', hint: 'Use a port number >= 1024.' }, + LOCK_STALE: { msg: 'Found a stale lock file.', hint: 'The lock file will be automatically removed.' }, + LOCK_CONFLICT: { msg: 'Another instance is running.', hint: 'Stop the other zero-mock instance or use a different file.' }, + LOCK_WRITE_FAILED: { msg: 'Failed to write lock file.', hint: 'Check directory permissions.' }, + WATCH_FAILED: { msg: 'File watcher failed to start.', hint: 'Hot-reloading will not work. Check OS file watch limits.' }, + WATCH_RELOAD_FAILED: { msg: 'Failed to reload JSON file.', hint: 'Fix the JSON syntax error. Serving last good snapshot.' }, + SERVER_CRASH: { msg: 'Server crashed unexpectedly.', hint: 'Check the error details below.' }, + UNKNOWN: { msg: 'An unknown error occurred.', hint: 'Please report this issue.' }, +}; + +function formatErrorBlock(type: 'ERROR' | 'WARN', code: ErrorCode, detail?: string) { + const badge = type === 'ERROR' + ? pc.red(pc.bold(pc.inverse(' ERROR '))) + : pc.yellow(pc.bold(pc.inverse(' WARN '))); + + const { msg, hint } = ERROR_MESSAGES[code]; + + let out = `\n ${badge} ${pc.bold(pc.white(msg))}\n`; + if (detail) { + out += ` ${pc.dim('│')} ${pc.dim(detail)}\n`; + } + out += ` ${pc.dim('╰─')} ${pc.blue(hint)}\n`; + + return out; +} + +export function printError(code: ErrorCode, detail?: string): void { + console.error(formatErrorBlock('WARN', code, detail)); +} + +export function printFatal(code: ErrorCode, detail?: string): never { + console.error(formatErrorBlock('ERROR', code, detail)); + process.exit(1); +} + +export function validateConfig(file: string, port: number): void { + if (path.extname(file) !== '.json') { + printFatal('FILE_NOT_JSON', `Path: ${file}`); + } + if (!fs.existsSync(file)) { + printFatal('FILE_NOT_FOUND', `Path: ${file}`); + } + try { + fs.accessSync(file, fs.constants.R_OK); + } catch (e: any) { + printFatal('FILE_UNREADABLE', e.message); + } + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + printFatal('PORT_INVALID', `Given: ${port}`); + } + if (port < 1024) { + printFatal('PORT_PRIVILEGED', `Given: ${port}`); + } +} + +export function acquireLock(file: string): void { + const lockFilePath = `${file}.zero-mock.lock`; + + if (fs.existsSync(lockFilePath)) { + try { + const lockData = fs.readFileSync(lockFilePath, 'utf8'); + const { pid, startedAt } = JSON.parse(lockData); + + try { + process.kill(pid, 0); // test liveness + // Alive + printFatal('LOCK_CONFLICT', `PID ${pid} since ${startedAt}`); + } catch (e: any) { + if (e.code === 'ESRCH') { + // Stale + printError('LOCK_STALE', `PID ${pid} is dead.`); + fs.unlinkSync(lockFilePath); + } else { + // No permission or other error + printFatal('LOCK_CONFLICT', `Unable to check PID ${pid}.`); + } + } + } catch (e: any) { + // Unreadable or invalid lock file format + printError('LOCK_STALE', `Invalid lock file format.`); + fs.unlinkSync(lockFilePath); + } + } + + try { + fs.writeFileSync(lockFilePath, JSON.stringify({ + pid: process.pid, + startedAt: new Date().toISOString() + }), { flag: 'wx' }); + } catch (e: any) { + if (e.code === 'EEXIST') { + printFatal('LOCK_CONFLICT', 'Lock file appeared during race condition.'); + } else { + printFatal('LOCK_WRITE_FAILED', e.message); + } + } + + const cleanup = () => { + if (fs.existsSync(lockFilePath)) { + try { + fs.unlinkSync(lockFilePath); + } catch (_) {} + } + }; + + process.on('exit', cleanup); + process.on('SIGINT', () => { cleanup(); process.exit(130); }); + process.on('SIGTERM', () => { cleanup(); process.exit(143); }); + process.on('uncaughtException', (err) => { + cleanup(); + printFatal('SERVER_CRASH', err.message); + }); +} diff --git a/src/index.ts b/src/index.ts index 4a21c96..3bbb981 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,22 @@ #!/usr/bin/env node import chokidar from "chokidar"; +import pc from "picocolors"; import { Command } from "commander"; import { JsonStore } from "./store/jsonStore"; import { bootstrap } from "./server/bootstrap"; +import { runWizard } from "./cli-wizard"; +import { validateConfig, acquireLock, printError, printFatal } from "./errors"; +import { clearSavedConfig } from "./config-store"; type CliOpts = { - file: string; - port: string; - delay: string; - watch: boolean; + file?: string; + port?: string; + delay?: string; + watch?: boolean; corsOrigin?: string; corsMethods?: string; corsCredentials?: boolean; + reset?: boolean; }; function parseDelayMs(raw: string): number | null { @@ -45,8 +50,6 @@ function startFileWatcher(filePath: string): void { const msg = err instanceof Error ? err.message : String(err); console.error(`[watch] Watcher error: ${msg}`); }); - - console.log(`[watch] Watching "${filePath}" for changes.`); } const program = new Command(); @@ -54,54 +57,73 @@ const program = new Command(); program .name("zero-mock") .description("Generate a REST API from a JSON file") - .requiredOption("-f, --file ", "path to the source JSON file") - .option("-p, --port ", "HTTP port", "3000") - .option("-d, --delay ", "delay each request by this many ms (0 = off)", "0") - .option("-w, --watch", "reload JSON from disk when the file changes", false) + .option("-f, --file ", "path to the source JSON file") + .option("-p, --port ", "HTTP port") + .option("-d, --delay ", "delay each request by this many ms (0 = off)") + .option("-w, --watch", "reload JSON from disk when the file changes") .option("--cors-origin ", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*") .option("--cors-methods ", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE") .option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false) + .option("--reset", "clear saved wizard configuration") .action(async (opts: CliOpts) => { - const port = Number.parseInt(opts.port, 10); - if (Number.isNaN(port) || port < 1 || port > 65535) { - console.error("error: --port must be a number between 1 and 65535"); - process.exit(1); - return; + if (opts.reset) { + clearSavedConfig(); } + + let { file, port: portRaw, delay: delayRaw, watch } = opts; - const delayMs = parseDelayMs(opts.delay); + if (!file && !portRaw && !delayRaw && watch === undefined) { + const wizardConfig = await runWizard(); + file = wizardConfig.file; + portRaw = wizardConfig.port; + delayRaw = wizardConfig.delay; + watch = wizardConfig.watch; + } else { + if (!file) { + console.error("error: required option '-f, --file ' not specified"); + process.exit(1); + return; + } + portRaw = portRaw ?? "3000"; + delayRaw = delayRaw ?? "0"; + watch = watch ?? false; + } + + const port = Number.parseInt(portRaw, 10); + const delayMs = parseDelayMs(delayRaw); if (delayMs === null) { console.error("error: --delay must be a non-negative integer"); process.exit(1); return; } - const filePath = opts.file; + const filePath = file; + + // Ordered Validation Step 1 & 2 + validateConfig(filePath, port); + acquireLock(filePath); try { await JsonStore.load(filePath); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(message); - process.exit(1); - return; + } catch (err: any) { + // JsonStore.load handles its own printFatal internally, + // but we catch to ensure we don't crash without formatting. + printFatal('UNKNOWN', err.message); } try { - await bootstrap(port, { + await bootstrap(port, filePath, watch, { delayMs, corsOrigin: opts.corsOrigin, corsMethods: opts.corsMethods, corsCredentials: opts.corsCredentials, }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`Failed to start server: ${message}`); - process.exit(1); - return; + } catch (err: any) { + // Bootstrap will handle EADDRINUSE internally, catching just in case. + printFatal('UNKNOWN', err.message); } - if (opts.watch) { + if (watch) { startFileWatcher(filePath); } }); diff --git a/src/server/app.ts b/src/server/app.ts index 52cd7c9..b8ad0db 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -2,6 +2,7 @@ import type { Application, ErrorRequestHandler, NextFunction, Request, Response import cors from "cors"; import express from "express"; import { buildDynamicRouter } from "./routes/dynamicRouter"; +import { requestLoggingMiddleware } from "./logger"; const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { if (res.headersSent) { @@ -18,13 +19,6 @@ export type CreateAppOptions = { corsCredentials?: boolean; }; -function requestLoggingMiddleware(req: Request, res: Response, next: NextFunction): void { - res.on("finish", () => { - console.log(`[${req.method}] ${req.path} - ${res.statusCode}`); - }); - next(); -} - function delayMiddleware(delayMs: number) { return (_req: Request, _res: Response, next: NextFunction): void => { if (delayMs <= 0) { diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 4fe8074..9e6d8bf 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -1,24 +1,26 @@ import type { CreateAppOptions } from "./app"; import { createApp } from "./app"; import { JsonStore } from "../store/jsonStore"; +import { printStartupBanner } from "./logger"; +import { printFatal } from "../errors"; -export async function bootstrap(port: number, appOptions: CreateAppOptions): Promise { +export async function bootstrap(port: number, file: string, watch: boolean, appOptions: CreateAppOptions): Promise { const app = createApp(appOptions); await new Promise((resolve, reject) => { const server = app.listen(port, () => { resolve(); }); - server.on("error", reject); + server.on("error", (e: NodeJS.ErrnoException) => { + if (e.code === 'EADDRINUSE') { + printFatal('PORT_IN_USE', `Port ${port} — ${e.message}`); + } else { + printFatal('UNKNOWN', e.message); + } + reject(e); + }); }); - const base = `http://localhost:${port}`; - console.log(`🚀 Server is running at ${base}`); - console.log("Read/write API: GET, POST, PUT, PATCH, DELETE are active for each resource below.\n"); - - for (const resource of Object.keys(JsonStore.getData())) { - console.log(` ${resource}`); - console.log(` GET POST ${base}/${resource}`); - console.log(` GET PUT PATCH DELETE ${base}/${resource}/:id`); - } + const resources = Object.keys(JsonStore.getData()); + printStartupBanner(file, port, watch, appOptions.delayMs, resources); } diff --git a/src/server/logger.ts b/src/server/logger.ts new file mode 100644 index 0000000..0450e98 --- /dev/null +++ b/src/server/logger.ts @@ -0,0 +1,85 @@ +import type { Request, Response, NextFunction } from 'express'; +import pc from 'picocolors'; +import { stripAnsi, sectionHeader, renderConfigPanel } from '../cli-wizard'; + +export function getMethodBadge(method: string): string { + const m = method.toUpperCase(); + const padded = m.padEnd(6, ' '); + const inner = `[${padded}]`; + + switch(m) { + case 'GET': return pc.bold(pc.cyan(inner)); + case 'POST': return pc.bold(pc.green(inner)); + case 'PUT': return pc.bold(pc.yellow(inner)); + case 'PATCH': return pc.bold(pc.magenta(inner)); + case 'DELETE': return pc.bold(pc.red(inner)); + default: return pc.bold(pc.white(inner)); + } +} + +export function requestLoggingMiddleware(req: Request, res: Response, next: NextFunction): void { + const startMs = Date.now(); + const start = new Date(); + + res.on("finish", () => { + const timeStr = start.toLocaleTimeString('en-US', { hour12: true }); + const duration = Date.now() - startMs; + + const badge = getMethodBadge(req.method); + + let statusStr = String(res.statusCode); + if (res.statusCode >= 200 && res.statusCode < 300) statusStr = pc.green(statusStr); + else if (res.statusCode >= 300 && res.statusCode < 400) statusStr = pc.cyan(statusStr); + else if (res.statusCode >= 400 && res.statusCode < 500) statusStr = pc.yellow(statusStr); + else if (res.statusCode >= 500) statusStr = pc.red(statusStr); + + const pathPadded = req.path.length > 20 ? req.path : req.path.padEnd(20, ' '); + + console.log(`${pc.dim(`[${timeStr}]`)} ${badge} ${pc.white(pathPadded)} ${statusStr} ${pc.dim(`${duration}ms`)}`); + }); + next(); +} + +export function getMonochromeBadge(method: string): string { + const m = method.toUpperCase(); + const padded = m.padEnd(6, ' '); + return `[${padded}]`; +} + +export function printStartupBanner(file: string, port: number, isWatch: boolean, delayMs: number, resources: string[]): void { + console.log('\n' + sectionHeader('SERVER READY')); + + console.log(renderConfigPanel({ + file, + port: String(port), + delay: String(delayMs), + watch: isWatch + }) + '\n'); + + console.log(sectionHeader('AVAILABLE RESOURCES')); + + const base = `http://localhost:${port}`; + + const get = getMonochromeBadge('GET'); + const post = getMonochromeBadge('POST'); + const put = getMonochromeBadge('PUT'); + const patch = getMonochromeBadge('PATCH'); + const del = getMonochromeBadge('DELETE'); + + for (const resource of resources) { + console.log(pc.bold(pc.white(`■ /${resource}`))); + + const methodsLeft1 = ` ${get} ${post}`; + const methodsLeft2 = ` ${get} ${put} ${patch} ${del}`; + + // Align URLs at column 45 + const pad1 = ' '.repeat(Math.max(1, 45 - stripAnsi(methodsLeft1).length)); + const pad2 = ' '.repeat(Math.max(1, 45 - stripAnsi(methodsLeft2).length)); + + console.log(`${methodsLeft1}${pad1}${pc.white(`${base}/${resource}`)}`); + console.log(`${methodsLeft2}${pad2}${pc.white(`${base}/${resource}/:id`)}\n`); + } + + console.log(sectionHeader('REQUEST LOGS')); + console.log(`${pc.green('✓')} ${pc.dim('Ready on')} ${pc.white(`${base}`)}\n`); +} \ No newline at end of file diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts index 7d23561..b42c1ee 100644 --- a/src/store/jsonStore.ts +++ b/src/store/jsonStore.ts @@ -1,89 +1,59 @@ import { randomBytes } from "crypto"; import { dirname, join } from "path"; -import { access, readFile, rename, unlink, writeFile } from "fs/promises"; +import { readFile, rename, unlink, writeFile } from "fs/promises"; import type { JsonData } from "../types"; +import { printError, printFatal } from "../errors"; function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function parseJsonStorePayload(raw: string, filePath: string): JsonData { - let parsed: unknown; - try { - parsed = JSON.parse(raw) as unknown; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid JSON in "${filePath}": ${message}`); - } - - if (!isPlainObject(parsed)) { - throw new Error( - `JSON root in "${filePath}" must be a non-null object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}).`, - ); - } - - const data: JsonData = {}; - for (const [key, value] of Object.entries(parsed)) { - if (!Array.isArray(value)) { - throw new Error( - `Invalid mock database shape in "${filePath}": property "${key}" must be an array.`, - ); - } - data[key] = value as unknown[]; - } - return data; -} - class JsonStoreImpl { private data: JsonData = {}; private backingPath: string | null = null; - private lockPath: string | null = null; /** Serializes concurrent save() calls so writes are not interleaved. */ private saveChain: Promise = Promise.resolve(); - async load(filePath: string): Promise { - const lockPath = filePath + ".zero-mock.lock"; + async load(filePath: string, isReload = false): Promise { + let raw: string; try { - await access(lockPath); - console.warn( - `[warn] Lock file found: "${lockPath}". ` + - `Another zero-mock process may be running on this file. ` + - `If not, delete the lock file and retry.`, - ); - } catch { - await writeFile(lockPath, String(process.pid), "utf8"); - this.lockPath = lockPath; - this._registerLockCleanup(); + raw = await readFile(filePath, "utf8"); + } catch (err: any) { + if (isReload) throw new Error(`Unreadable: ${err.message}`); + printFatal('FILE_UNREADABLE', err.message); } - let raw: string; + let parsed: unknown; try { - raw = await readFile(filePath, "utf8"); - } catch (err) { - const code = err && typeof err === "object" && "code" in err ? String((err as NodeJS.ErrnoException).code) : ""; - const message = err instanceof Error ? err.message : String(err); - throw new Error( - code - ? `Could not read JSON file "${filePath}" (${code}): ${message}` - : `Could not read JSON file "${filePath}": ${message}`, - ); + parsed = JSON.parse(raw); + } catch (err: any) { + if (isReload) throw new Error(`Parse error: ${err.message}`); + printFatal('JSON_INVALID', err.message); } - this.data = parseJsonStorePayload(raw, filePath); - this.backingPath = filePath; - } + if (!isPlainObject(parsed)) { + const msg = `got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}`; + if (isReload) throw new Error(`Root not an object (${msg})`); + printFatal('JSON_NOT_OBJECT', msg); + } - private _registerLockCleanup(): void { - const cleanup = (): void => { - if (this.lockPath) { - try { - require("fs").unlinkSync(this.lockPath); - } catch (_) {} + const keys = Object.keys(parsed); + if (keys.length === 0) { + printError('JSON_EMPTY'); + } + + const data: JsonData = {}; + for (const [key, value] of Object.entries(parsed)) { + if (!Array.isArray(value)) { + const msg = `property "${key}" must be an array.`; + if (isReload) throw new Error(msg); + printFatal('JSON_INVALID', msg); } - }; - process.once("exit", cleanup); - process.once("SIGINT", () => { cleanup(); process.exit(130); }); - process.once("SIGTERM", () => { cleanup(); process.exit(143); }); + data[key] = value as unknown[]; + } + + this.data = data; + this.backingPath = filePath; } getData(): JsonData {