commit 4c54bfff2642b0fec4d5de255db779c553660958 Author: eshmeshek Date: Sat Mar 14 16:19:48 2026 +0300 Initial commit: kisync CLI client for KIS API Builder CLI tool for syncing local folders with KIS API Builder server. Commands: init, pull, push, status with conflict detection. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73827e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.kisync.json +.kisync-state.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9562d87 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,638 @@ +{ + "name": "kisync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kisync", + "version": "1.0.0", + "dependencies": { + "chalk": "^4.1.2", + "commander": "^11.1.0", + "node-fetch": "^2.7.0" + }, + "bin": { + "kisync": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/node-fetch": "^2.6.9", + "ts-node": "^10.9.2", + "typescript": "^5.3.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..99998b2 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "kisync", + "version": "1.0.0", + "description": "CLI tool for syncing local folders with KIS API Builder", + "main": "dist/index.js", + "bin": { + "kisync": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "ts-node src/index.ts", + "start": "node dist/index.js" + }, + "dependencies": { + "chalk": "^4.1.2", + "commander": "^11.1.0", + "node-fetch": "^2.7.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/node-fetch": "^2.6.9", + "typescript": "^5.3.2", + "ts-node": "^10.9.2" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..088757c --- /dev/null +++ b/src/api.ts @@ -0,0 +1,57 @@ +import fetch from 'node-fetch'; +import { Config } from './config'; + +export class ApiClient { + private baseUrl: string; + private token: string; + + constructor(config: Config) { + this.baseUrl = config.host.replace(/\/$/, ''); + this.token = config.token; + } + + private async request(method: string, path: string, body?: any): Promise { + const url = `${this.baseUrl}${path}`; + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (res.status === 401) { + throw new Error('Authentication failed. Check your token (kisync init to reconfigure).'); + } + + const data = await res.json(); + + if (!res.ok) { + if (res.status === 409) { + // Conflict response — return data with a flag instead of throwing + return { _conflict: true, ...data }; + } + throw new Error(data.error || `HTTP ${res.status}: ${res.statusText}`); + } + + return data; + } + + async pull() { + return this.request('GET', '/api/sync/pull'); + } + + async status(clientState: { endpoints: any[]; folders: any[] }) { + return this.request('POST', '/api/sync/status', clientState); + } + + async push(endpoints: any[], force = false) { + return this.request('POST', '/api/sync/push', { endpoints, force }); + } + + async login(username: string, password: string): Promise { + const data = await this.request('POST', '/api/auth/login', { username, password }); + return data.token; + } +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..4390372 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,101 @@ +import * as readline from 'readline'; +import chalk from 'chalk'; +import { writeConfig, findProjectRoot } from '../config'; +import { ApiClient } from '../api'; + +function ask(question: string, hidden = false): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + if (hidden) { + // For password input - don't echo characters + process.stdout.write(question); + let input = ''; + const stdin = process.stdin; + const wasRaw = stdin.isRaw; + if (stdin.setRawMode) stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf-8'); + + const onData = (char: string) => { + const c = char.toString(); + if (c === '\n' || c === '\r') { + stdin.removeListener('data', onData); + if (stdin.setRawMode) stdin.setRawMode(wasRaw || false); + process.stdout.write('\n'); + rl.close(); + resolve(input); + } else if (c === '\u0003') { + process.exit(); + } else if (c === '\u007f' || c === '\b') { + if (input.length > 0) { + input = input.slice(0, -1); + process.stdout.write('\b \b'); + } + } else { + input += c; + process.stdout.write('*'); + } + }; + + stdin.on('data', onData); + } else { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + } + }); +} + +export async function initCommand(): Promise { + const cwd = process.cwd(); + + const existing = findProjectRoot(cwd); + if (existing) { + console.log(chalk.yellow(`Project already initialized at: ${existing}`)); + const overwrite = await ask('Reinitialize? (y/N): '); + if (overwrite.toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } + } + + console.log(chalk.bold('\nKIS API Builder Sync — Setup\n')); + + const host = await ask('Server URL (e.g. http://localhost:3000): '); + if (!host) { + throw new Error('Server URL is required'); + } + + console.log(chalk.gray('\nLogin to get authentication token:')); + const username = await ask('Username: '); + const password = await ask('Password: ', true); + + if (!username || !password) { + throw new Error('Username and password are required'); + } + + // Try to authenticate + console.log(chalk.gray('\nConnecting...')); + const tempClient = new ApiClient({ host, token: '' }); + let token: string; + try { + token = await tempClient.login(username, password); + } catch (err: any) { + throw new Error(`Authentication failed: ${err.message}`); + } + + writeConfig({ host, token }, cwd); + + console.log(chalk.green('\nProject initialized successfully!')); + console.log(chalk.gray(`Config saved to: ${cwd}/.kisync.json`)); + console.log(''); + console.log('Next steps:'); + console.log(` ${chalk.cyan('kisync pull')} — download endpoints from server`); + console.log(` ${chalk.cyan('kisync status')} — check what changed`); + console.log(` ${chalk.cyan('kisync push')} — upload your changes`); +} diff --git a/src/commands/pull.ts b/src/commands/pull.ts new file mode 100644 index 0000000..dc0b957 --- /dev/null +++ b/src/commands/pull.ts @@ -0,0 +1,302 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import chalk from 'chalk'; +import { readConfig, readState, writeState, getProjectRoot, SyncState } from '../config'; +import { ApiClient } from '../api'; +import { + sanitizeName, + buildFolderPath, + writeEndpointToDisk, + findEndpointDirs, + readEndpointFromDisk, +} from '../files'; +import { computeEndpointHash } from '../hash'; + +function ask(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +interface PullDiff { + newEndpoints: { name: string; folder: string }[]; + updatedEndpoints: { name: string; folder: string; serverDate: string }[]; + deletedEndpoints: { name: string; folder: string }[]; + unchangedCount: number; + localConflicts: { name: string; folder: string }[]; // locally modified AND server modified +} + +export async function pullCommand(force = false): Promise { + const root = getProjectRoot(); + const config = readConfig(root); + const state = readState(root); + const api = new ApiClient(config); + + console.log(chalk.gray('Pulling from server...')); + + const data = await api.pull(); + const { endpoints, folders } = data; + + console.log( + chalk.gray( + `Server: ${endpoints.length} endpoints, ${folders.length} folders` + ) + ); + + // Build folders map + const foldersMap = new Map(); + for (const f of folders) { + foldersMap.set(f.id, f); + } + + // Detect local modifications + const localModifiedIds = new Set(); + if (state.last_sync) { + const endpointDirs = findEndpointDirs(root); + for (const dir of endpointDirs) { + const ep = readEndpointFromDisk(dir); + if (!ep || !ep.id) continue; + const stateEntry = state.endpoints[ep.id]; + if (!stateEntry || !stateEntry.hash) continue; + const currentHash = computeEndpointHash(ep); + if (currentHash !== stateEntry.hash) { + localModifiedIds.add(ep.id); + } + } + } + + // Build diff preview + const diff: PullDiff = { + newEndpoints: [], + updatedEndpoints: [], + deletedEndpoints: [], + unchangedCount: 0, + localConflicts: [], + }; + + for (const ep of endpoints) { + const folderName = ep.folder_name || '_no_folder'; + + if (!state.endpoints[ep.id]) { + diff.newEndpoints.push({ name: ep.name, folder: folderName }); + } else { + const serverTime = new Date(ep.updated_at).getTime(); + const stateTime = new Date(state.endpoints[ep.id].updated_at).getTime(); + + if (serverTime > stateTime) { + // Server has a newer version + if (localModifiedIds.has(ep.id)) { + // CONFLICT: both local and server changed + diff.localConflicts.push({ name: ep.name, folder: folderName }); + } else { + diff.updatedEndpoints.push({ + name: ep.name, + folder: folderName, + serverDate: ep.updated_at, + }); + } + } else { + diff.unchangedCount++; + } + } + } + + // Detect server deletions + const serverIds = new Set(endpoints.map((e: any) => e.id)); + for (const [id, info] of Object.entries(state.endpoints)) { + if (!serverIds.has(id)) { + const dirName = path.basename(info.folder_path); + const parentName = path.basename(path.dirname(info.folder_path)); + diff.deletedEndpoints.push({ name: dirName, folder: parentName }); + } + } + + // Show preview + const totalChanges = + diff.newEndpoints.length + + diff.updatedEndpoints.length + + diff.deletedEndpoints.length + + diff.localConflicts.length; + + if (totalChanges === 0) { + console.log(chalk.green('\nEverything is up to date.')); + return; + } + + console.log(chalk.bold('\nIncoming changes from server:\n')); + + for (const item of diff.newEndpoints) { + console.log(chalk.green(` + new: ${item.folder}/${item.name}`)); + } + for (const item of diff.updatedEndpoints) { + console.log(chalk.blue(` ~ updated: ${item.folder}/${item.name}`)); + console.log(chalk.gray(` server updated: ${new Date(item.serverDate).toLocaleString()}`)); + } + for (const item of diff.deletedEndpoints) { + console.log(chalk.red(` - deleted: ${item.folder}/${item.name}`)); + } + for (const item of diff.localConflicts) { + console.log(chalk.redBright(` ! CONFLICT: ${item.folder}/${item.name}`)); + console.log(chalk.redBright(` changed locally AND on server`)); + } + + if (diff.unchangedCount > 0) { + console.log(chalk.gray(`\n ${diff.unchangedCount} unchanged`)); + } + + // Handle conflicts + if (diff.localConflicts.length > 0 && !force) { + console.log( + chalk.yellow( + `\n${diff.localConflicts.length} conflict(s): you edited these locally, but they were also changed on the server.` + ) + ); + console.log(chalk.yellow('Options:')); + console.log(chalk.yellow(' 1) "kisync pull --force" — overwrite your local changes with server version')); + console.log(chalk.yellow(' 2) "kisync push" — push your changes first (server version will be overwritten)')); + console.log(chalk.yellow(' 3) "kisync push --force" — force push if server also changed')); + return; + } + + // If there are locally modified files that DON'T conflict (server hasn't changed them), + // those are safe — pull won't touch them. But warn if force is used. + const safeLocalModified = [...localModifiedIds].filter( + (id) => !diff.localConflicts.find((c) => { + const ep = endpoints.find((e: any) => e.id === id); + return ep && c.name === ep.name; + }) + ); + + if (force && safeLocalModified.length > 0) { + console.log( + chalk.yellow(`\n--force: ${safeLocalModified.length} locally modified endpoint(s) will be overwritten.`) + ); + } + + // Confirm + if (!force) { + const confirm = await ask('\nApply these changes? (y/N): '); + if (confirm.toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } + } + + // Apply changes + const newState: SyncState = { endpoints: {}, folders: {}, last_sync: '' }; + + // Create folder structure + for (const folder of folders) { + const folderPath = buildFolderPath(folder.id, foldersMap, root); + fs.mkdirSync(folderPath, { recursive: true }); + + const folderMeta = { + id: folder.id, + name: folder.name, + parent_id: folder.parent_id, + }; + fs.writeFileSync( + path.join(folderPath, '_folder.json'), + JSON.stringify(folderMeta, null, 2), + 'utf-8' + ); + + newState.folders[folder.id] = { + updated_at: folder.updated_at, + path: path.relative(root, folderPath), + }; + } + + // Write endpoints + let applied = 0; + let skipped = 0; + + for (const ep of endpoints) { + let endpointDir: string; + if (ep.folder_id && foldersMap.has(ep.folder_id)) { + const folderPath = buildFolderPath(ep.folder_id, foldersMap, root); + endpointDir = path.join(folderPath, sanitizeName(ep.name)); + } else { + endpointDir = path.join(root, '_no_folder', sanitizeName(ep.name)); + } + + const isConflict = diff.localConflicts.some((c) => c.name === ep.name); + + // Skip conflicts unless force + if (isConflict && !force) { + // Keep local version in state but mark with server's updated_at + if (state.endpoints[ep.id]) { + newState.endpoints[ep.id] = state.endpoints[ep.id]; + } + skipped++; + continue; + } + + // If endpoint moved to different folder, clean up old location + if (state.endpoints[ep.id]) { + const oldRelPath = state.endpoints[ep.id].folder_path; + const oldAbsPath = path.join(root, oldRelPath); + const newRelPath = path.relative(root, endpointDir); + if (oldRelPath !== newRelPath && fs.existsSync(oldAbsPath)) { + fs.rmSync(oldAbsPath, { recursive: true, force: true }); + } + } + + writeEndpointToDisk(ep, endpointDir); + + const hash = computeEndpointHash(ep); + newState.endpoints[ep.id] = { + updated_at: ep.updated_at, + folder_path: path.relative(root, endpointDir), + hash, + }; + + applied++; + } + + // Clean up endpoints deleted on server + let deleted = 0; + for (const [id, info] of Object.entries(state.endpoints)) { + if (!serverIds.has(id)) { + const oldPath = path.join(root, info.folder_path); + if (fs.existsSync(oldPath)) { + fs.rmSync(oldPath, { recursive: true, force: true }); + deleted++; + } + } + } + + cleanEmptyDirs(root); + + newState.last_sync = new Date().toISOString(); + writeState(newState, root); + + // Summary + console.log(''); + console.log(chalk.green(`Pull complete: ${applied} applied, ${deleted} deleted, ${skipped} skipped.`)); +} + +function cleanEmptyDirs(dir: string): void { + if (!fs.existsSync(dir)) return; + const entries = fs.readdirSync(dir); + + for (const entry of entries) { + const fullPath = path.join(dir, entry); + if (fs.statSync(fullPath).isDirectory()) { + cleanEmptyDirs(fullPath); + } + } + + const remaining = fs.readdirSync(dir); + if (remaining.length === 0 && dir !== process.cwd()) { + fs.rmdirSync(dir); + } +} diff --git a/src/commands/push.ts b/src/commands/push.ts new file mode 100644 index 0000000..1c0d7b9 --- /dev/null +++ b/src/commands/push.ts @@ -0,0 +1,117 @@ +import * as path from 'path'; +import * as readline from 'readline'; +import chalk from 'chalk'; +import { readConfig, readState, writeState, getProjectRoot } from '../config'; +import { ApiClient } from '../api'; +import { findEndpointDirs, readEndpointFromDisk } from '../files'; +import { computeEndpointHash } from '../hash'; + +function ask(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +export async function pushCommand(force = false): Promise { + const root = getProjectRoot(); + const config = readConfig(root); + const state = readState(root); + const api = new ApiClient(config); + + if (!state.last_sync) { + console.log(chalk.yellow('No sync history. Run "kisync pull" first.')); + return; + } + + // Find locally modified endpoints + const endpointDirs = findEndpointDirs(root); + const modified: { ep: any; dir: string }[] = []; + + for (const dir of endpointDirs) { + const ep = readEndpointFromDisk(dir); + if (!ep || !ep.id) continue; + + const stateEntry = state.endpoints[ep.id]; + if (!stateEntry) continue; + + const currentHash = computeEndpointHash(ep); + if (stateEntry.hash && currentHash !== stateEntry.hash) { + modified.push({ ep, dir }); + } + } + + if (modified.length === 0) { + console.log(chalk.green('Nothing to push. All endpoints match server state.')); + return; + } + + // Show what will be pushed + console.log(chalk.bold(`\n${modified.length} endpoint(s) to push:\n`)); + for (const { ep, dir } of modified) { + console.log(chalk.yellow(` ~ ${ep.name}`)); + console.log(chalk.gray(` ${path.relative(root, dir)}`)); + } + console.log(''); + + if (!force) { + const confirm = await ask('Push these changes? (y/N): '); + if (confirm.toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } + } + + // Prepare push payload — include _base_updated_at for conflict detection + const pushEndpoints = modified.map(({ ep }) => { + const stateEntry = state.endpoints[ep.id]; + return { + ...ep, + _base_updated_at: stateEntry ? stateEntry.updated_at : undefined, + }; + }); + + console.log(chalk.gray('\nPushing to server...')); + + const result = await api.push(pushEndpoints, force); + + // Handle conflicts + if (result._conflict) { + console.log(chalk.red('\nConflicts detected! These endpoints were modified on the server:')); + console.log(chalk.gray('(Someone else changed them since your last sync)\n')); + for (const c of result.conflicts || []) { + console.log(chalk.red(` ${c.name}`)); + console.log(chalk.gray(` server updated: ${c.server_updated_at}`)); + console.log(chalk.gray(` your base: ${c.client_base_updated_at}`)); + } + if (result.applied && result.applied.length > 0) { + console.log(chalk.green(`\n${result.applied.length} non-conflicting endpoint(s) were pushed.`)); + } + console.log(chalk.yellow('\nUse "kisync push --force" to overwrite server changes.')); + console.log(chalk.yellow('Or run "kisync pull --force" to get the latest version first.')); + return; + } + + // Update state with new updated_at from server + for (const r of result.results) { + const serverEp = r.endpoint; + if (serverEp && state.endpoints[serverEp.id]) { + const localEp = modified.find((m) => m.ep.id === serverEp.id); + state.endpoints[serverEp.id].updated_at = serverEp.updated_at; + state.endpoints[serverEp.id].hash = localEp + ? computeEndpointHash(localEp.ep) + : state.endpoints[serverEp.id].hash; + } + } + + state.last_sync = new Date().toISOString(); + writeState(state, root); + + console.log(chalk.green(`\nPush complete. ${result.results.length} endpoint(s) updated.`)); +} diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..cbdf816 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,119 @@ +import * as path from 'path'; +import chalk from 'chalk'; +import { readConfig, readState, getProjectRoot } from '../config'; +import { ApiClient } from '../api'; +import { findEndpointDirs, readEndpointFromDisk } from '../files'; +import { computeEndpointHash } from '../hash'; + +export async function statusCommand(): Promise { + const root = getProjectRoot(); + const config = readConfig(root); + const state = readState(root); + const api = new ApiClient(config); + + if (!state.last_sync) { + console.log(chalk.yellow('No sync history. Run "kisync pull" first.')); + return; + } + + console.log(chalk.gray(`Last sync: ${state.last_sync}\n`)); + + // 1) Detect local changes + const localModified: string[] = []; + const localNew: string[] = []; + + const endpointDirs = findEndpointDirs(root); + const knownIds = new Set(Object.keys(state.endpoints)); + const foundIds = new Set(); + + for (const dir of endpointDirs) { + const ep = readEndpointFromDisk(dir); + if (!ep) continue; + + if (ep.id && knownIds.has(ep.id)) { + foundIds.add(ep.id); + const stateEntry = state.endpoints[ep.id]; + if (stateEntry && stateEntry.hash) { + const currentHash = computeEndpointHash(ep); + if (currentHash !== stateEntry.hash) { + localModified.push(`${path.relative(root, dir)} (${ep.name})`); + } + } + } else if (!ep.id) { + // New local endpoint (no id assigned yet) + localNew.push(`${path.relative(root, dir)} (${ep.name || 'unnamed'})`); + } + } + + // Locally deleted (existed in state but no longer on disk) + const localDeleted: string[] = []; + for (const [id, info] of Object.entries(state.endpoints)) { + if (!foundIds.has(id)) { + localDeleted.push(`${info.folder_path}`); + } + } + + // 2) Check server changes + const clientEndpoints = Object.entries(state.endpoints).map(([id, info]) => ({ + id, + updated_at: info.updated_at, + })); + const clientFolders = Object.entries(state.folders).map(([id, info]) => ({ + id, + updated_at: info.updated_at, + })); + + console.log(chalk.gray('Checking server...')); + const serverStatus = await api.status({ endpoints: clientEndpoints, folders: clientFolders }); + + // 3) Display results + const hasLocalChanges = localModified.length > 0 || localNew.length > 0 || localDeleted.length > 0; + const hasServerChanges = + serverStatus.endpoints.changed.length > 0 || + serverStatus.endpoints.new.length > 0 || + serverStatus.endpoints.deleted.length > 0; + + if (!hasLocalChanges && !hasServerChanges) { + console.log(chalk.green('\nEverything is in sync.')); + return; + } + + // Local changes + if (hasLocalChanges) { + console.log(chalk.bold('\nLocal changes (not pushed):')); + for (const item of localModified) { + console.log(chalk.yellow(` modified: ${item}`)); + } + for (const item of localNew) { + console.log(chalk.green(` new: ${item}`)); + } + for (const item of localDeleted) { + console.log(chalk.red(` deleted: ${item}`)); + } + } + + // Server changes + if (hasServerChanges) { + console.log(chalk.bold('\nServer changes (not pulled):')); + for (const item of serverStatus.endpoints.changed) { + console.log(chalk.blue(` modified: ${item.name}`)); + } + for (const item of serverStatus.endpoints.new) { + console.log(chalk.green(` new: ${item.name}`)); + } + for (const item of serverStatus.endpoints.deleted) { + console.log(chalk.red(` deleted: id=${item.id}`)); + } + } + + // Conflicts warning + if (hasLocalChanges && hasServerChanges) { + console.log(chalk.yellow('\nBoth local and server have changes!')); + console.log(chalk.gray(' Push first to send your changes, then pull to get server updates.')); + console.log(chalk.gray(' Or use --force on either to overwrite.')); + } else if (hasLocalChanges) { + console.log(chalk.gray('\nRun "kisync push" to upload your changes.')); + } else { + console.log(chalk.gray('\nRun "kisync pull" to download server changes.')); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..1c4497e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,66 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface Config { + host: string; + token: string; +} + +export interface SyncState { + endpoints: Record; + folders: Record; + last_sync: string; +} + +const CONFIG_FILE = '.kisync.json'; +const STATE_FILE = '.kisync-state.json'; + +export function findProjectRoot(startDir: string = process.cwd()): string | null { + let dir = startDir; + while (true) { + if (fs.existsSync(path.join(dir, CONFIG_FILE))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +export function getProjectRoot(): string { + const root = findProjectRoot(); + if (!root) { + throw new Error( + 'Not a kisync project. Run "kisync init" first to initialize.' + ); + } + return root; +} + +export function readConfig(projectRoot?: string): Config { + const root = projectRoot || getProjectRoot(); + const configPath = path.join(root, CONFIG_FILE); + const raw = fs.readFileSync(configPath, 'utf-8'); + return JSON.parse(raw); +} + +export function writeConfig(config: Config, projectRoot: string): void { + const configPath = path.join(projectRoot, CONFIG_FILE); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); +} + +export function readState(projectRoot?: string): SyncState { + const root = projectRoot || getProjectRoot(); + const statePath = path.join(root, STATE_FILE); + if (!fs.existsSync(statePath)) { + return { endpoints: {}, folders: {}, last_sync: '' }; + } + const raw = fs.readFileSync(statePath, 'utf-8'); + return JSON.parse(raw); +} + +export function writeState(state: SyncState, projectRoot?: string): void { + const root = projectRoot || getProjectRoot(); + const statePath = path.join(root, STATE_FILE); + fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8'); +} diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 0000000..e481e90 --- /dev/null +++ b/src/files.ts @@ -0,0 +1,328 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Sanitize a name for use as a folder/file name + */ +export function sanitizeName(name: string): string { + return name.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, ' ').trim(); +} + +/** + * Build the full folder path for a folder, resolving parent chain + */ +export function buildFolderPath( + folderId: string, + foldersMap: Map, + projectRoot: string +): string { + const parts: string[] = []; + let currentId: string | null = folderId; + + while (currentId) { + const folder = foldersMap.get(currentId); + if (!folder) break; + parts.unshift(sanitizeName(folder.name)); + currentId = folder.parent_id; + } + + return path.join(projectRoot, ...parts); +} + +/** + * Write an endpoint to disk as a set of files + */ +export function writeEndpointToDisk( + ep: any, + endpointDir: string +): void { + fs.mkdirSync(endpointDir, { recursive: true }); + + // endpoint.json — metadata (everything except code/queries content) + const meta: any = { + id: ep.id, + name: ep.name, + description: ep.description || '', + method: ep.method, + path: ep.path, + execution_type: ep.execution_type || 'sql', + database_name: ep.database_name || null, + database_type: ep.database_type || null, + database_id: ep.database_id || null, + parameters: ep.parameters || [], + is_public: ep.is_public || false, + enable_logging: ep.enable_logging || false, + detailed_response: ep.detailed_response || false, + response_schema: ep.response_schema || null, + folder_id: ep.folder_id || null, + folder_name: ep.folder_name || null, + updated_at: ep.updated_at, + created_at: ep.created_at, + }; + + fs.writeFileSync( + path.join(endpointDir, 'endpoint.json'), + JSON.stringify(meta, null, 2), + 'utf-8' + ); + + // SQL query file + if (ep.execution_type === 'sql' && ep.sql_query) { + fs.writeFileSync(path.join(endpointDir, 'query.sql'), ep.sql_query, 'utf-8'); + } + + // Script file + if (ep.execution_type === 'script' && ep.script_code) { + const ext = ep.script_language === 'python' ? 'py' : 'js'; + fs.writeFileSync(path.join(endpointDir, `main.${ext}`), ep.script_code, 'utf-8'); + } + + // Script queries + const scriptQueries = ep.script_queries || []; + if (ep.execution_type === 'script' && scriptQueries.length > 0) { + const queriesDir = path.join(endpointDir, 'queries'); + fs.mkdirSync(queriesDir, { recursive: true }); + + // Write an index file with database mappings for each query + const queryIndex: any[] = []; + + for (const sq of scriptQueries) { + const sqMeta: any = { + name: sq.name, + database_name: sq.database_name || null, + database_type: sq.database_type || null, + database_id: sq.database_id || null, + }; + + if (sq.sql) { + const fileName = `${sanitizeName(sq.name)}.sql`; + fs.writeFileSync(path.join(queriesDir, fileName), sq.sql, 'utf-8'); + sqMeta.file = fileName; + } + + // AQL-type script query + if (sq.aql_method) { + const fileName = `${sanitizeName(sq.name)}.http`; + const httpContent = buildHttpFile( + sq.aql_method, + sq.aql_endpoint || '', + sq.aql_body || '', + sq.aql_query_params || {} + ); + fs.writeFileSync(path.join(queriesDir, fileName), httpContent, 'utf-8'); + sqMeta.file = fileName; + sqMeta.type = 'aql'; + } + + queryIndex.push(sqMeta); + } + + fs.writeFileSync( + path.join(queriesDir, '_index.json'), + JSON.stringify(queryIndex, null, 2), + 'utf-8' + ); + } + + // AQL endpoint as .http file + if (ep.execution_type === 'aql') { + const httpContent = buildHttpFile( + ep.aql_method || 'GET', + ep.aql_endpoint || '', + ep.aql_body || '', + ep.aql_query_params || {} + ); + fs.writeFileSync(path.join(endpointDir, 'request.http'), httpContent, 'utf-8'); + } +} + +/** + * Build .http file content from AQL params + */ +function buildHttpFile( + method: string, + endpoint: string, + body: string, + queryParams: Record +): string { + let url = endpoint; + + // Append query params + const qp = Object.entries(queryParams || {}); + if (qp.length > 0) { + const params = qp.map(([k, v]) => `${k}=${v}`).join('&'); + url += (url.includes('?') ? '&' : '?') + params; + } + + let content = `${method} ${url}\n`; + content += `Content-Type: application/json\n`; + + if (body) { + content += `\n${body}\n`; + } + + return content; +} + +/** + * Parse .http file back to AQL params + */ +export function parseHttpFile(content: string): { + method: string; + endpoint: string; + body: string; + queryParams: Record; +} { + const lines = content.split('\n'); + let method = 'GET'; + let endpoint = ''; + let body = ''; + const queryParams: Record = {}; + + // First non-empty line is the request line + let requestLineFound = false; + let headersEnded = false; + const bodyLines: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (!requestLineFound) { + if (trimmed === '') continue; + // Parse "METHOD URL" + const match = trimmed.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(.+)$/i); + if (match) { + method = match[1].toUpperCase(); + let url = match[2].trim(); + + // Extract query params from URL + const qIdx = url.indexOf('?'); + if (qIdx !== -1) { + const queryString = url.substring(qIdx + 1); + url = url.substring(0, qIdx); + for (const pair of queryString.split('&')) { + const eqIdx = pair.indexOf('='); + if (eqIdx !== -1) { + queryParams[pair.substring(0, eqIdx)] = pair.substring(eqIdx + 1); + } + } + } + endpoint = url; + requestLineFound = true; + } + continue; + } + + // Skip headers until empty line + if (!headersEnded) { + if (trimmed === '') { + headersEnded = true; + continue; + } + // Skip header lines (e.g. Content-Type: ...) + continue; + } + + // Everything after empty line is body + bodyLines.push(line); + } + + body = bodyLines.join('\n').trim(); + + return { method, endpoint, body, queryParams }; +} + +/** + * Read an endpoint from disk back to API format + */ +export function readEndpointFromDisk(endpointDir: string): any | null { + const metaPath = path.join(endpointDir, 'endpoint.json'); + if (!fs.existsSync(metaPath)) return null; + + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + const ep: any = { ...meta }; + + // Read SQL query + const sqlPath = path.join(endpointDir, 'query.sql'); + if (fs.existsSync(sqlPath)) { + ep.sql_query = fs.readFileSync(sqlPath, 'utf-8'); + } + + // Read script code + for (const ext of ['js', 'py']) { + const scriptPath = path.join(endpointDir, `main.${ext}`); + if (fs.existsSync(scriptPath)) { + ep.script_code = fs.readFileSync(scriptPath, 'utf-8'); + ep.script_language = ext === 'py' ? 'python' : 'javascript'; + break; + } + } + + // Read script queries + const queriesIndexPath = path.join(endpointDir, 'queries', '_index.json'); + if (fs.existsSync(queriesIndexPath)) { + const queryIndex = JSON.parse(fs.readFileSync(queriesIndexPath, 'utf-8')); + ep.script_queries = queryIndex.map((sq: any) => { + const result: any = { + name: sq.name, + database_id: sq.database_id || undefined, + }; + + if (sq.file) { + const filePath = path.join(endpointDir, 'queries', sq.file); + if (fs.existsSync(filePath)) { + if (sq.type === 'aql' || sq.file.endsWith('.http')) { + const parsed = parseHttpFile(fs.readFileSync(filePath, 'utf-8')); + result.aql_method = parsed.method; + result.aql_endpoint = parsed.endpoint; + result.aql_body = parsed.body; + result.aql_query_params = parsed.queryParams; + } else { + result.sql = fs.readFileSync(filePath, 'utf-8'); + } + } + } + + return result; + }); + } + + // Read AQL .http file + const httpPath = path.join(endpointDir, 'request.http'); + if (fs.existsSync(httpPath)) { + const parsed = parseHttpFile(fs.readFileSync(httpPath, 'utf-8')); + ep.aql_method = parsed.method; + ep.aql_endpoint = parsed.endpoint; + ep.aql_body = parsed.body; + ep.aql_query_params = parsed.queryParams; + } + + return ep; +} + +/** + * Find all endpoint directories recursively + */ +export function findEndpointDirs(dir: string): string[] { + const results: string[] = []; + + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + + const fullPath = path.join(dir, entry.name); + + if (fs.existsSync(path.join(fullPath, 'endpoint.json'))) { + results.push(fullPath); + } else { + // Could be a folder directory, recurse + results.push(...findEndpointDirs(fullPath)); + } + } + + return results; +} diff --git a/src/hash.ts b/src/hash.ts new file mode 100644 index 0000000..15b6346 --- /dev/null +++ b/src/hash.ts @@ -0,0 +1,42 @@ +import * as crypto from 'crypto'; + +/** + * Compute a stable hash of endpoint content for change detection. + * Only hashes the content that matters (code, queries, config), not timestamps. + */ +export function computeEndpointHash(ep: any): string { + const significant = { + name: ep.name || '', + description: ep.description || '', + method: ep.method || '', + path: ep.path || '', + execution_type: ep.execution_type || 'sql', + database_id: ep.database_id || '', + sql_query: ep.sql_query || '', + parameters: JSON.stringify(ep.parameters || []), + script_language: ep.script_language || '', + script_code: ep.script_code || '', + script_queries: JSON.stringify( + (ep.script_queries || []).map((sq: any) => ({ + name: sq.name, + sql: sq.sql || '', + database_id: sq.database_id || '', + aql_method: sq.aql_method || '', + aql_endpoint: sq.aql_endpoint || '', + aql_body: sq.aql_body || '', + aql_query_params: JSON.stringify(sq.aql_query_params || {}), + })) + ), + aql_method: ep.aql_method || '', + aql_endpoint: ep.aql_endpoint || '', + aql_body: ep.aql_body || '', + aql_query_params: JSON.stringify(ep.aql_query_params || {}), + is_public: String(ep.is_public || false), + enable_logging: String(ep.enable_logging || false), + detailed_response: String(ep.detailed_response || false), + response_schema: JSON.stringify(ep.response_schema || null), + }; + + const content = JSON.stringify(significant); + return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8032928 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { initCommand } from './commands/init'; +import { pullCommand } from './commands/pull'; +import { pushCommand } from './commands/push'; +import { statusCommand } from './commands/status'; + +const program = new Command(); + +program + .name('kisync') + .description('CLI tool for syncing local folders with KIS API Builder') + .version('1.0.0'); + +program + .command('init') + .description('Initialize a new kisync project in the current directory') + .action(async () => { + try { + await initCommand(); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +program + .command('pull') + .description('Download all endpoints from server to local files') + .option('--force', 'Overwrite local changes without prompting') + .action(async (opts) => { + try { + await pullCommand(opts.force); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +program + .command('push') + .description('Upload local changes to server') + .option('--force', 'Force push, overwriting server changes') + .action(async (opts) => { + try { + await pushCommand(opts.force); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +program + .command('status') + .description('Show sync status — what changed locally and on server') + .action(async () => { + try { + await statusCommand(); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +program.parse(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..73994fa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true + }, + "include": ["src/**/*"] +}