mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

fix: push to knots via in-process `ssh2` instead of `ssh` binary

+336 -234
+2
package.json
··· 52 52 "nuxt": "^4.4.8", 53 53 "nuxt-og-image": "^6.4.11", 54 54 "rolldown": "^1.0.0-rc.18", 55 + "ssh2": "^1.17.0", 55 56 "vite-plus": "0.1.20", 56 57 "vue": "3.5.33", 57 58 "vue-router": "^5.0.6" ··· 63 64 "@playwright/test": "1.59.1", 64 65 "@stylistic/eslint-plugin": "^5.10.0", 65 66 "@types/node": "^24.13.2", 67 + "@types/ssh2": "^1.15.5", 66 68 "@vitest/coverage-v8": "^4.1.5", 67 69 "@vue/test-utils": "2.4.10", 68 70 "drizzle-kit": "^0.31.10",
+87 -130
pnpm-lock.yaml
··· 59 59 version: 4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@24.13.2)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(esbuild@0.28.0)(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(magicast@0.5.3)(optionator@0.9.4)(oxlint@1.61.0(oxlint-tsgolint@0.22.0))(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.21.0)(typescript@6.0.3)(vite@7.3.2(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) 60 60 nuxt-og-image: 61 61 specifier: ^6.4.11 62 - version: 6.5.3(b947c0efbbd857aebb0c05595d628220) 62 + version: 6.5.3(7c45f4c9f7a120d9de0460f4082612ea) 63 63 rolldown: 64 64 specifier: ^1.0.0-rc.18 65 65 version: 1.0.0-rc.18 66 + ssh2: 67 + specifier: ^1.17.0 68 + version: 1.17.0 66 69 vite-plus: 67 70 specifier: 0.1.20 68 71 version: 0.1.20(@types/node@24.13.2)(@vitest/coverage-v8@4.1.5)(esbuild@0.28.0)(happy-dom@20.9.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.21.0)(typescript@6.0.3)(vite@7.3.2(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) ··· 91 94 '@types/node': 92 95 specifier: ^24.13.2 93 96 version: 24.13.2 97 + '@types/ssh2': 98 + specifier: ^1.15.5 99 + version: 1.15.5 94 100 '@vitest/coverage-v8': 95 101 specifier: ^4.1.5 96 102 version: 4.1.5(@vitest/browser@4.1.5)(@voidzero-dev/vite-plus-test@0.1.20) ··· 2928 2934 '@types/json-schema@7.0.15': 2929 2935 resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 2930 2936 2937 + '@types/node@18.19.130': 2938 + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} 2939 + 2931 2940 '@types/node@24.13.2': 2932 2941 resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} 2933 2942 2934 2943 '@types/resolve@1.20.2': 2935 2944 resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} 2945 + 2946 + '@types/ssh2@1.15.5': 2947 + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} 2936 2948 2937 2949 '@types/web-bluetooth@0.0.21': 2938 2950 resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} ··· 3358 3370 resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} 3359 3371 engines: {node: '>= 14'} 3360 3372 3373 + asn1@0.2.6: 3374 + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} 3375 + 3361 3376 assertion-error@2.0.1: 3362 3377 resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 3363 3378 engines: {node: '>=12'} ··· 3453 3468 engines: {node: '>=6.0.0'} 3454 3469 hasBin: true 3455 3470 3471 + bcrypt-pbkdf@1.0.2: 3472 + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} 3473 + 3456 3474 before-after-hook@4.0.0: 3457 3475 resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} 3458 3476 ··· 3494 3512 buffer@6.0.3: 3495 3513 resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} 3496 3514 3515 + buildcheck@0.0.7: 3516 + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} 3517 + engines: {node: '>=10.0.0'} 3518 + 3497 3519 bundle-name@4.1.0: 3498 3520 resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} 3499 3521 engines: {node: '>=18'} ··· 3604 3626 3605 3627 core-util-is@1.0.3: 3606 3628 resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} 3629 + 3630 + cpu-features@0.0.10: 3631 + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} 3632 + engines: {node: '>=10.0.0'} 3607 3633 3608 3634 crc-32@1.2.2: 3609 3635 resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} ··· 4738 4764 multiformats@9.9.0: 4739 4765 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 4740 4766 4767 + nan@2.27.0: 4768 + resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} 4769 + 4741 4770 nano-staged@1.0.2: 4742 4771 resolution: {integrity: sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw==} 4743 4772 engines: {node: ^22 || >= 24} ··· 5387 5416 safe-buffer@5.2.1: 5388 5417 resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 5389 5418 5419 + safer-buffer@2.1.2: 5420 + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 5421 + 5390 5422 sax@1.6.0: 5391 5423 resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} 5392 5424 engines: {node: '>=11.0.0'} ··· 5494 5526 resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} 5495 5527 engines: {node: '>=20.16.0'} 5496 5528 hasBin: true 5529 + 5530 + ssh2@1.17.0: 5531 + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} 5532 + engines: {node: '>=10.16.0'} 5497 5533 5498 5534 standard-as-callback@2.1.0: 5499 5535 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} ··· 5652 5688 engines: {node: '>=18.0.0'} 5653 5689 hasBin: true 5654 5690 5691 + tweetnacl@0.14.5: 5692 + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} 5693 + 5655 5694 type-check@0.4.0: 5656 5695 resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} 5657 5696 engines: {node: '>= 0.8.0'} ··· 5688 5727 5689 5728 unctx@2.5.0: 5690 5729 resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} 5730 + 5731 + undici-types@5.26.5: 5732 + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} 5691 5733 5692 5734 undici-types@7.18.2: 5693 5735 resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} ··· 8495 8537 8496 8538 '@types/json-schema@7.0.15': {} 8497 8539 8540 + '@types/node@18.19.130': 8541 + dependencies: 8542 + undici-types: 5.26.5 8543 + 8498 8544 '@types/node@24.13.2': 8499 8545 dependencies: 8500 8546 undici-types: 7.18.2 8501 8547 8502 8548 '@types/resolve@1.20.2': {} 8549 + 8550 + '@types/ssh2@1.15.5': 8551 + dependencies: 8552 + '@types/node': 18.19.130 8503 8553 8504 8554 '@types/web-bluetooth@0.0.21': {} 8505 8555 ··· 9010 9060 - bare-buffer 9011 9061 - react-native-b4a 9012 9062 9063 + asn1@0.2.6: 9064 + dependencies: 9065 + safer-buffer: 2.1.2 9066 + 9013 9067 assertion-error@2.0.1: {} 9014 9068 9015 9069 ast-kit@2.2.0: ··· 9086 9140 9087 9141 baseline-browser-mapping@2.10.27: {} 9088 9142 9143 + bcrypt-pbkdf@1.0.2: 9144 + dependencies: 9145 + tweetnacl: 0.14.5 9146 + 9089 9147 before-after-hook@4.0.0: {} 9090 9148 9091 9149 bindings@1.5.0: ··· 9127 9185 base64-js: 1.5.1 9128 9186 ieee754: 1.2.1 9129 9187 9188 + buildcheck@0.0.7: 9189 + optional: true 9190 + 9130 9191 bundle-name@4.1.0: 9131 9192 dependencies: 9132 9193 run-applescript: 7.1.0 ··· 9234 9295 core-js@3.49.0: {} 9235 9296 9236 9297 core-util-is@1.0.3: {} 9298 + 9299 + cpu-features@0.0.10: 9300 + dependencies: 9301 + buildcheck: 0.0.7 9302 + nan: 2.27.0 9303 + optional: true 9237 9304 9238 9305 crc-32@1.2.2: {} 9239 9306 ··· 10372 10439 10373 10440 multiformats@9.9.0: {} 10374 10441 10442 + nan@2.27.0: 10443 + optional: true 10444 + 10375 10445 nano-staged@1.0.2: {} 10376 10446 10377 10447 nanoid@3.3.12: {} ··· 10485 10555 - supports-color 10486 10556 - uploadthing 10487 10557 10488 - nitropack@2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15): 10489 - dependencies: 10490 - '@cloudflare/kv-asset-handler': 0.4.2 10491 - '@rollup/plugin-alias': 6.0.0(rollup@4.60.2) 10492 - '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.2) 10493 - '@rollup/plugin-inject': 5.0.5(rollup@4.60.2) 10494 - '@rollup/plugin-json': 6.1.0(rollup@4.60.2) 10495 - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.2) 10496 - '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) 10497 - '@rollup/plugin-terser': 1.0.0(rollup@4.60.2) 10498 - '@vercel/nft': 1.5.0(rollup@4.60.2) 10499 - archiver: 7.0.1 10500 - c12: 3.3.4(magicast@0.5.3) 10501 - chokidar: 5.0.0 10502 - citty: 0.2.2 10503 - compatx: 0.2.0 10504 - confbox: 0.2.4 10505 - consola: 3.4.2 10506 - cookie-es: 2.0.1 10507 - croner: 10.0.1 10508 - crossws: 0.3.5 10509 - db0: 0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)) 10510 - defu: 6.1.7 10511 - destr: 2.0.5 10512 - dot-prop: 10.1.0 10513 - esbuild: 0.28.0 10514 - escape-string-regexp: 5.0.0 10515 - etag: 1.8.1 10516 - exsolve: 1.0.8 10517 - globby: 16.2.0 10518 - gzip-size: 7.0.0 10519 - h3: 1.15.11 10520 - hookable: 5.5.3 10521 - httpxy: 0.5.1 10522 - ioredis: 5.10.1 10523 - jiti: 2.7.0 10524 - klona: 2.0.6 10525 - knitwork: 1.3.0 10526 - listhen: 1.10.0(srvx@0.11.15) 10527 - magic-string: 0.30.21 10528 - magicast: 0.5.3 10529 - mime: 4.1.0 10530 - mlly: 1.8.2 10531 - node-fetch-native: 1.6.7 10532 - node-mock-http: 1.0.4 10533 - ofetch: 1.5.1 10534 - ohash: 2.0.11 10535 - pathe: 2.0.3 10536 - perfect-debounce: 2.1.0 10537 - pkg-types: 2.3.1 10538 - pretty-bytes: 7.1.0 10539 - radix3: 1.1.2 10540 - rollup: 4.60.2 10541 - rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2) 10542 - scule: 1.3.0 10543 - semver: 7.8.4 10544 - serve-placeholder: 2.0.2 10545 - serve-static: 2.2.1 10546 - source-map: 0.7.6 10547 - std-env: 4.1.0 10548 - ufo: 1.6.4 10549 - ultrahtml: 1.6.0 10550 - uncrypto: 0.1.3 10551 - unctx: 2.5.0 10552 - unenv: 2.0.0-rc.24 10553 - unimport: 6.3.0(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18) 10554 - unplugin-utils: 0.3.1 10555 - unstorage: 1.17.5(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(ioredis@5.10.1) 10556 - untyped: 2.0.0 10557 - unwasm: 0.5.3 10558 - youch: 4.1.1 10559 - youch-core: 0.3.3 10560 - transitivePeerDependencies: 10561 - - '@azure/app-configuration' 10562 - - '@azure/cosmos' 10563 - - '@azure/data-tables' 10564 - - '@azure/identity' 10565 - - '@azure/keyvault-secrets' 10566 - - '@azure/storage-blob' 10567 - - '@capacitor/preferences' 10568 - - '@deno/kv' 10569 - - '@electric-sql/pglite' 10570 - - '@libsql/client' 10571 - - '@netlify/blobs' 10572 - - '@planetscale/database' 10573 - - '@upstash/redis' 10574 - - '@vercel/blob' 10575 - - '@vercel/functions' 10576 - - '@vercel/kv' 10577 - - aws4fetch 10578 - - bare-abort-controller 10579 - - bare-buffer 10580 - - better-sqlite3 10581 - - drizzle-orm 10582 - - encoding 10583 - - idb-keyval 10584 - - mysql2 10585 - - oxc-parser 10586 - - react-native-b4a 10587 - - rolldown 10588 - - sqlite3 10589 - - srvx 10590 - - supports-color 10591 - - uploadthing 10592 - optional: true 10593 - 10594 10558 node-addon-api@7.1.1: {} 10595 10559 10596 10560 node-fetch-native@1.6.7: {} ··· 10630 10594 dependencies: 10631 10595 boolbase: 1.0.0 10632 10596 10633 - nuxt-og-image@6.5.3(b947c0efbbd857aebb0c05595d628220): 10597 + nuxt-og-image@6.5.3(7c45f4c9f7a120d9de0460f4082612ea): 10634 10598 dependencies: 10635 10599 '@clack/prompts': 1.5.1 10636 10600 '@nuxt/kit': 4.4.8(magicast@0.5.3) ··· 10670 10634 '@resvg/resvg-js': 2.6.2 10671 10635 '@resvg/resvg-wasm': 2.6.2 10672 10636 fontless: 0.2.1(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(ioredis@5.10.1)(vite@7.3.2(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) 10673 - nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15) 10637 + nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.133.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15) 10674 10638 playwright-core: 1.59.1 10675 10639 sharp: 0.34.5 10676 10640 unifont: 0.7.4 ··· 11488 11452 11489 11453 safe-buffer@5.2.1: {} 11490 11454 11455 + safer-buffer@2.1.2: {} 11456 + 11491 11457 sax@1.6.0: {} 11492 11458 11493 11459 scule@1.3.0: {} ··· 11617 11583 11618 11584 srvx@0.11.15: {} 11619 11585 11586 + ssh2@1.17.0: 11587 + dependencies: 11588 + asn1: 0.2.6 11589 + bcrypt-pbkdf: 1.0.2 11590 + optionalDependencies: 11591 + cpu-features: 0.0.10 11592 + nan: 2.27.0 11593 + 11620 11594 standard-as-callback@2.1.0: {} 11621 11595 11622 11596 statuses@2.0.2: {} ··· 11783 11757 optionalDependencies: 11784 11758 fsevents: 2.3.3 11785 11759 11760 + tweetnacl@0.14.5: {} 11761 + 11786 11762 type-check@0.4.0: 11787 11763 dependencies: 11788 11764 prelude-ls: 1.2.1 ··· 11824 11800 estree-walker: 3.0.3 11825 11801 magic-string: 0.30.21 11826 11802 unplugin: 2.3.11 11803 + 11804 + undici-types@5.26.5: {} 11827 11805 11828 11806 undici-types@7.18.2: {} 11829 11807 ··· 11868 11846 optionalDependencies: 11869 11847 oxc-parser: 0.133.0 11870 11848 rolldown: 1.0.0-rc.18 11871 - 11872 - unimport@6.3.0(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18): 11873 - dependencies: 11874 - acorn: 8.16.0 11875 - escape-string-regexp: 5.0.0 11876 - estree-walker: 3.0.3 11877 - local-pkg: 1.1.2 11878 - magic-string: 0.30.21 11879 - mlly: 1.8.2 11880 - pathe: 2.0.3 11881 - picomatch: 4.0.4 11882 - pkg-types: 2.3.1 11883 - scule: 1.3.0 11884 - strip-literal: 3.1.0 11885 - tinyglobby: 0.2.17 11886 - unplugin: 3.0.0 11887 - unplugin-utils: 0.3.1 11888 - optionalDependencies: 11889 - oxc-parser: 0.134.0 11890 - rolldown: 1.0.0-rc.18 11891 - optional: true 11892 11849 11893 11850 universal-github-app-jwt@2.2.2: {} 11894 11851
+101 -28
server/utils/git-wire/receive-pack.ts
··· 1 - import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' 2 - import { Readable } from 'node:stream' 1 + import { PassThrough, Readable } from 'node:stream' 2 + import { Client } from 'ssh2' 3 3 import { classifyNgReason, classifySshStderr, WireError } from './errors' 4 4 import { encodePktLine, flushPkt, lineToString, PktLineReader } from './pkt-line' 5 5 import { type Advertisement, parseAdvertisement } from './refs' ··· 24 24 } 25 25 26 26 /** 27 - * A spawned process exposing `git-receive-pack`'s stdio. The default factory 28 - * runs ssh to the knot; tests inject a factory that spawns the binary against 29 - * a local bare repo, so the stdio protocol is identical either way. 27 + * A process exposing `git-receive-pack`'s stdio. The default factory runs an 28 + * in-process `ssh2` channel to the knot; tests inject a factory that spawns the 29 + * binary against a local bare repo, so the stdio protocol is identical either 30 + * way. 30 31 */ 31 32 export interface ReceivePackProcess { 32 33 stdin: NodeJS.WritableStream ··· 44 45 host: string 45 46 port?: number 46 47 repoPath: string 47 - sshArgs: string[] 48 + /** Decrypted OpenSSH-format private key for this install. */ 49 + privateKey: string 48 50 } 49 51 50 - /** Default transport: ssh to the knot and invoke its `git-receive-pack`. */ 51 - export function sshReceivePackFactory(target: SshTarget): ReceivePackFactory { 52 + /** 53 + * Default transport: open an in-process `ssh2` connection to the knot and run 54 + * its `git-receive-pack`. No `ssh` binary (the Vercel runtime has none); the 55 + * key stays in memory. 56 + * 57 + * Host keys: tangled knots are addressed by hostname over TLS-fronted DNS, and 58 + * v1 has no pinned host keys, so `hostVerifier` accepts any (TOFU-equivalent to 59 + * the previous `StrictHostKeyChecking=accept-new`). Pin once the canonical knot 60 + * keys are known. 61 + */ 62 + export function ssh2ReceivePackFactory(target: SshTarget): ReceivePackFactory { 52 63 return () => { 53 - const portArgs = target.port ? ['-p', String(target.port)] : [] 54 - // ssh:// transports invoke the remote command with the path including its 55 - // leading slash, single-quoted. The knot resolves repos by that path. 64 + // The knot resolves repos by the leading-slash path, single-quoted. 56 65 const remoteCmd = `git-receive-pack '${target.repoPath}'` 57 - const child = spawn('ssh', [...target.sshArgs, ...portArgs, `git@${target.host}`, remoteCmd], { 58 - stdio: ['pipe', 'pipe', 'pipe'], 66 + const client = new Client() 67 + 68 + const stdin = new PassThrough() 69 + const stdout = new PassThrough() 70 + let stderrBuf = Buffer.alloc(0) 71 + let connError: Error | null = null 72 + let killed = false 73 + 74 + const appendStderr = (chunk: Buffer) => { 75 + stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP) 76 + } 77 + 78 + const done = new Promise<number | null>(resolve => { 79 + let settled = false 80 + const settle = (code: number | null) => { 81 + if (settled) return 82 + settled = true 83 + resolve(code) 84 + } 85 + 86 + client.on('ready', () => { 87 + client.exec(remoteCmd, (err, channel) => { 88 + if (err) { 89 + connError = err 90 + stdout.end() 91 + client.end() 92 + settle(null) 93 + return 94 + } 95 + stdin.pipe(channel) 96 + channel.pipe(stdout) 97 + channel.stderr.on('data', appendStderr) 98 + channel.on('exit', code => settle(typeof code === 'number' ? code : null)) 99 + channel.on('close', () => { client.end(); stdout.end() }) 100 + }) 101 + }) 102 + 103 + // A connection / auth failure surfaces here. Capturing it (rather than 104 + // leaving 'error' unhandled, which crashes the process) folds the message 105 + // into the stderr band so open() reports a WireError the job handler 106 + // catches. Ending stdout unblocks the advertisement read. 107 + client.on('error', err => { 108 + if (!killed) connError = err 109 + stdout.end() 110 + settle(null) 111 + }) 112 + 113 + // Always-fires backstop: `client.end()` (from kill(), a channel close, or 114 + // an exec error) emits 'close', so `done` resolves even if the channel 115 + // already exited and won't emit another event. 116 + client.on('close', () => { 117 + stdout.end() 118 + settle(null) 119 + }) 59 120 }) 60 - return wrapChild(child) 61 - } 62 - } 63 121 64 - function wrapChild(child: ChildProcessWithoutNullStreams): ReceivePackProcess { 65 - let stderrBuf = Buffer.alloc(0) 66 - child.stderr.on('data', (chunk: Buffer) => { 67 - stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP) 68 - }) 69 - const done = new Promise<number | null>(resolve => child.on('close', resolve)) 70 - return { 71 - stdin: child.stdin, 72 - stdout: child.stdout, 73 - stderr: () => stderrBuf.toString('utf8'), 74 - kill: () => child.kill('SIGKILL'), 75 - done, 122 + // stdin EPIPE-style errors once the channel goes away are expected. 123 + stdin.on('error', () => {}) 124 + 125 + client.connect({ 126 + host: target.host, 127 + port: target.port ?? 22, 128 + username: 'git', 129 + privateKey: target.privateKey, 130 + readyTimeout: 15_000, 131 + hostVerifier: () => true, 132 + }) 133 + 134 + return { 135 + stdin, 136 + stdout, 137 + stderr: () => { 138 + const captured = stderrBuf.toString('utf8') 139 + if (connError) return `${captured}${captured ? '\n' : ''}ssh error: ${connError.message}`.trim() 140 + return captured 141 + }, 142 + kill: () => { 143 + killed = true 144 + client.end() 145 + stdout.end() 146 + }, 147 + done, 148 + } 76 149 } 77 150 } 78 151
+9 -23
server/utils/splice.ts
··· 2 2 type ReceivePackFactory, 3 3 ReceivePackSession, 4 4 type RefUpdate, 5 - sshReceivePackFactory, 5 + ssh2ReceivePackFactory, 6 6 } from './git-wire/receive-pack' 7 7 import { ZERO_SHA } from './git-wire/refs' 8 8 import { fetchPack } from './git-wire/upload-pack' 9 - import { loadSshArgsForInstall } from './ssh-cmd' 9 + import { loadSshKeyForInstall } from './ssh-cmd' 10 10 import { sshEndpointForKnot } from './sync-push-host' 11 11 12 12 const DEFAULT_MAX_PACK_BYTES = 1024 * 1024 * 1024 ··· 20 20 return Number.isNaN(n) || n <= 0 ? DEFAULT_MAX_PACK_BYTES : n 21 21 } 22 22 23 - async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<{ 24 - factory: ReceivePackFactory 25 - cleanup: () => void 26 - }> { 27 - const { args, cleanup } = await loadSshArgsForInstall(installationId) 23 + async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<ReceivePackFactory> { 24 + const privateKey = await loadSshKeyForInstall(installationId) 28 25 const { host, port } = sshEndpointForKnot(knot) 29 26 // ssh:// path form: leading slash, the knot resolves the repo by DID. 30 - const factory = sshReceivePackFactory({ host, port, repoPath: `/${repoDid}`, sshArgs: args }) 31 - return { factory, cleanup } 27 + return ssh2ReceivePackFactory({ host, port, repoPath: `/${repoDid}`, privateKey }) 32 28 } 33 29 34 30 export interface SplicePushParams { ··· 64 60 * keeps the knot's advertised tip as the authoritative compare-and-swap base. 65 61 */ 66 62 export async function splicePush(params: SplicePushParams): Promise<SplicePushResult> { 67 - const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid) 68 - try { 69 - return await runSplice(factory, params) 70 - } 71 - finally { 72 - cleanup() 73 - } 63 + const factory = await sshFactory(params.installationId, params.knot, params.repoDid) 64 + return runSplice(factory, params) 74 65 } 75 66 76 67 /** The fetch + push exchange over an open session. Split out for the wire test. */ ··· 127 118 repoDid: string 128 119 ref: string 129 120 }): Promise<SpliceDeleteResult> { 130 - const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid) 131 - try { 132 - return await runSpliceDelete(factory, params.ref) 133 - } 134 - finally { 135 - cleanup() 136 - } 121 + const factory = await sshFactory(params.installationId, params.knot, params.repoDid) 122 + return runSpliceDelete(factory, params.ref) 137 123 } 138 124 139 125 /** The delete exchange over an open session. Split out for the wire test. */
+7 -53
server/utils/ssh-cmd.ts
··· 1 - import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' 2 - import os from 'node:os' 3 - import path from 'node:path' 4 1 import { sql } from 'drizzle-orm' 5 2 import { sshKey } from '../db/schema' 6 3 import { useDb } from './db' ··· 8 5 import { pkcs8ToOpenSshPrivate } from './ssh-keypair' 9 6 10 7 /** 11 - * Materialise the install's SSH private key as an OpenSSH-format file on disk 12 - * and return: 13 - * - `args`: the ssh option list (`-i <key> -o ...`) ready to splice into a 14 - * `spawn('ssh', [...args, target, command])` call 15 - * - a `cleanup()` callback that synchronously removes the temp dir 16 - * 17 - * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename 18 - * (collision-resistant for concurrent worker invocations on the same instance), 19 - * and is removed in `cleanup()`. Callers must invoke `cleanup()` in a `finally` 20 - * — leaking the key on disk is the worst failure mode here. 8 + * Decrypt the install's SSH private key and return it as an in-memory 9 + * OpenSSH-format string, ready to hand to the `ssh2` client. 21 10 * 22 - * Host key checking: tangled knots are addressed by hostname; v1 uses 23 - * `StrictHostKeyChecking=accept-new` (TOFU) with a per-call empty known_hosts, 24 - * which is effectively "trust the DNS for the configured knot". A future 25 - * commit can ship pinned host keys for the canonical knots once we know what 26 - * those are. 11 + * The push transport runs in-process via `ssh2` (no `ssh` binary, which the 12 + * Vercel runtime doesn't provide), so the key never touches disk: it's 13 + * decrypted, used for one connection, and dropped when the function returns. 27 14 */ 28 - export async function loadSshArgsForInstall(installationId: number): Promise<{ 29 - args: string[] 30 - cleanup: () => void 31 - }> { 15 + export async function loadSshKeyForInstall(installationId: number): Promise<string> { 32 16 const db = useDb() 33 17 const rows = await db.select({ 34 18 privateKeyCiphertext: sshKey.privateKeyCiphertext, ··· 44 28 const row = rows[0]! 45 29 46 30 const pem = decrypt(row.privateKeyCiphertext, row.privateKeyNonce) 47 - const openSsh = pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`) 48 - 49 - // Distinct dir per call so concurrent pushes within one process don't race. 50 - const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-')) 51 - const keyPath = path.join(dir, 'id_ed25519') 52 - const knownHostsPath = path.join(dir, 'known_hosts') 53 - 54 - writeFileSync(keyPath, openSsh, { mode: 0o600 }) 55 - chmodSync(keyPath, 0o600) 56 - writeFileSync(knownHostsPath, '', { mode: 0o600 }) 57 - 58 - const args = [ 59 - '-i', keyPath, 60 - '-o', `UserKnownHostsFile=${knownHostsPath}`, 61 - '-o', 'StrictHostKeyChecking=accept-new', 62 - '-o', 'IdentitiesOnly=yes', 63 - '-o', 'BatchMode=yes', 64 - '-o', 'ConnectTimeout=15', 65 - ] 66 - 67 - return { 68 - args, 69 - cleanup: () => { 70 - try { 71 - rmSync(dir, { recursive: true, force: true }) 72 - } 73 - catch { 74 - // best-effort; the temp dir will be cleaned up on process restart. 75 - } 76 - }, 77 - } 31 + return pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`) 78 32 }
+130
test/unit/ssh2-receive-pack.spec.ts
··· 1 + import { spawn } from 'node:child_process' 2 + import { afterEach, beforeEach, describe, expect, it } from 'vitest' 3 + import { Server, utils as sshUtils } from 'ssh2' 4 + import { ReceivePackSession } from '../../server/utils/git-wire/receive-pack' 5 + import { ssh2ReceivePackFactory } from '../../server/utils/git-wire/receive-pack' 6 + import { ZERO_SHA } from '../../server/utils/git-wire/refs' 7 + import { generateKeypair, pkcs8ToOpenSshPrivate } from '../../server/utils/ssh-keypair' 8 + import { fakeGithubFetch, GitFixture } from '../utils/git-wire' 9 + import { fetchPack } from '../../server/utils/git-wire/upload-pack' 10 + 11 + async function* fromBuffer(b: Buffer): AsyncGenerator<Buffer> { 12 + yield b 13 + } 14 + 15 + async function drain(gen: AsyncGenerator<Buffer>): Promise<Buffer> { 16 + const parts: Buffer[] = [] 17 + for await (const c of gen) parts.push(c) 18 + return Buffer.concat(parts) 19 + } 20 + 21 + /** 22 + * An in-process ssh2 server that authorises one public key and runs the real 23 + * `git-receive-pack` against the given bare repo on exec. Mirrors the knot's 24 + * `git@host: git-receive-pack '<path>'` surface so the ssh2 factory is exercised 25 + * end to end. 26 + */ 27 + function startKnotServer(authorizedPubKey: string, repoFor: (path: string) => string | null) { 28 + const hostKey = sshUtils.generateKeyPairSync('ed25519').private 29 + const parsed = sshUtils.parseKey(authorizedPubKey) 30 + if (parsed instanceof Error) throw parsed 31 + const allowed = Array.isArray(parsed) ? parsed[0]! : parsed 32 + const allowedSSH = allowed.getPublicSSH() 33 + 34 + const server = new Server({ hostKeys: [hostKey] }, client => { 35 + client.on('authentication', ctx => { 36 + if (ctx.method === 'publickey' && ctx.key.algo === allowed.type && ctx.key.data.equals(allowedSSH)) { 37 + ctx.accept() 38 + return 39 + } 40 + ctx.reject() 41 + }) 42 + client.on('ready', () => { 43 + client.on('session', accept => { 44 + accept().once('exec', (acceptExec, _reject, info) => { 45 + const match = info.command.match(/^git-receive-pack '(.+)'$/) 46 + const repo = match ? repoFor(match[1]!) : null 47 + const stream = acceptExec() 48 + if (!repo) { 49 + stream.stderr.write('repository not found\n') 50 + stream.exit(128) 51 + stream.end() 52 + return 53 + } 54 + const child = spawn('git-receive-pack', [repo], { stdio: ['pipe', 'pipe', 'pipe'] }) 55 + stream.pipe(child.stdin) 56 + child.stdout.pipe(stream) 57 + child.stderr.on('data', (d: Buffer) => stream.stderr.write(d)) 58 + child.on('close', code => { stream.exit(code ?? 0); stream.end() }) 59 + }) 60 + }) 61 + }) 62 + }) 63 + 64 + return new Promise<{ port: number, close: () => void }>(resolve => { 65 + server.listen(0, '127.0.0.1', () => { 66 + resolve({ port: (server.address() as { port: number }).port, close: () => server.close() }) 67 + }) 68 + }) 69 + } 70 + 71 + describe('ssh2ReceivePackFactory (against an in-process ssh2 knot)', () => { 72 + let fx: GitFixture 73 + let realFetch: typeof globalThis.fetch 74 + let knotServer: { port: number, close: () => void } | null = null 75 + 76 + beforeEach(() => { 77 + fx = new GitFixture() 78 + realFetch = globalThis.fetch 79 + }) 80 + 81 + afterEach(() => { 82 + globalThis.fetch = realFetch 83 + knotServer?.close() 84 + knotServer = null 85 + fx.cleanup() 86 + }) 87 + 88 + async function packFor(ghBare: string, want: string, haves: string[]): Promise<Buffer> { 89 + globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', ghBare]])) as unknown as typeof globalThis.fetch 90 + const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 't', want, haves, maxBytes: 1 << 30 }) 91 + return drain(pack) 92 + } 93 + 94 + it('pushes a ref to the knot over a real ssh2 connection', async () => { 95 + const gh = fx.initBare('gh.git') 96 + const work = fx.initWork('work') 97 + const sha = fx.commit(work, 'a.txt', 'hello') 98 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 99 + const knot = fx.initBare('knot.git') 100 + 101 + const key = generateKeypair('synchub.to/1') 102 + knotServer = await startKnotServer(key.publicKeyOpenSsh, p => (p === '/repo-did' ? knot : null)) 103 + 104 + const factory = ssh2ReceivePackFactory({ 105 + host: '127.0.0.1', 106 + port: knotServer.port, 107 + repoPath: '/repo-did', 108 + // Mirror production: the worker hands ssh2 the OpenSSH-format key that 109 + // `loadSshKeyForInstall` derives from the stored PKCS#8 PEM. 110 + privateKey: pkcs8ToOpenSshPrivate(key.privateKeyPem, 'synchub.to/1'), 111 + }) 112 + 113 + const session = await ReceivePackSession.open(factory) 114 + await session.push([{ ref: 'refs/heads/main', old: ZERO_SHA, next: sha }], fromBuffer(await packFor(gh, sha, []))) 115 + 116 + expect(fx.revParse(knot, 'refs/heads/main')).toBe(sha) 117 + }) 118 + 119 + it('surfaces a connection failure as a caught WireError, not an uncaught throw', async () => { 120 + const key = generateKeypair('synchub.to/1') 121 + // Nothing listening on this port: connect() emits 'error' (ECONNREFUSED). 122 + const factory = ssh2ReceivePackFactory({ 123 + host: '127.0.0.1', 124 + port: 1, 125 + repoPath: '/repo-did', 126 + privateKey: pkcs8ToOpenSshPrivate(key.privateKeyPem, 'synchub.to/1'), 127 + }) 128 + await expect(ReceivePackSession.open(factory)).rejects.toThrow(/ssh error|ECONNREFUSED|advertisement|end of stream/) 129 + }) 130 + })