From 17dbd2b25779debd5d4db3b8a566d391d702d00f Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Mon, 15 Jun 2026 16:49:01 -0700 Subject: [PATCH] More detailed, fancier feedback in interactive mode --- package.json | 4 +- pnpm-lock.yaml | 211 +++++------------------ src/cli.ts | 294 +++++++++++++++---------------- src/index.ts | 2 +- src/output.ts | 334 ++++++++++++++++++++++++++++++++++++ src/pushwork.ts | 47 ++++- test/setup.ts | 16 +- test/unit/doc-shape.test.ts | 5 +- 8 files changed, 577 insertions(+), 336 deletions(-) create mode 100644 src/output.ts diff --git a/package.json b/package.json index 5b8da19..5b69065 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.29", "@automerge/automerge-repo-storage-nodefs": "2.6.0-subduction.29", "@automerge/automerge-subduction": "0.15.0", + "@clack/prompts": "^1.5.1", "@commander-js/extra-typings": "^14.0.0", "chalk": "^5.3.0", "commander": "^14.0.2", @@ -54,8 +55,7 @@ "diff": "^8.0.2", "glob": "^10.3.0", "ignore": "^5.3.0", - "mime-types": "^2.1.35", - "ora": "^7.0.1" + "mime-types": "^2.1.35" }, "devDependencies": { "@types/debug": "^4.1.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f12bdc..ce80feb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@automerge/automerge-subduction': specifier: 0.15.0 version: 0.15.0 + '@clack/prompts': + specifier: ^1.5.1 + version: 1.5.1 '@commander-js/extra-typings': specifier: ^14.0.0 version: 14.0.0(commander@14.0.3) @@ -50,9 +53,6 @@ importers: mime-types: specifier: ^2.1.35 version: 2.1.35 - ora: - specifier: ^7.0.1 - version: 7.0.1 devDependencies: '@types/debug': specifier: ^4.1.13 @@ -159,6 +159,14 @@ packages: cpu: [x64] os: [win32] + '@clack/core@1.4.1': + resolution: {integrity: sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.5.1': + resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==} + engines: {node: '>= 20.12.0'} + '@commander-js/extra-typings@14.0.0': resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} peerDependencies: @@ -714,12 +722,6 @@ packages: base-x@5.0.1: resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - bl@5.1.0: - resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} - brace-expansion@2.1.1: resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} @@ -733,9 +735,6 @@ packages: bs58check@4.0.0: resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -759,14 +758,6 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -806,9 +797,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -845,6 +833,15 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -866,28 +863,14 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-interactive@2.0.0: - resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} - engines: {node: '>=12'} - - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -915,10 +898,6 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - log-symbols@5.1.0: - resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} - engines: {node: '>=12'} - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -943,10 +922,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -971,14 +946,6 @@ packages: resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} hasBin: true - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - ora@7.0.1: - resolution: {integrity: sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==} - engines: {node: '>=16'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1007,22 +974,11 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - rollup@4.62.0: resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -1039,13 +995,13 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1056,10 +1012,6 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - stdin-discarder@0.1.0: - resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1068,13 +1020,6 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string-width@6.1.0: - resolution: {integrity: sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==} - engines: {node: '>=16'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1126,9 +1071,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@14.0.0: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true @@ -1309,6 +1251,18 @@ snapshots: '@cbor-extract/cbor-extract-win32-x64@2.2.2': optional: true + '@clack/core@1.4.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.5.1': + dependencies: + '@clack/core': 1.4.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@commander-js/extra-typings@14.0.0(commander@14.0.3)': dependencies: commander: 14.0.3 @@ -1659,14 +1613,6 @@ snapshots: base-x@5.0.1: {} - base64-js@1.5.1: {} - - bl@5.1.0: - dependencies: - buffer: 6.0.3 - inherits: 2.0.4 - readable-stream: 3.6.2 - brace-expansion@2.1.1: dependencies: balanced-match: 1.0.2 @@ -1684,11 +1630,6 @@ snapshots: '@noble/hashes': 1.8.0 bs58: 6.0.0 - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - cac@6.7.14: {} cbor-extract@2.2.2: @@ -1719,12 +1660,6 @@ snapshots: check-error@2.1.3: {} - cli-cursor@4.0.0: - dependencies: - restore-cursor: 4.0.0 - - cli-spinners@2.9.2: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1752,8 +1687,6 @@ snapshots: eastasianwidth@0.2.0: {} - emoji-regex@10.6.0: {} - emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -1829,6 +1762,16 @@ snapshots: fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -1850,18 +1793,10 @@ snapshots: html-escaper@2.0.2: {} - ieee754@1.2.1: {} - ignore@5.3.2: {} - inherits@2.0.4: {} - is-fullwidth-code-point@3.0.0: {} - is-interactive@2.0.0: {} - - is-unicode-supported@1.3.0: {} - isexe@2.0.0: {} isomorphic-ws@5.0.0(ws@8.21.0): @@ -1895,11 +1830,6 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - log-symbols@5.1.0: - dependencies: - chalk: 5.6.2 - is-unicode-supported: 1.3.0 - loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -1924,8 +1854,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mimic-fn@2.1.0: {} - minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -1945,22 +1873,6 @@ snapshots: detect-libc: 2.1.2 optional: true - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - ora@7.0.1: - dependencies: - chalk: 5.6.2 - cli-cursor: 4.0.0 - cli-spinners: 2.9.2 - is-interactive: 2.0.0 - is-unicode-supported: 1.3.0 - log-symbols: 5.1.0 - stdin-discarder: 0.1.0 - string-width: 6.1.0 - strip-ansi: 7.2.0 - package-json-from-dist@1.0.1: {} path-key@3.1.1: {} @@ -1984,17 +1896,6 @@ snapshots: pure-rand@8.4.0: {} - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - restore-cursor@4.0.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - rollup@4.62.0: dependencies: '@types/estree': 1.0.9 @@ -2026,8 +1927,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 - safe-buffer@5.2.1: {} - semver@7.8.4: {} shebang-command@2.0.0: @@ -2038,20 +1937,16 @@ snapshots: siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@3.10.0: {} - stdin-discarder@0.1.0: - dependencies: - bl: 5.1.0 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2064,16 +1959,6 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 - string-width@6.1.0: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 10.6.0 - strip-ansi: 7.2.0 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2114,8 +1999,6 @@ snapshots: undici-types@6.21.0: {} - util-deprecate@1.0.2: {} - uuid@14.0.0: {} vite-node@2.1.9(@types/node@20.19.43): diff --git a/src/cli.ts b/src/cli.ts index db91ba8..68941fe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,8 +2,6 @@ import "./log.js"; // sets up DEBUG=true → DEBUG=* before anything else import { Command } from "@commander-js/extra-typings"; import * as path from "path"; -import * as readline from "readline/promises"; -import { stdin as input, stdout as output } from "process"; import type { AutomergeUrl } from "@automerge/automerge-repo"; import { clone, @@ -19,129 +17,98 @@ import { url, } from "./pushwork.js"; import { log } from "./log.js"; +import { out } from "./output.js"; +import { legacyUrl, subductionUrl } from "./repo.js"; import { formatVersions } from "./version.js"; import { migrate, versionLabel } from "./migrations.js"; const dlog = log("cli"); +const priorWarn = process.listeners("warning"); +process.removeAllListeners("warning"); +process.on("warning", (w) => { + if (w.name === "TimeoutNegativeWarning") return; + if (priorWarn.length > 0) for (const l of priorWarn) l.call(process, w); + else process.stderr.write(`(node:${process.pid}) ${w.name}: ${w.message}\n`); +}); + const collect = (value: string, prev: string[] | undefined) => (prev ?? []).concat(value); const backendOf = (opts: { sub?: boolean; legacy?: boolean }) => opts.legacy || opts.sub === false ? "legacy" : "subduction"; +const endpointOf = (backend: "legacy" | "subduction") => + backend === "legacy" ? legacyUrl() : subductionUrl(); + +const report = (phase: string) => out.step(phase); + +const plural = (n: number, one: string, many = one + "s") => + `${n} ${n === 1 ? one : many}`; + async function pickBranchInteractively(info: { title?: string; branches: { name: string; url: AutomergeUrl }[]; }): Promise { const titlePart = info.title ? ` (${info.title})` : ""; - process.stderr.write( - `\nThis URL points to a legacy "branches" doc${titlePart}.\n`, + out.info( + `This URL is a legacy "branches" doc${titlePart}; branches aren't supported. Pick one to clone its folder directly.`, ); - process.stderr.write( - `Branches aren't supported in pushwork — pick a branch to clone its folder directly:\n\n`, + return out.select( + "Branch to clone", + info.branches.map((b) => ({ value: b.url, label: b.name })), ); - for (let i = 0; i < info.branches.length; i++) { - process.stderr.write(` ${i + 1}) ${info.branches[i].name}\n`); - } - process.stderr.write("\n"); - - const rl = readline.createInterface({ input, output }); - try { - while (true) { - const answer = ( - await rl.question(`Pick a branch [1-${info.branches.length}]: `) - ).trim(); - const n = Number.parseInt(answer, 10); - if (Number.isFinite(n) && n >= 1 && n <= info.branches.length) { - return info.branches[n - 1].url; - } - const byName = info.branches.find((b) => b.name === answer); - if (byName) return byName.url; - process.stderr.write(`invalid selection: ${answer}\n`); - } - } finally { - rl.close(); - } -} - -// Read a single keypress (git-style prompts). Falls back to a line read when -// stdin isn't a TTY (e.g. piped input), returning the first character. -async function readChar(): Promise { - if (!input.isTTY || typeof input.setRawMode !== "function") { - const rl = readline.createInterface({ input, output }); - try { - const line = (await rl.question("")).trim(); - return line.slice(0, 1) || "\n"; - } finally { - rl.close(); - } - } - return new Promise((resolve) => { - input.setRawMode(true); - input.resume(); - input.once("data", (buf: Buffer) => { - input.setRawMode(false); - input.pause(); - resolve(buf.toString("utf8")); - }); - }); } async function pickStrategyInteractively(info: { url: AutomergeUrl; viewCode: () => string; }): Promise { - process.stderr.write( - `\nThis repo's root doc has no standard @patchwork.type, but it declares a custom strategy:\n`, - ); - process.stderr.write(` .pushworkStrategy → ${info.url}\n\n`); - process.stderr.write( - `Pushwork can download this strategy module and run it to decode the repo.\n`, + out.info( + `This repo's root doc has no standard @patchwork.type but declares a custom strategy: ${info.url}`, ); - process.stderr.write( - `Running it executes code written by the document's author. Inspect it first.\n\n`, + out.warn( + "Running it executes code written by the document's author. Inspect it first.", ); - - while (true) { - process.stderr.write( - `Download and run this strategy? [y]es, [n]o, [v]iew code, [?] help: `, - ); - const raw = await readChar(); - const code = raw.charCodeAt(0); - if (code === 3) { - // Ctrl-C - process.stderr.write("\naborted\n"); - process.exit(130); - } - const ch = raw.toLowerCase(); - process.stderr.write(/\S/.test(ch) ? ch + "\n" : "\n"); - if (ch === "y") return true; - if (ch === "n" || code === 27) return false; // n or Esc - if (ch === "v") { - process.stderr.write("\n----- .pushworkStrategy -----\n"); - process.stderr.write(info.viewCode().replace(/\n?$/, "\n")); - process.stderr.write("----- end -----\n\n"); - continue; - } - process.stderr.write( - ` y - download the strategy module and run it to decode this repo\n` + - ` n - abort the clone (Esc also works)\n` + - ` v - print the strategy source, then ask again\n`, + for (;;) { + const choice = await out.select<"run" | "view" | "abort">( + "Download and run this strategy?", + [ + { value: "run", label: "Run it", hint: "decode this repo with the strategy" }, + { value: "view", label: "View the code first" }, + { value: "abort", label: "Abort the clone" }, + ], ); + if (choice === "run") return true; + if (choice === "abort") return false; + out.log("\n----- .pushworkStrategy -----"); + out.log(info.viewCode().replace(/\n?$/, "\n") + "----- end -----\n"); } } const program = new Command() .name("pushwork") .description("Bidirectional directory synchronization using Automerge CRDTs") - .version(formatVersions(), "-v, --version", "Print version info and exit"); + .version(formatVersions(), "-v, --version", "Print version info and exit") + .option( + "--porcelain", + "Machine-readable output: tab-separated lines, no spinners/colors/prompts", + ) + .option("-q, --quiet", "Suppress progress; show only results and errors") + .option("--silent", "Suppress all output except errors (check exit code)") + .hook("preAction", (thisCommand) => { + const opts = thisCommand.opts(); + out.configure({ + porcelain: Boolean(opts.porcelain), + verbosity: opts.silent ? "silent" : opts.quiet ? "quiet" : "normal", + }); + }); program .command("version") .description("Print pushwork and Automerge package versions") .action(() => { - process.stdout.write(formatVersions() + "\n"); + out.log(formatVersions()); }); program @@ -163,13 +130,23 @@ program ) .action(async (dir, opts) => { dlog("init dir=%s opts=%o", dir, opts); - const u = await init({ - dir: path.resolve(dir), - backend: backendOf(opts), - shape: opts.shape, - artifactDirectories: opts.artifactDir, + const backend = backendOf(opts); + const root = path.resolve(dir); + out.intro("pushwork init"); + out.task("Connecting to sync server"); + const info = await init( + { dir: root, backend, shape: opts.shape, artifactDirectories: opts.artifactDir }, + report, + ); + out.done(); // complete the final phase line before the summary + out.obj({ + Path: root, + Files: `${info.files} tracked`, + Backend: backend, + Sync: endpointOf(backend), }); - process.stderr.write(`initialized ${u}\n`); + out.block("INITIALIZED", info.url); + out.outro("Done"); }); program @@ -192,16 +169,31 @@ program ) .action(async (u, dir, opts) => { dlog("clone url=%s dir=%s opts=%o", u, dir, opts); - await clone({ - url: u, - dir: path.resolve(dir), - backend: backendOf(opts), - shape: opts.shape, - artifactDirectories: opts.artifactDir, - onBranchesDoc: pickBranchInteractively, - onStrategyDoc: pickStrategyInteractively, + const backend = backendOf(opts); + const root = path.resolve(dir); + out.intro("pushwork clone"); + out.task("Connecting to sync server"); + const info = await clone( + { + url: u, + dir: root, + backend, + shape: opts.shape, + artifactDirectories: opts.artifactDir, + onBranchesDoc: pickBranchInteractively, + onStrategyDoc: pickStrategyInteractively, + }, + report, + ); + out.done(); // complete the final phase line before the summary + out.obj({ + Path: root, + Files: `${info.files} downloaded`, + Backend: backend, + Sync: endpointOf(backend), }); - process.stderr.write(`cloned into ${path.resolve(dir)}\n`); + out.block("CLONED", info.url); + out.outro("Done"); }); program @@ -215,13 +207,11 @@ program dlog("migrate root=%s", root); const result = await migrate(root); if (result.steps.length === 0) { - process.stderr.write(`already up to date (version ${result.to})\n`); + out.success(`already up to date (version ${result.to})`); return; } - process.stderr.write( - `migrated ${versionLabel(result.from)} → ${result.to}\n`, - ); - for (const s of result.steps) process.stderr.write(` ${s}\n`); + out.success(`migrated ${versionLabel(result.from)} → ${result.to}`); + out.arr(result.steps); }); program @@ -229,8 +219,7 @@ program .description("Print the automerge URL of this pushwork repo") .action(async () => { dlog("url cwd=%s", process.cwd()); - const u = await url(process.cwd()); - process.stdout.write(u + "\n"); + out.log(await url(process.cwd())); }); program @@ -242,8 +231,10 @@ program ) .action(async (opts) => { dlog("sync cwd=%s opts=%o", process.cwd(), opts); - await sync(process.cwd(), { nuclear: opts.nuclear }); - process.stderr.write(opts.nuclear ? "nuclear synced\n" : "synced\n"); + out.intro(opts.nuclear ? "pushwork sync --nuclear" : "pushwork sync"); + out.task("Connecting to sync server"); + await sync(process.cwd(), { nuclear: opts.nuclear }, report); + out.outro(opts.nuclear ? "nuclear synced" : "synced"); }); program @@ -252,8 +243,9 @@ program .description("Commit local changes without contacting the sync server") .action(async () => { dlog("save cwd=%s", process.cwd()); + out.task("Saving"); await save(process.cwd()); - process.stderr.write("saved\n"); + out.done("saved"); }); program @@ -261,17 +253,22 @@ program .description("Show changes against the saved state") .action(async () => { const { diff: d } = await status(process.cwd()); - const lines: string[] = []; const total = d.added.length + d.modified.length + d.deleted.length; + if (out.isPorcelain) { + for (const p of d.modified) out.log(`modified\t${p}`); + for (const p of d.added) out.log(`added\t${p}`); + for (const p of d.deleted) out.log(`deleted\t${p}`); + return; + } if (total === 0) { - lines.push("nothing to save, working tree clean"); - } else { - lines.push("Changes:"); - for (const p of d.modified) lines.push(` modified: ${p}`); - for (const p of d.added) lines.push(` added: ${p}`); - for (const p of d.deleted) lines.push(` deleted: ${p}`); + out.log("nothing to save, working tree clean"); + return; } - process.stdout.write(lines.join("\n") + "\n"); + const lines = ["Changes:"]; + for (const p of d.modified) lines.push(` modified: ${p}`); + for (const p of d.added) lines.push(` added: ${p}`); + for (const p of d.deleted) lines.push(` deleted: ${p}`); + out.log(lines.join("\n")); }); program @@ -281,21 +278,25 @@ program .action(async (limitPath) => { const entries = await diff(process.cwd(), limitPath); if (entries.length === 0) { - process.stdout.write("(no changes)\n"); + out.log("(no changes)"); return; } const { createPatch } = await import("diff"); const td = new TextDecoder("utf-8", { fatal: false }); + const chunks: string[] = []; for (const e of entries) { const before = e.before ? td.decode(e.before) : ""; const after = e.after ? td.decode(e.after) : ""; const header = - e.kind === "added" ? `+++ ${e.path}` : - e.kind === "deleted" ? `--- ${e.path}` : - `*** ${e.path}`; - process.stdout.write(header + "\n"); - process.stdout.write(createPatch(e.path, before, after, "", "") + "\n"); + e.kind === "added" + ? `+++ ${e.path}` + : e.kind === "deleted" + ? `--- ${e.path}` + : `*** ${e.path}`; + chunks.push(header); + chunks.push(createPatch(e.path, before, after, "", "")); } + out.log(chunks.join("\n")); }); program @@ -305,12 +306,14 @@ program .action(async (pathspec) => { const entries = await heads(process.cwd(), pathspec); if (entries.length === 0) { - process.stdout.write("(no matching docs)\n"); + out.log("(no matching docs)"); return; } - for (const e of entries) { - process.stdout.write(`${e.path}\t${e.url}\t${e.heads.join(" ")}\n`); - } + out.log( + entries + .map((e) => `${e.path}\t${e.url}\t${e.heads.join(" ")}`) + .join("\n"), + ); }); program @@ -319,7 +322,7 @@ program .argument("[name]", "Optional name for the snarf entry") .action(async (name) => { const result = await cutWorkdir(process.cwd(), { name }); - process.stderr.write(`cut #${result.id}: ${result.entries} entr${result.entries === 1 ? "y" : "ies"}\n`); + out.success(`cut #${result.id}: ${plural(result.entries, "entry", "entries")}`); }); program @@ -328,8 +331,9 @@ program .argument("[id-or-name]", "Snarf id or name") .action(async (selector) => { const result = await pasteSnarf(process.cwd(), selector); - process.stderr.write( - `pasted #${result.id}${result.name ? ` (${result.name})` : ""}: ${result.entries} entr${result.entries === 1 ? "y" : "ies"}\n`, + const label = result.name ? ` (${result.name})` : ""; + out.success( + `pasted #${result.id}${label}: ${plural(result.entries, "entry", "entries")}`, ); }); @@ -340,24 +344,22 @@ program .action(async () => { const snarfs = await showSnarfs(process.cwd()); if (snarfs.length === 0) { - process.stdout.write("(no snarfs)\n"); + out.log("(no snarfs)"); return; } - for (const s of snarfs) { - const ts = new Date(s.createdAt).toISOString(); - const label = s.name ? `"${s.name}"` : ""; - process.stdout.write( - `#${s.id}${label ? " " + label : ""} ${s.entries.length} entr${s.entries.length === 1 ? "y" : "ies"} ${ts}\n`, - ); - } + out.arr( + snarfs.map((s) => { + const ts = new Date(s.createdAt).toISOString(); + const name = s.name ? ` "${s.name}"` : ""; + return `#${s.id}${name} ${plural(s.entries.length, "entry", "entries")} ${ts}`; + }), + ); }); program .parseAsync(process.argv) - .then(() => process.exit(0)) + .then(() => out.exit(0)) .catch((err) => { - process.stderr.write( - `pushwork: ${err instanceof Error ? err.message : String(err)}\n`, - ); - process.exit(1); + out.error(err instanceof Error ? err.message : String(err)); + out.exit(1); }); diff --git a/src/index.ts b/src/index.ts index ff7fc18..177e6aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ export { showSnarfs, nuclearizeRepo, } from "./pushwork.js"; -export type { HeadsEntry } from "./pushwork.js"; +export type { HeadsEntry, Reporter, RepoSummary } from "./pushwork.js"; export type { Snarf, SnarfEntry } from "./snarf.js"; export type { Backend, PushworkConfig } from "./config.js"; export { CONFIG_VERSION } from "./config.js"; diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..655da62 --- /dev/null +++ b/src/output.ts @@ -0,0 +1,334 @@ +import chalk from "chalk"; +import * as clack from "@clack/prompts"; + +/** + * Terminal output controller (singleton). One object owns the output + * format (interactive clack UI vs machine-readable porcelain) and the + * verbosity; the CLI talks through it so every command renders + * consistently and scripts/CI never hang. + * + * Modes (format): + * interactive (default) clack: gutter, spinners, prompts + * porcelain --porcelain tab-separated `\t` lines + * + * Verbosity (orthogonal): + * normal spinners + messages + prompts + * quiet -q only the final summary line + errors; prompts auto-default + * silent --silent errors to stderr only; prompts auto-default + * + * Non-TTY output (piped/redirected, no flag) degrades spinners to plain + * lines — clack only drops its cursor animation under CI=true, so without + * this a piped run spews escape codes. + * + * `log()` is the bare data path (e.g. `pushwork url`): plain stdout in + * every mode, suppressed only by --silent. + */ + +export type Verbosity = "normal" | "quiet" | "silent"; + +export interface OutputConfig { + porcelain?: boolean; + verbosity?: Verbosity; +} + +export interface SelectOption { + value: T; + label: string; + hint?: string; +} + +export class Output { + private static instance: Output | null = null; + + private porcelain = false; + private verbosity: Verbosity = "normal"; + private spinner: ReturnType | null = null; + private taskStart: number | null = null; + private taskMessage = ""; + private introShown = false; + + private constructor() {} + + static getInstance(): Output { + if (!Output.instance) Output.instance = new Output(); + return Output.instance; + } + + configure(config: OutputConfig): void { + if (config.porcelain !== undefined) this.porcelain = config.porcelain; + if (config.verbosity !== undefined) this.verbosity = config.verbosity; + } + + get isPorcelain(): boolean { + return this.porcelain; + } + get isQuiet(): boolean { + return this.verbosity !== "normal"; + } + get isSilent(): boolean { + return this.verbosity === "silent"; + } + + /** Whether a prompt can actually be asked (else callers must default). */ + get isInteractive(): boolean { + return ( + !this.porcelain && + !this.isQuiet && + Boolean(process.stdout.isTTY) && + Boolean(process.stdin.isTTY) + ); + } + + private get isTTY(): boolean { + return Boolean(process.stdout.isTTY); + } + + /** Normal verbosity but not a TTY: render spinners as plain lines. */ + private get plain(): boolean { + return !this.porcelain && !this.isQuiet && !this.isTTY; + } + + // ── Lifecycle (intro / outro) ──────────────────────────────────────── + + intro(title: string): void { + if (this.porcelain || this.isQuiet) return; + if (!this.isTTY) return; + clack.intro(chalk.inverse(` ${title} `)); + this.introShown = true; + } + + /** Closing line. Quiet prints a plain summary; silent/porcelain suppress. */ + outro(message: string): void { + // Complete the final phase so its line persists, then frame the close. + if (this.taskStart != null) this.done(); + this.halt(); + if (this.isSilent || this.porcelain) return; + if (this.isQuiet) { + if (message) console.log(strip(message)); + return; + } + if (this.plain) { + console.log(message); + return; + } + if (this.introShown) { + clack.outro(message); + this.introShown = false; + } else { + clack.log.success(message); + } + } + + // ── Task (spinner) ─────────────────────────────────────────────────── + + task(message: string): void { + if (this.spinner) this.done(); + this.taskStart = Date.now(); + this.taskMessage = message; + // Only an interactive TTY shows a live spinner; porcelain/plain/quiet + // render one completion line per phase (via done()) instead, so the + // phase log isn't doubled (start + done) or animated into a pipe. + if (this.isQuiet || this.porcelain || this.plain) return; + this.spinner = clack.spinner(); + this.spinner.start(message); + } + + /** + * Advance to a new phase: complete the current one (leaving its line), + * then start the next. This is the per-phase reporter the library calls + * during long operations, so each phase persists with its own timing. + */ + step(message: string): void { + this.done(); + this.task(message); + } + + done(message?: string, showTime = true): void { + if (this.taskStart == null) return; + let text = message ?? this.taskMessage ?? "done"; + if (showTime) text += chalk.dim(` (${fmtMs(Date.now() - this.taskStart)})`); + if (this.spinner) { + this.spinner.stop(text); + this.spinner = null; + } else if (this.porcelain && !this.isQuiet) { + console.log(`ok\t${strip(text)}`); + } else if (this.plain) { + console.log(text); + } + this.taskStart = null; + } + + // ── Plain + leveled output ─────────────────────────────────────────── + + /** Bare stdout line — the data path. Suppressed only by --silent. */ + log(message: string): void { + if (this.isSilent) return; + this.halt(); + console.log(message); + } + + info(message: string): void { + if (this.isQuiet) return; + this.halt(); + if (this.porcelain) console.log(`info\t${message}`); + else if (this.plain) console.log(message); + else clack.log.info(message); + } + + success(message: string): void { + if (this.isQuiet) return; + this.halt(); + if (this.porcelain) console.log(`ok\t${message}`); + else if (this.plain) console.log(message); + else clack.log.success(message); + } + + warn(message: string): void { + if (this.isSilent) { + console.error(`warning: ${message}`); + return; + } + if (this.isQuiet) return; + this.halt(); + if (this.porcelain) console.log(`warning\t${message}`); + else if (this.plain) console.log(`warning: ${message}`); + else clack.log.warn(message); + } + + error(message: unknown): void { + const text = message instanceof Error ? message.message : String(message); + if (this.spinner) { + this.spinner.error(chalk.red("failed")); + this.spinner = null; + this.taskStart = null; + } + if (this.isSilent) { + console.error(`error: ${text}`); + return; + } + if (this.porcelain) { + console.log(`error\t${text}`); + return; + } + if (this.plain) { + console.error(`error: ${text}`); + return; + } + clack.log.error(chalk.red(text)); + } + + // ── Structured data ────────────────────────────────────────────────── + + /** Key-value rows. Porcelain: `key\tvalue` lines. Quiet/silent: suppressed. */ + obj(record: Record): void { + if (this.isQuiet) return; + this.halt(); + const entries = Object.entries(record).filter(([, v]) => v !== undefined); + if (entries.length === 0) return; + if (this.porcelain) { + for (const [k, v] of entries) console.log(`${k}\t${String(v)}`); + return; + } + const pad = Math.max(...entries.map(([k]) => k.length)); + const rows = entries + .map(([k, v]) => `${chalk.dim(k.padEnd(pad + 2))}${String(v)}`) + .join("\n"); + if (this.introShown) clack.log.message(rows); + else console.log(rows); + } + + /** + * Highlighted banner (e.g. a final "CLONED " line). Porcelain emits + * `ok\t