From 7e036157f892ef37c631864e6b288fa931463dd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 08:12:30 +0000 Subject: [PATCH] feat(cli): add login, whoami, and cloud import commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add auth.ts: loadApiKey/saveApiKey/clearApiKey with 0600 config file permissions; reads NOMARK_TOKEN env var or ~/.nomark/config.json api_key - Add http.ts: cloudImport and probeAuth using process-import edge function - Add login.ts: interactive key entry + NOMARK_TOKEN env var flow - Add whoami.ts: probe-based auth check with uid/email display - Update import.ts: cloud mode (POST to edge) when authenticated, local mode unchanged; fix dryRun bug (was always true when flag absent) - Update index.ts: async main, login/whoami commands, VERSION 0.2.0 - Add README sections: Install, Authenticate, Import, Managing API Keys - Tests: auth.test.ts (15), http.test.ts (12) — all 288 tests pass - Coverage: 84.11% lines on src/ (threshold: 80%) --- package-lock.json | 808 +++++++++++++++++++++++++ packages/engine/README.md | 75 ++- packages/engine/package.json | 1 + packages/engine/src/cli/auth.ts | 55 ++ packages/engine/src/cli/http.ts | 65 ++ packages/engine/src/cli/import.ts | 55 +- packages/engine/src/cli/index.ts | 70 ++- packages/engine/src/cli/login.ts | 40 ++ packages/engine/src/cli/whoami.ts | 22 + packages/engine/tests/cli/auth.test.ts | 158 +++++ packages/engine/tests/cli/http.test.ts | 180 ++++++ 11 files changed, 1489 insertions(+), 40 deletions(-) create mode 100644 packages/engine/src/cli/auth.ts create mode 100644 packages/engine/src/cli/http.ts create mode 100644 packages/engine/src/cli/login.ts create mode 100644 packages/engine/src/cli/whoami.ts create mode 100644 packages/engine/tests/cli/auth.test.ts create mode 100644 packages/engine/tests/cli/http.test.ts diff --git a/package-lock.json b/package-lock.json index 1bfbbe2..3f489cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,70 @@ "packages/create-nomark-engine" ] }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -453,6 +517,34 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -500,6 +592,17 @@ "resolved": "packages/pro", "link": true }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1007,6 +1110,32 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1024,6 +1153,29 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1093,6 +1245,26 @@ "url": "https://paulmillr.com/funding/" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1120,6 +1292,21 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1148,6 +1335,20 @@ "node": ">=6" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1247,6 +1448,23 @@ "rollup": "^4.34.8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1262,6 +1480,165 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1309,6 +1686,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1319,6 +1703,48 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -1384,6 +1810,40 @@ "node": ">=0.10.0" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1584,6 +2044,42 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1591,6 +2087,19 @@ "dev": true, "license": "ISC" }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1625,6 +2134,110 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -1648,6 +2261,34 @@ "node": ">=16 || 14 >=14.17" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2423,6 +3064,22 @@ "dev": true, "license": "MIT" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2440,6 +3097,104 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -2476,6 +3231,7 @@ }, "devDependencies": { "@types/node": "^25.5.2", + "@vitest/coverage-v8": "^2.0.0", "tsup": "^8.0.0", "typescript": "^5.5.0", "vitest": "^2.0.0" @@ -2492,6 +3248,58 @@ } } }, + "packages/engine/node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "packages/engine/node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "packages/engine/node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "packages/pro": { "name": "@nomark-ai/pro", "version": "0.2.0", diff --git a/packages/engine/README.md b/packages/engine/README.md index a5b1256..b590bd1 100644 --- a/packages/engine/README.md +++ b/packages/engine/README.md @@ -109,9 +109,82 @@ Includes offline queue, conflict resolution, privacy filtering, and merge strate ## CLI +The `nomark` CLI imports your AI conversation exports into your NOMARK preference ledger. + +### Install + +```bash +npm install -g @nomark-ai/engine +# or use without installing: +npx nomark +``` + +Requires Node.js 18+. + +### Authenticate + +Get an API key from [nomark.ai](https://nomark.ai) and run: + +```bash +npx nomark login +``` + +You will be prompted for your key (`nomark_sk_...`). Alternatively, set the `NOMARK_TOKEN` environment variable: + +```bash +export NOMARK_TOKEN=nomark_sk_... +npx nomark login +``` + +The key is saved to `~/.nomark/config.json` with mode 0600. + +Verify authentication: + +```bash +npx nomark whoami +``` + +### Import + +Export your conversation history from ChatGPT, Claude, or Gemini, then import: + +```bash +# ChatGPT — export from Settings → Data Controls → Export Data +npx nomark import --platform chatgpt --file conversations.json + +# Claude — export from Settings → Privacy → Export Data +npx nomark import --platform claude --file claude-export.json + +# Gemini — export via Google Takeout +npx nomark import --platform gemini --file takeout.json +``` + +Use `--dry-run` to preview without writing changes (local mode only): + +```bash +npx nomark import --platform chatgpt --file conversations.json --dry-run +``` + +Use `--ledger` to specify a custom ledger path (default: `./nomark-ledger.jsonl`): + +```bash +npx nomark import --platform chatgpt --file conversations.json --ledger ~/my-ledger.jsonl +``` + +### Managing API Keys + +| Action | Command | +|--------|---------| +| Save key interactively | `npx nomark login` | +| Save key from env var | `NOMARK_TOKEN=nomark_sk_... npx nomark login` | +| Check current user | `npx nomark whoami` | + +The API key is stored in `~/.nomark/config.json` under the `api_key` field with file permissions set to 0600 (owner read/write only). It is never logged or echoed. + +To remove the key, delete the `api_key` field from `~/.nomark/config.json`. + ```bash npx nomark profile # Show resolved preference profile -npx nomark import # Import from ChatGPT/Claude exports npx nomark review # Review and manage ledger entries ``` diff --git a/packages/engine/package.json b/packages/engine/package.json index fa80d32..dd24a29 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@types/node": "^25.5.2", + "@vitest/coverage-v8": "^2.0.0", "tsup": "^8.0.0", "typescript": "^5.5.0", "vitest": "^2.0.0" diff --git a/packages/engine/src/cli/auth.ts b/packages/engine/src/cli/auth.ts new file mode 100644 index 0000000..82dfc3a --- /dev/null +++ b/packages/engine/src/cli/auth.ts @@ -0,0 +1,55 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +function getConfigDir(): string { + return path.join(os.homedir(), '.nomark') +} + +function getConfigPath(): string { + return path.join(getConfigDir(), 'config.json') +} + +export function loadApiKey(): string | undefined { + const envKey = process.env['NOMARK_TOKEN'] + if (envKey) return envKey + const configPath = getConfigPath() + if (!fs.existsSync(configPath)) return undefined + try { + const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + if (typeof raw['api_key'] === 'string') return raw['api_key'] + } catch { + // malformed JSON or read error — treat as missing + } + return undefined +} + +export function saveApiKey(key: string): void { + const configDir = getConfigDir() + const configPath = getConfigPath() + fs.mkdirSync(configDir, { recursive: true }) + let existing: Record = {} + if (fs.existsSync(configPath)) { + try { + existing = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + } catch { + // malformed — start fresh but keep file + } + } + existing['api_key'] = key + fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n') + fs.chmodSync(configPath, 0o600) +} + +export function clearApiKey(): void { + const configPath = getConfigPath() + if (!fs.existsSync(configPath)) return + try { + const existing = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + delete existing['api_key'] + fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n') + fs.chmodSync(configPath, 0o600) + } catch { + // ignore read/parse errors + } +} diff --git a/packages/engine/src/cli/http.ts b/packages/engine/src/cli/http.ts new file mode 100644 index 0000000..aa079c9 --- /dev/null +++ b/packages/engine/src/cli/http.ts @@ -0,0 +1,65 @@ +const EDGE_FUNCTION_URL = + 'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import' + +export type ImportResult = { + platform: string + conversationsAnalyzed: number + signalsExtracted: number + signalsPromoted: number + byConfidence: { high: number; medium: number; low: number } +} + +export type AuthProbeResult = { + authenticated: boolean + uid?: string + email?: string +} + +export async function cloudImport( + apiKey: string, + platform: string, + data: unknown +): Promise { + const res = await fetch(EDGE_FUNCTION_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ platform, data }), + }) + if (!res.ok) { + const body = await res.text() + throw new Error(`HTTP ${res.status}: ${body}`) + } + return res.json() as Promise +} + +export async function probeAuth(apiKey: string): Promise { + let res: Response + try { + res = await fetch(EDGE_FUNCTION_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ platform: 'probe', data: null }), + }) + } catch (err) { + throw new Error( + `Network error: ${err instanceof Error ? err.message : String(err)}` + ) + } + if (res.status === 401) return { authenticated: false } + try { + const body = (await res.json()) as Record + return { + authenticated: true, + uid: typeof body['uid'] === 'string' ? body['uid'] : undefined, + email: typeof body['email'] === 'string' ? body['email'] : undefined, + } + } catch { + return { authenticated: true } + } +} diff --git a/packages/engine/src/cli/import.ts b/packages/engine/src/cli/import.ts index 59da3b2..05566af 100644 --- a/packages/engine/src/cli/import.ts +++ b/packages/engine/src/cli/import.ts @@ -6,17 +6,17 @@ import { runMigration } from '../importers/pipeline.js' import { writeLedger, parseLedger } from '../ledger.js' import type { Conversation } from '../importers/types.js' import { requireFlag } from './args.js' +import { loadApiKey } from './auth.js' +import { cloudImport } from './http.js' const DEFAULT_LEDGER = './nomark-ledger.jsonl' -export function importCommand(flags: Record): void { +export async function importCommand(flags: Record): Promise { const platform = requireFlag(flags, 'platform') const filePath = requireFlag(flags, 'file') - const dryRun = flags['dry-run'] !== false && flags['dry-run'] !== 'false' const ledgerPath = typeof flags['ledger'] === 'string' ? flags['ledger'] : DEFAULT_LEDGER const maxConversations = typeof flags['max'] === 'string' ? parseInt(flags['max'], 10) : undefined - // Read file if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`) process.exit(1) @@ -24,6 +24,41 @@ export function importCommand(flags: Record): void { const raw = fs.readFileSync(filePath, 'utf8') + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (err) { + console.error(`Invalid JSON in ${filePath}: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + return + } + + const apiKey = loadApiKey() + + if (apiKey) { + // Cloud mode: POST to edge function + let result + try { + result = await cloudImport(apiKey, platform, parsed) + } catch (err) { + console.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + return + } + console.log() + console.log(`Import complete.`) + console.log(` Platform: ${result.platform}`) + console.log(` Conversations analyzed: ${result.conversationsAnalyzed}`) + console.log(` Signals extracted: ${result.signalsExtracted}`) + console.log(` Signals promoted: ${result.signalsPromoted}`) + console.log(` By confidence: ${result.byConfidence.high} high, ${result.byConfidence.medium} medium, ${result.byConfidence.low} low`) + console.log() + return + } + + // Local mode: use parsers + const dryRun = flags['dry-run'] === true || flags['dry-run'] === 'true' + const parsers: Record Conversation[]> = { chatgpt: parseChatGPTExport, claude: parseClaudeExport, @@ -31,9 +66,15 @@ export function importCommand(flags: Record): void { const parser = parsers[platform] if (!parser) { - console.error(`Unsupported platform: ${platform}. Supported: chatgpt, claude`) + if (platform === 'gemini') { + console.error( + 'Gemini imports require a NOMARK account. Run `nomark login` to authenticate, then retry.' + ) + } else { + console.error(`Unsupported platform: ${platform}. Supported: chatgpt, claude`) + } process.exit(1) - return // unreachable but satisfies TS + return } const conversations = parser(raw) @@ -49,14 +90,12 @@ export function importCommand(flags: Record): void { existingLedger = fs.readFileSync(ledgerPath, 'utf8') } - // Run pipeline const report = runMigration(conversations, { existingLedger: existingLedger || undefined, dryRun, maxConversations, }) - // Display report console.log() console.log(` Analyzing ${report.conversationsAnalyzed} conversations...`) console.log(` Extracted ${report.signalsExtracted} signals (${report.byConfidence.high} high, ${report.byConfidence.medium} medium, ${report.byConfidence.low} low confidence)`) @@ -70,7 +109,6 @@ export function importCommand(flags: Record): void { console.log() console.log(' [DRY RUN] No changes written. Run with --dry-run=false to apply.') } else { - // Write ledger const existingEntries = existingLedger ? parseLedger(existingLedger) : [] const allEntries = [...existingEntries, ...report.ledgerEntries] const content = writeLedger(allEntries) @@ -79,6 +117,5 @@ export function importCommand(flags: Record): void { console.log(` Written to ${ledgerPath}`) } - console.log(' No account needed. Everything runs locally.') console.log() } diff --git a/packages/engine/src/cli/index.ts b/packages/engine/src/cli/index.ts index be734de..e56b028 100644 --- a/packages/engine/src/cli/index.ts +++ b/packages/engine/src/cli/index.ts @@ -3,10 +3,12 @@ import { parseArgs } from './args.js' import { configCommand } from './config.js' import { importCommand } from './import.js' +import { loginCommand } from './login.js' import { profileCommand } from './profile.js' import { reviewCommand } from './review.js' +import { whoamiCommand } from './whoami.js' -const VERSION = '0.1.0' +const VERSION = '0.2.0' const HELP = ` nomark — agent outcome quality engine @@ -15,6 +17,8 @@ const HELP = ` npx nomark [options] Commands: + login Save your NOMARK API key + whoami Show authenticated user config Configure model and API key import Import AI conversation history profile View your preference profile @@ -25,13 +29,15 @@ const HELP = ` --version Show version Examples: - npx nomark config --model claude --api-key $ANTHROPIC_API_KEY - npx nomark import --platform chatgpt --file export.json + npx nomark login + npx nomark import --platform chatgpt --file conversations.json + npx nomark import --platform claude --file claude-export.json + npx nomark import --platform gemini --file takeout.json + npx nomark whoami npx nomark profile - npx nomark review ` -function main(): void { +async function main(): Promise { const { command, flags } = parseArgs(process.argv) if (flags['version']) { @@ -44,31 +50,35 @@ function main(): void { return } - try { - switch (command) { - case 'config': - configCommand(flags) - break - case 'import': - importCommand(flags) - break - case 'profile': - profileCommand(flags) - break - case 'review': - reviewCommand(flags) - break - default: - console.error(`Unknown command: ${command}`) - console.log(HELP) - process.exit(1) - } - } catch (err) { - if (err instanceof Error) { - console.error(`Error: ${err.message}`) - } - process.exit(1) + switch (command) { + case 'login': + await loginCommand() + break + case 'whoami': + await whoamiCommand() + break + case 'config': + configCommand(flags) + break + case 'import': + await importCommand(flags) + break + case 'profile': + profileCommand(flags) + break + case 'review': + reviewCommand(flags) + break + default: + console.error(`Unknown command: ${command}`) + console.log(HELP) + process.exit(1) } } -main() +main().catch((err) => { + if (err instanceof Error) { + console.error(`Error: ${err.message}`) + } + process.exit(1) +}) diff --git a/packages/engine/src/cli/login.ts b/packages/engine/src/cli/login.ts new file mode 100644 index 0000000..45cb766 --- /dev/null +++ b/packages/engine/src/cli/login.ts @@ -0,0 +1,40 @@ +import * as readline from 'node:readline' +import { saveApiKey } from './auth.js' +import { probeAuth } from './http.js' + +export async function loginCommand(): Promise { + const envKey = process.env['NOMARK_TOKEN'] + if (envKey) { + const result = await probeAuth(envKey) + if (!result.authenticated) { + console.error('Error: NOMARK_TOKEN is not valid (401 Unauthorized)') + process.exit(1) + } + saveApiKey(envKey) + console.log('Logged in successfully.') + if (result.uid) console.log(` User: ${result.uid}`) + if (result.email) console.log(` Email: ${result.email}`) + return + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }) + const key = await new Promise((resolve) => { + rl.question('Enter your NOMARK API key (nomark_sk_...): ', (answer) => { + rl.close() + resolve(answer.trim()) + }) + }) + if (!key) { + console.error('Error: No API key provided') + process.exit(1) + } + const result = await probeAuth(key) + if (!result.authenticated) { + console.error('Error: API key is not valid (401 Unauthorized)') + process.exit(1) + } + saveApiKey(key) + console.log('Logged in successfully. API key saved to ~/.nomark/config.json') + if (result.uid) console.log(` User: ${result.uid}`) + if (result.email) console.log(` Email: ${result.email}`) +} diff --git a/packages/engine/src/cli/whoami.ts b/packages/engine/src/cli/whoami.ts new file mode 100644 index 0000000..dfcb998 --- /dev/null +++ b/packages/engine/src/cli/whoami.ts @@ -0,0 +1,22 @@ +import { loadApiKey } from './auth.js' +import { probeAuth } from './http.js' + +export async function whoamiCommand(): Promise { + const key = loadApiKey() + if (!key) { + console.error('Not authenticated. Run `nomark login`.') + process.exit(1) + } + const result = await probeAuth(key) + if (!result.authenticated) { + console.error('Not authenticated. Run `nomark login`.') + process.exit(1) + } + if (result.uid) { + console.log(result.uid) + } else if (result.email) { + console.log(result.email) + } else { + console.log(`Authenticated (key: ...${key.slice(-4)})`) + } +} diff --git a/packages/engine/tests/cli/auth.test.ts b/packages/engine/tests/cli/auth.test.ts new file mode 100644 index 0000000..b978602 --- /dev/null +++ b/packages/engine/tests/cli/auth.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { loadApiKey, saveApiKey, clearApiKey } from '../../src/cli/auth.js' + +let tmpDir: string +let originalHome: string | undefined +let originalToken: string | undefined + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomark-auth-test-')) + originalHome = process.env['HOME'] + originalToken = process.env['NOMARK_TOKEN'] + process.env['HOME'] = tmpDir + delete process.env['NOMARK_TOKEN'] +}) + +afterEach(() => { + process.env['HOME'] = originalHome + if (originalToken !== undefined) { + process.env['NOMARK_TOKEN'] = originalToken + } else { + delete process.env['NOMARK_TOKEN'] + } + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('loadApiKey', () => { + it('returns undefined when no config and no env var', () => { + expect(loadApiKey()).toBeUndefined() + }) + + it('returns NOMARK_TOKEN env var when set', () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_envtest' + expect(loadApiKey()).toBe('nomark_sk_envtest') + }) + + it('returns api_key from config file when present', () => { + const configDir = path.join(tmpDir, '.nomark') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ api_key: 'nomark_sk_fromfile' }) + ) + expect(loadApiKey()).toBe('nomark_sk_fromfile') + }) + + it('returns undefined when config exists but has no api_key field', () => { + const configDir = path.join(tmpDir, '.nomark') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ model: 'claude', apiKey: 'some-local-key' }) + ) + expect(loadApiKey()).toBeUndefined() + }) + + it('prefers NOMARK_TOKEN over config file', () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_env' + const configDir = path.join(tmpDir, '.nomark') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ api_key: 'nomark_sk_file' }) + ) + expect(loadApiKey()).toBe('nomark_sk_env') + }) + + it('returns undefined when config file is malformed JSON', () => { + const configDir = path.join(tmpDir, '.nomark') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'config.json'), '{invalid json}') + expect(loadApiKey()).toBeUndefined() + }) +}) + +describe('saveApiKey', () => { + it('creates config directory and file if they do not exist', () => { + saveApiKey('nomark_sk_new') + const configPath = path.join(tmpDir, '.nomark', 'config.json') + expect(fs.existsSync(configPath)).toBe(true) + }) + + it('writes api_key to config file', () => { + saveApiKey('nomark_sk_abc123') + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const data = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(data['api_key']).toBe('nomark_sk_abc123') + }) + + it('sets file permissions to 0600', () => { + saveApiKey('nomark_sk_perms') + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const stat = fs.statSync(configPath) + // 0o600 = 384 decimal; mask with 0o777 to get permission bits only + expect(stat.mode & 0o777).toBe(0o600) + }) + + it('merges with existing config, preserving other keys', () => { + const configDir = path.join(tmpDir, '.nomark') + fs.mkdirSync(configDir, { recursive: true }) + const configPath = path.join(configDir, 'config.json') + fs.writeFileSync(configPath, JSON.stringify({ model: 'claude', apiKey: 'local-key' })) + saveApiKey('nomark_sk_merged') + const data = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(data['api_key']).toBe('nomark_sk_merged') + expect(data['model']).toBe('claude') + expect(data['apiKey']).toBe('local-key') + }) + + it('overwrites existing api_key', () => { + saveApiKey('nomark_sk_first') + saveApiKey('nomark_sk_second') + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const data = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(data['api_key']).toBe('nomark_sk_second') + }) + + it('does not log the key to stdout or stderr', () => { + const origLog = console.log + const origErr = console.error + const logged: string[] = [] + console.log = (...args: unknown[]) => logged.push(String(args)) + console.error = (...args: unknown[]) => logged.push(String(args)) + saveApiKey('nomark_sk_secret') + console.log = origLog + console.error = origErr + for (const msg of logged) { + expect(msg).not.toContain('nomark_sk_secret') + } + }) +}) + +describe('clearApiKey', () => { + it('does nothing when config file does not exist', () => { + expect(() => clearApiKey()).not.toThrow() + }) + + it('removes api_key from config file', () => { + saveApiKey('nomark_sk_todelete') + clearApiKey() + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const data = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(data['api_key']).toBeUndefined() + }) + + it('preserves other keys when clearing api_key', () => { + const configDir = path.join(tmpDir, '.nomark') + fs.mkdirSync(configDir, { recursive: true }) + const configPath = path.join(configDir, 'config.json') + fs.writeFileSync(configPath, JSON.stringify({ model: 'gpt', api_key: 'nomark_sk_old' })) + clearApiKey() + const data = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(data['model']).toBe('gpt') + expect(data['api_key']).toBeUndefined() + }) +}) diff --git a/packages/engine/tests/cli/http.test.ts b/packages/engine/tests/cli/http.test.ts new file mode 100644 index 0000000..2b8029f --- /dev/null +++ b/packages/engine/tests/cli/http.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { cloudImport, probeAuth } from '../../src/cli/http.js' + +const mockFetch = vi.fn() + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) + mockFetch.mockReset() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('cloudImport', () => { + it('POSTs to the edge function with correct headers', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + platform: 'chatgpt', + conversationsAnalyzed: 3, + signalsExtracted: 10, + signalsPromoted: 5, + byConfidence: { high: 2, medium: 3, low: 5 }, + }), + }) + + await cloudImport('nomark_sk_test', 'chatgpt', [{ id: 1 }]) + + expect(mockFetch).toHaveBeenCalledOnce() + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import') + expect(init.method).toBe('POST') + const headers = init.headers as Record + expect(headers['Authorization']).toBe('Bearer nomark_sk_test') + expect(headers['Content-Type']).toBe('application/json') + }) + + it('sends platform and data in request body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + platform: 'claude', + conversationsAnalyzed: 1, + signalsExtracted: 2, + signalsPromoted: 1, + byConfidence: { high: 1, medium: 1, low: 0 }, + }), + }) + + const data = { conversations: [{ title: 'test' }] } + await cloudImport('nomark_sk_test', 'claude', data) + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) as Record + expect(body['platform']).toBe('claude') + expect(body['data']).toEqual(data) + }) + + it('returns ImportResult on success', async () => { + const expected = { + platform: 'chatgpt', + conversationsAnalyzed: 5, + signalsExtracted: 20, + signalsPromoted: 10, + byConfidence: { high: 4, medium: 6, low: 10 }, + } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => expected, + }) + + const result = await cloudImport('nomark_sk_test', 'chatgpt', []) + expect(result).toEqual(expected) + }) + + it('throws with HTTP status and body on non-ok response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }) + + await expect(cloudImport('nomark_sk_bad', 'chatgpt', [])).rejects.toThrow('HTTP 401') + }) + + it('throws with 500 error body on server error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }) + + await expect(cloudImport('nomark_sk_test', 'chatgpt', [])).rejects.toThrow('HTTP 500') + }) + + it('does not include api_key in request body (credential isolation)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + platform: 'chatgpt', + conversationsAnalyzed: 0, + signalsExtracted: 0, + signalsPromoted: 0, + byConfidence: { high: 0, medium: 0, low: 0 }, + }), + }) + + await cloudImport('nomark_sk_secret', 'chatgpt', []) + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + const body = init.body as string + expect(body).not.toContain('nomark_sk_secret') + }) +}) + +describe('probeAuth', () => { + it('returns authenticated: false on 401', async () => { + mockFetch.mockResolvedValueOnce({ + status: 401, + json: async () => ({}), + }) + + const result = await probeAuth('nomark_sk_bad') + expect(result.authenticated).toBe(false) + }) + + it('returns authenticated: true on non-401 response', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + json: async () => ({}), + }) + + const result = await probeAuth('nomark_sk_valid') + expect(result.authenticated).toBe(true) + }) + + it('returns uid and email from response body when present', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + json: async () => ({ uid: 'abc-123', email: 'user@example.com' }), + }) + + const result = await probeAuth('nomark_sk_valid') + expect(result.authenticated).toBe(true) + expect(result.uid).toBe('abc-123') + expect(result.email).toBe('user@example.com') + }) + + it('returns authenticated: true even if response body is not JSON', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + json: async () => { throw new Error('not json') }, + }) + + const result = await probeAuth('nomark_sk_valid') + expect(result.authenticated).toBe(true) + expect(result.uid).toBeUndefined() + }) + + it('throws on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('network failure')) + await expect(probeAuth('nomark_sk_test')).rejects.toThrow('Network error') + }) + + it('POSTs probe payload to edge function', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + json: async () => ({}), + }) + + await probeAuth('nomark_sk_test') + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import') + const body = JSON.parse(init.body as string) as Record + expect(body['platform']).toBe('probe') + expect(body['data']).toBeNull() + }) +})