Compare commits
1316 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 947547b4d8 | |||
| 29133c0e37 | |||
| 8850b7a9f6 | |||
| a9b62fafa7 | |||
| 05de97d20c | |||
| 8106b089c0 | |||
| 986146d5a1 | |||
| 939b8a9dee | |||
| 8a12e1b50a | |||
| 17ddefd9a2 | |||
| 27470da264 | |||
| f7404f37c0 | |||
| 41e6a6986b | |||
| 63e062d8f8 | |||
| 6bc8d793f8 | |||
| 94ab81fa64 | |||
| 333553d283 | |||
| 4b4e88102e | |||
| aa18df8724 | |||
| 250193a881 | |||
| c890376c5d | |||
| 4050da5b13 | |||
| b5b4769116 | |||
| 943714cb47 | |||
| ac5e679eea | |||
| a3e2cd892f | |||
| 68066a5321 | |||
| ce93a90e89 | |||
| ac8a0c4466 | |||
| 86a4bdc999 | |||
| 4636ced3a4 | |||
| 646b1c90a2 | |||
| 48ecb49a59 | |||
| 396e17f698 | |||
| c4230233bb | |||
| d14c0890fa | |||
| d9f5017ade | |||
| 553a2e68cc | |||
| 0de793bb80 | |||
| 7f8c6d6dc1 | |||
| 880b52ed52 | |||
| b33c3530b0 | |||
| 705ecb8f18 | |||
| cd812f2a3a | |||
| 842d104431 | |||
| c031d3e34c | |||
| 30a7fe25d9 | |||
| 7e6756f99e | |||
| 9621d38ddd | |||
| bffb3a95fc | |||
| c9324271f6 | |||
| 2eb006fa70 | |||
| b803016077 | |||
| ecab9a4be1 | |||
| 029b0fd08a | |||
| 2cd85b7440 | |||
| c3b0ec99cd | |||
| 59c207ef29 | |||
| 0cf8b64a4b | |||
| 69ab3f9770 | |||
| 3f08b6949e | |||
| 17e3e8ad63 | |||
| ceb6b63e2d | |||
| e377d40104 | |||
| de8d33e715 | |||
| 6864518bd9 | |||
| b75f0b92a1 | |||
| 2e32ed7fd1 | |||
| 30d2333cad | |||
| 1271ccce77 | |||
| 82d4bb34b7 | |||
| f8ca89ada6 | |||
| f85fa7d786 | |||
| 45c91ccc7f | |||
| c17efc489c | |||
| f7fffe2859 | |||
| 62ab4ac379 | |||
| 1744fab580 | |||
| 01de5de342 | |||
| d69451c9e6 | |||
| fceb0bbb23 | |||
| f5a3e41190 | |||
| 3113346b47 | |||
| 0dc9691557 | |||
| cef796272c | |||
| dc45089908 | |||
| 395bfa3bb3 | |||
| c37e9a2a15 | |||
| 90b0204205 | |||
| 904218aa4c | |||
| f08d1b68ab | |||
| cfd7c72fd2 | |||
| 509471c97d | |||
| f748d14d25 | |||
| ae2bdb48cf | |||
| dcc5a7a253 | |||
| 25ed795ff6 | |||
| 431db5f7d4 | |||
| 24253f189c | |||
| a59192d95d | |||
| d734ed3677 | |||
| 1379ebec06 | |||
| c5eded9f7b | |||
| 68c2efdb5c | |||
| 00a8461b5b | |||
| 7154c7f0e0 | |||
| 0f07666aeb | |||
| 489dc482e8 | |||
| 55c4759780 | |||
| 16b4fe0080 | |||
| 9cd995c5f2 | |||
| e60d6a5d67 | |||
| b274af05c3 | |||
| 40e725f2ed | |||
| 0f091eefc9 | |||
| 2b00c58e9b | |||
| 6d9681fb8a | |||
| acc4d27677 | |||
| ffe581a92b | |||
| 6e643515ce | |||
| 1cc7aca53f | |||
| e5111622a6 | |||
| 99b98cb5c9 | |||
| 20447cfb46 | |||
| 723a1813c9 | |||
| 04d52a0631 | |||
| 097d18cd56 | |||
| 5043995d14 | |||
| 804cfc8b72 | |||
| a0dc6f3333 | |||
| a53957271e | |||
| abae483dd3 | |||
| 4418e09211 | |||
| ca79457c19 | |||
| d2cd947b9c | |||
| ea28285639 | |||
| 6174ddea9a | |||
| ce56adc3c8 | |||
| 59334bb13a | |||
| 21cd1f5217 | |||
| 515df53c96 | |||
| cbf7f48c44 | |||
| ed37aeb615 | |||
| ea89558696 | |||
| c0bad75a37 | |||
| bdc227f110 | |||
| a9c3a1b6de | |||
| f0e7b0b63a | |||
| a709e2f1e3 | |||
| 3a34bd74db | |||
| b755cb715a | |||
| 42753fae0a | |||
| d8b8fa2d6e | |||
| a75277e0b8 | |||
| f95b83674b | |||
| bb13e780af | |||
| 6e80d5187c | |||
| db9b704d1d | |||
| 65a2b5361c | |||
| 7bca73cd7b | |||
| 59383014be | |||
| 60a5296f5f | |||
| eb4fe333a7 | |||
| 8d06d6d817 | |||
| b6afb536b6 | |||
| 790047fa8a | |||
| baf55b6b6d | |||
| 92d85244f1 | |||
| e0e8fef265 | |||
| deb0f8344b | |||
| ed135950eb | |||
| ed7c547a06 | |||
| 1a9f927c4c | |||
| 350af86b65 | |||
| e136b73192 | |||
| b6bf2fec21 | |||
| 56daadbbca | |||
| 7de26ec9a7 | |||
| 7dbd3036df | |||
| 687085f9c7 | |||
| 96cfc7e184 | |||
| 0b26433b1a | |||
| 4f06bb40f8 | |||
| 46d571b65a | |||
| 7da1bfd315 | |||
| b30f47b5aa | |||
| ccc6c7206c | |||
| a549f4cae0 | |||
| 07c451ce22 | |||
| 62da624369 | |||
| a1a9b03180 | |||
| e4c1d37c35 | |||
| 42cacac256 | |||
| 2816970327 | |||
| 5744fcdcb2 | |||
| 3c412b394d | |||
| 4e64cea373 | |||
| db579db87f | |||
| f612a918d9 | |||
| 1a78e06a7a | |||
| 374a98ffc3 | |||
| d723e2e273 | |||
| 41030171b5 | |||
| aa31706bcc | |||
| 4e3810493d | |||
| eeb2db92ed | |||
| 5a2930029b | |||
| 2e9a73923b | |||
| 97e67f8a88 | |||
| 074849af74 | |||
| 6939783518 | |||
| 260e48fa48 | |||
| 52c4ef5df5 | |||
| 46b634e0fa | |||
| a556c29a17 | |||
| bbc4f508b5 | |||
| fe0243549a | |||
| d9196ba650 | |||
| a0ea1fa392 | |||
| 195bcb4495 | |||
| 929627a191 | |||
| 83a313b4b7 | |||
| f734bfb50f | |||
| 9efc22d195 | |||
| bd3bef5fb0 | |||
| ddbe49e847 | |||
| ed9bddec0b | |||
| 73a07eeb54 | |||
| 6e27aed804 | |||
| 5f955781a6 | |||
| c124f7310e | |||
| 73ffcae549 | |||
| 80e55ba0be | |||
| eecf955096 | |||
| 6426db9a5d | |||
| bf4753cd09 | |||
| a592eaf383 | |||
| 75d654cec3 | |||
| a18ed37600 | |||
| 6747a8dfbc | |||
| 583dfb8126 | |||
| bda31dd086 | |||
| 283af35f3b | |||
| 82564e2580 | |||
| fc6a9026e6 | |||
| e1538bb5ab | |||
| d47db969ea | |||
| f01de37a59 | |||
| cee429a0a0 | |||
| 321ee89676 | |||
| aa60484315 | |||
| b807df3ee8 | |||
| c11e922573 | |||
| 7135a3bbeb | |||
| 142b87dd67 | |||
| dff4579f28 | |||
| 2b3b662a9a | |||
| 98bc5195b1 | |||
| a02912ccbb | |||
| 2f1d29dfd2 | |||
| 11efacc4cb | |||
| 8133d0da3d | |||
| 093d63faae | |||
| 07f5e04488 | |||
| acfc97fc7c | |||
| 6e30554143 | |||
| fa1c788c41 | |||
| 3a688942c3 | |||
| 6797522618 | |||
| 4730a53313 | |||
| 5a538792f0 | |||
| 5f20289767 | |||
| 4c7ed97a76 | |||
| a9c2228e3c | |||
| ec44fcb7cb | |||
| 1f9c8d261b | |||
| 23ab3bd416 | |||
| 79482775d2 | |||
| 2dcb3678b0 | |||
| 8d7c206012 | |||
| c21c264426 | |||
| 77a39c18f1 | |||
| 65a00b0a8e | |||
| 0c2d5679f1 | |||
| 75d0e0de43 | |||
| 19ff864eaa | |||
| 6bbee7cb10 | |||
| e268923cf6 | |||
| 4fb3d7182c | |||
| 6e7a6ec032 | |||
| 0e92c92f87 | |||
| a6f1eb39ec | |||
| 21364f6239 | |||
| a0527094a9 | |||
| eda890c294 | |||
| 1ed8db87cc | |||
| 136892478f | |||
| aee90d31a0 | |||
| 6fe632fda3 | |||
| fa975a2f5b | |||
| bd5ab14d2f | |||
| 32890bfda9 | |||
| a3d4113b97 | |||
| 8acfa722f8 | |||
| 234beba06a | |||
| 90c27a0f73 | |||
| 2577a5d2c2 | |||
| 84bf10b65b | |||
| 5d70f5fd97 | |||
| f42cc59d53 | |||
| d12b222d52 | |||
| b663c7f9d4 | |||
| 30abfa7e23 | |||
| 0be32c34c5 | |||
| 8d4863b894 | |||
| ad1ae8fb54 | |||
| b31e41bfd9 | |||
| 999d5a937d | |||
| 45ea543a4b | |||
| 9d72a5e02d | |||
| 8973c45b4e | |||
| b3e5a5dc62 | |||
| d8bddadd42 | |||
| aa552d1c80 | |||
| 6daac5f442 | |||
| a8c453fbda | |||
| 3e15da5ad7 | |||
| 8e776e7d42 | |||
| de3d5654b4 | |||
| b0e19ffa30 | |||
| bacf451cf5 | |||
| 2a5600b6af | |||
| bfbc78d9b7 | |||
| d509281bdc | |||
| 866fdfb4f6 | |||
| 6c14d0f4c5 | |||
| b31a8e3bf8 | |||
| 7f3261e453 | |||
| 861c3617dd | |||
| 1f4b1f5421 | |||
| f851460a32 | |||
| 992ac6d9eb | |||
| 2e9ac57930 | |||
| ce8d090f27 | |||
| 4ec8b59fd6 | |||
| 6b086af8b4 | |||
| 52f95665ed | |||
| d1ce5ab105 | |||
| 0fd10649f4 | |||
| 3c65900768 | |||
| 0b962b4fee | |||
| a58633fc5f | |||
| 24c2ed3ae3 | |||
| 84a77d98cf | |||
| 20e50e02e9 | |||
| c8bb354972 | |||
| 3f9b73d211 | |||
| 40bbffed17 | |||
| b16ec4e809 | |||
| 9629c895d5 | |||
| f4420fcc03 | |||
| 17f8c3e134 | |||
| 02512a522c | |||
| 4974f99842 | |||
| a3d0c32be3 | |||
| 31523160fc | |||
| 183d5cdeec | |||
| 52fed810f2 | |||
| f58d63da8d | |||
| 6793e6fbdc | |||
| 4f88e9b97d | |||
| 5004cec9b3 | |||
| 76c81e4209 | |||
| 323043dc01 | |||
| 37902ce70a | |||
| c69e795d49 | |||
| c3b82af95a | |||
| 24510d4828 | |||
| 11a04a99f7 | |||
| 6e96eeaa05 | |||
| 2c16d4af5a | |||
| d1f9cf43de | |||
| 26fb11cb30 | |||
| a7a3eb8658 | |||
| fbedcd1f5f | |||
| 6033af0f23 | |||
| 4809786b16 | |||
| a37f40ff6e | |||
| 44f5cc9de0 | |||
| e059d0cbd9 | |||
| c51040b0de | |||
| eba2abc108 | |||
| ed7626009a | |||
| 442413ec6b | |||
| 90986df641 | |||
| 8b505dbce3 | |||
| 2b26e9643b | |||
| eb16f01839 | |||
| 872cf33d1c | |||
| afbce19028 | |||
| e27228e735 | |||
| 3125232114 | |||
| e552840cc4 | |||
| 569142845a | |||
| e62d837858 | |||
| 27badfb88a | |||
| e4d0781eb8 | |||
| 93c8373335 | |||
| 42bad6b4ea | |||
| 83d4bd88cf | |||
| 4141267104 | |||
| d5778f165b | |||
| 7128e26a27 | |||
| 78247e9969 | |||
| 66d36e034e | |||
| 4100ee842c | |||
| d4857c8e6e | |||
| 0f52781489 | |||
| 332f19cd22 | |||
| 53a1c103d6 | |||
| addf7a3e6b | |||
| c76078ea06 | |||
| 9299288e8a | |||
| dc34f24ee2 | |||
| fe83bf1483 | |||
| c184f9281d | |||
| 3127a69b67 | |||
| d7326c4f82 | |||
| f8478284b6 | |||
| 1c910de4b1 | |||
| 2c3cb955ef | |||
| 1d7746d839 | |||
| e16d2ba238 | |||
| 60f8b448ec | |||
| c0d61f29a8 | |||
| c47b99f9ee | |||
| 653429a6d6 | |||
| c2250e1817 | |||
| 92ca48d65f | |||
| 2f53d9a79d | |||
| d025bfe2da | |||
| fbc2460ac4 | |||
| 76c2800275 | |||
| 536bf984ee | |||
| 23f8af43ee | |||
| eba54bf2e7 | |||
| 50270e6541 | |||
| 5f7d9e80ee | |||
| a71ef35d5c | |||
| 2cf5291831 | |||
| 75e3e9dd7b | |||
| 7a75365d2f | |||
| 7a23d59aa1 | |||
| 2583423600 | |||
| 1d748a0424 | |||
| 86172790d1 | |||
| 92c9103059 | |||
| 16393dbc9d | |||
| e841b0ef03 | |||
| 27c7ae9d76 | |||
| dc3a40675d | |||
| 31e65ac2e3 | |||
| c2444d1b09 | |||
| 2faadae4cf | |||
| d24e51c355 | |||
| 8ed8af1b9c | |||
| 5f6b416f26 | |||
| 3671c05573 | |||
| db6eed9292 | |||
| 4c903ba0bb | |||
| 9eee216065 | |||
| 9e1dd02d70 | |||
| b986bf9bcc | |||
| a3e0e62efc | |||
| 8646c5acf6 | |||
| dab9465755 | |||
| 3a076cc4ea | |||
| 1b6d1ea0ef | |||
| c490284c65 | |||
| c3ca329138 | |||
| d0f73c32bb | |||
| 79e0da7cf2 | |||
| b261f505aa | |||
| 65c4c95cbe | |||
| 5b3c9c9055 | |||
| d32d4f845c | |||
| 637950d37f | |||
| 0e1ede58e5 | |||
| e914a11768 | |||
| 73d66dab35 | |||
| 634766a08e | |||
| 5fbc450e96 | |||
| 5acd7ae2c4 | |||
| 09578ddc5d | |||
| 8270801711 | |||
| de91c3cfc8 | |||
| 06888a2061 | |||
| cde2d33d03 | |||
| 9cf89f95d1 | |||
| 20486e15ea | |||
| 420c403da1 | |||
| cb8fdf3461 | |||
| 0937d08770 | |||
| 9b1fd8e1aa | |||
| d87861142c | |||
| ff5fc1d0f3 | |||
| d5a671cbb8 | |||
| d0508cbd96 | |||
| 12098cf8ac | |||
| 2557c35bea | |||
| 11f23bbd77 | |||
| 2b2ec729ac | |||
| 9292acec8c | |||
| 3095ed5375 | |||
| a333e303c1 | |||
| bd8512feab | |||
| c235fc4a7a | |||
| 9c283b9aee | |||
| 4e824d739b | |||
| fcbbc792b0 | |||
| ba7cf8e79e | |||
| 4ab49d8e43 | |||
| 7f864db80d | |||
| 772a163693 | |||
| c8e106f272 | |||
| ade4f0a1d4 | |||
| 4fcf933dd1 | |||
| 1fb60771ca | |||
| 2cd391e106 | |||
| 196f3bcba1 | |||
| bc73c5ca86 | |||
| f4badc15d1 | |||
| 76f731d0cd | |||
| 5e22858ab4 | |||
| bc1c54c43e | |||
| 95ac0d36f8 | |||
| 2bee3eb179 | |||
| 52165f2a0b | |||
| 9f2da226f4 | |||
| 46b999317d | |||
| 80bce3c9c2 | |||
| 90b25b4bd8 | |||
| f562f1eafd | |||
| ff520622ff | |||
| 1a6c6a4f31 | |||
| 4a9e0388f4 | |||
| e47b2a971e | |||
| 96777ec095 | |||
| 52019deef5 | |||
| c26a534ff6 | |||
| e5be5c0b05 | |||
| 5b07f52641 | |||
| e0109cb530 | |||
| 1496dd1598 | |||
| efcaeb2f00 | |||
| 1252946fe5 | |||
| 2913d5f636 | |||
| c50beb3d43 | |||
| 46b559001a | |||
| 55a2e2e060 | |||
| 12364a052f | |||
| 45ac971035 | |||
| 26b818c9a6 | |||
| 007836f52e | |||
| ed0982770b | |||
| dcc72e90b7 | |||
| b428660944 | |||
| 0008dcc0ee | |||
| e3fbf3981d | |||
| c5e9244a08 | |||
| e1fd823c13 | |||
| 346f0d636c | |||
| 315a259e5b | |||
| 3157a03c74 | |||
| 045807a748 | |||
| ea0e12ac80 | |||
| 692060af6c | |||
| d8b23a4b5c | |||
| 074bf55d6c | |||
| 84e505b817 | |||
| a12e6ff603 | |||
| b01d662a1c | |||
| e631dad499 | |||
| 7ab0bfdd6b | |||
| 35a4db43fb | |||
| 094028006b | |||
| c12f42fcac | |||
| 3e498b098b | |||
| bbf1a1a544 | |||
| 42d57f30bf | |||
| 19967a9ad0 | |||
| 9d1a8f12d8 | |||
| 57bebebe09 | |||
| d19303cd62 | |||
| 7c96e46a37 | |||
| f75e68f8ce | |||
| 9b922b00c1 | |||
| c8d9ee2457 | |||
| a29f23086a | |||
| 4a76365df4 | |||
| 972264844a | |||
| e23e6f3c1a | |||
| 03c10382d1 | |||
| 3abe24aa18 | |||
| 2a4eabcda5 | |||
| 280d98c9c4 | |||
| 227c5c80c8 | |||
| edd00a6ab5 | |||
| d57bb17fc7 | |||
| 098da5c868 | |||
| dfc17bcc26 | |||
| 7a5eab104e | |||
| e0bf815377 | |||
| 2cbc6f2890 | |||
| 5859b2c08b | |||
| 44eaa9dc8b | |||
| 49cd7a41dd | |||
| 53de5ece21 | |||
| 90dffa5fcc | |||
| ac6cd0ca15 | |||
| 0e970128ab | |||
| 9fc373384d | |||
| a76bd2ea55 | |||
| 3c610b74d2 | |||
| 0623005ff4 | |||
| 8b032eba9a | |||
| e3229b85fd | |||
| 1178431b8f | |||
| 9d92c4a2b1 | |||
| 0bda8707ce | |||
| 9cb7d3a0fd | |||
| f1204f948a | |||
| 455b8dafb0 | |||
| fe5a32d95b | |||
| b34f6f1690 | |||
| a89bc35c6f | |||
| e551b6c202 | |||
| c6ed1afc51 | |||
| 4bedebb74d | |||
| 741fc7eb05 | |||
| 000da1197c | |||
| dd8be29d54 | |||
| 099c2c7ff4 | |||
| 874232a152 | |||
| d21f046e1f | |||
| ef0e25878e | |||
| cd37619af1 | |||
| 209ee18943 | |||
| aff7733a5b | |||
| 4bb65921e4 | |||
| 26216a6a8c | |||
| 60c3f11d7e | |||
| fcab08bfab | |||
| e69ba466e1 | |||
| 675e85ee09 | |||
| 36ec13b2c4 | |||
| 0b9c36e3ce | |||
| 07cf895447 | |||
| 028f21f0cf | |||
| 77724e363d | |||
| 15863d8a5a | |||
| b836af58f0 | |||
| ef5d880f14 | |||
| 15fd03b4c0 | |||
| cac06f3e17 | |||
| 6d81c62cdf | |||
| 60e319589d | |||
| 7723a5785d | |||
| dd982fe6e2 | |||
| c02dfeaa97 | |||
| a3d513afb0 | |||
| 40fa99a1f9 | |||
| f26c996095 | |||
| 012c4dfb9f | |||
| 9337ca2616 | |||
| 5dcec4bcfd | |||
| ed2e5e4d09 | |||
| 2249f52c32 | |||
| f4ef5d2b53 | |||
| a92c5d6ce0 | |||
| b61ceaead6 | |||
| 0672cfc8f3 | |||
| b5859a43cc | |||
| de1cd7c737 | |||
| f588312ad3 | |||
| 643ef1fa45 | |||
| ec38a4ed33 | |||
| defbd80ce0 | |||
| f11b91e7fa | |||
| 58bb4d5a7f | |||
| d92af60d9b | |||
| bca1e043bd | |||
| 7719175e85 | |||
| 046146c4d8 | |||
| 0e2f66b35a | |||
| 56b9ff1dc9 | |||
| dab8e78cd5 | |||
| 67dd27b9a1 | |||
| 4605985a47 | |||
| 02ac27342a | |||
| cbc59a4a1c | |||
| 02758d96c7 | |||
| 12752134fd | |||
| 504e70441b | |||
| 85cd16e793 | |||
| aa2277f05e | |||
| 27eb96722a | |||
| 0215096c9e | |||
| 90d8c51402 | |||
| ba0bbfefe7 | |||
| dd65673c1e | |||
| 2f3f8de682 | |||
| 3ce6fcad3a | |||
| 4302e7426e | |||
| 4e246c35c8 | |||
| 0cbcae9624 | |||
| 313aed606f | |||
| aade735c79 | |||
| 1e5786a142 | |||
| 3e5e5eeafa | |||
| 8a48ee8e14 | |||
| 6a89981c77 | |||
| 03c014fdda | |||
| ae683cd3c9 | |||
| a0dcae0882 | |||
| 6e839b210c | |||
| c7f4f62e93 | |||
| 214aba8f01 | |||
| 153c9d944f | |||
| 99a9d15871 | |||
| f6fb905ef8 | |||
| a5456452f6 | |||
| 48cd9af739 | |||
| 01fac428d5 | |||
| 3acce100f6 | |||
| ba2dfe6598 | |||
| 46600c38a8 | |||
| b9ae66e557 | |||
| bed9010918 | |||
| d13ccbde71 | |||
| 2e2aac0413 | |||
| 5f18cff344 | |||
| 830300ea81 | |||
| 9da2fcfbca | |||
| ece68df495 | |||
| 6a43ddbee9 | |||
| ee58596ed0 | |||
| 5bce6e77ae | |||
| 5dec45c0c8 | |||
| b7ba6500e4 | |||
| 2a263d749b | |||
| 3a21af48d1 | |||
| cb3bfe99ce | |||
| 328d51622f | |||
| 38ead74697 | |||
| ffd4d72850 | |||
| d23d90a794 | |||
| c74036ad26 | |||
| a146a9f424 | |||
| ddc0c82cee | |||
| bab09ac0d7 | |||
| 8a6eb912c5 | |||
| b5fbd40ea3 | |||
| 46b1c085cd | |||
| e5642cd752 | |||
| be933e9085 | |||
| 26769675c7 | |||
| 672d6d67aa | |||
| b3c6967ed9 | |||
| 9a528b17e8 | |||
| 4532932749 | |||
| 639a8e2f35 | |||
| 56c2ab38c6 | |||
| 9769cc0ed4 | |||
| 96d66b2205 | |||
| fea73109ca | |||
| e39c15c16f | |||
| 2a13f60bbd | |||
| d8e9738d71 | |||
| f38b78f821 | |||
| 9c21630295 | |||
| ea502727ba | |||
| 72f4999162 | |||
| adf1dbe635 | |||
| af2cdb1ca1 | |||
| e499c0e990 | |||
| 206ab6793f | |||
| e207c90fd4 | |||
| c9864008a3 | |||
| 5c89b27a87 | |||
| 3c482ba017 | |||
| 2d684411d4 | |||
| 408ee36a8b | |||
| d9efbad80d | |||
| 92e5c9f71a | |||
| baef88be50 | |||
| 7eff6ac504 | |||
| a3ea4d4e18 | |||
| f12047e36f | |||
| c77f37cc43 | |||
| 8bc463b6d6 | |||
| 953429f0da | |||
| 8598050931 | |||
| 81566674a8 | |||
| aba9f88352 | |||
| 0093c6ddf7 | |||
| cd5a0c2fbc | |||
| b1c803d2f1 | |||
| 3883b68669 | |||
| 5111bb12cf | |||
| cc2518a346 | |||
| 6e98f4c953 | |||
| 8990b25970 | |||
| c0cdde8685 | |||
| 7a7e20e4c2 | |||
| 743ccd27b6 | |||
| 155d9110c5 | |||
| 651e0b3445 | |||
| 82f438cbe4 | |||
| c3c30b4e20 | |||
| 8b4889ce83 | |||
| 7ccaa0390d | |||
| 5f0ed73d0d | |||
| 54518b63d0 | |||
| 1c38f2e5fd | |||
| 7ba12707e1 | |||
| 01992b26f4 | |||
| 8670ba08d5 | |||
| 67698757cf | |||
| 833ef3f5d3 | |||
| b104f14899 | |||
| 60c86b1db8 | |||
| b6d2965962 | |||
| 1548552af3 | |||
| 002cf8025b | |||
| dfd7e4b62c | |||
| e54380cd81 | |||
| 09b3532793 | |||
| b36e288958 | |||
| bcd3c2739a | |||
| 695e653461 | |||
| b068ff810c | |||
| 1a57f7a047 | |||
| 90f955e48e | |||
| 14fbce5a6b | |||
| 5cff407379 | |||
| 79b7ff21f5 | |||
| 65996a40e9 | |||
| cf3566255a | |||
| 582c6fa36d | |||
| aefba8b4df | |||
| e43674fd29 | |||
| 3856826e4d | |||
| 00ed0b7691 | |||
| aa456f6cff | |||
| 3a9dfa643e | |||
| 28580114da | |||
| 8d47f118dd | |||
| d7b45157ff | |||
| db8a48284f | |||
| b00b8d6ca3 | |||
| 22b3d8f691 | |||
| 432c1cd476 | |||
| ed8e4a059a | |||
| 571c1e31ac | |||
| b294d0eea0 | |||
| 0b50fbc55d | |||
| f5d5534290 | |||
| 777eb7e398 | |||
| 9b1be55a9e | |||
| ef4a7d1fa1 | |||
| e6a7380aa5 | |||
| 2f41591339 | |||
| 8916ef0207 | |||
| c3288f765e | |||
| 11d631bee3 | |||
| e3be78edb0 | |||
| 59f01a8426 | |||
| 76b7c8ef3b | |||
| fbaae9ce76 | |||
| e70b58a3df | |||
| ec551c9de8 | |||
| 7a8cb5835a | |||
| 3b8ccf173b | |||
| 172ce29665 | |||
| e7d8a8d2c0 | |||
| cc7b43f4ba | |||
| 71425977f5 | |||
| 4549727d0b | |||
| e030d97059 | |||
| 13c1ca4f79 | |||
| 637d7c0db1 | |||
| 7513a4b6d6 | |||
| 76100ef07d | |||
| ab139209db | |||
| c6a31baaab | |||
| 7c9d4629b6 | |||
| 986858c20d | |||
| a0551095dc | |||
| d0804c886a | |||
| 1befca7e28 | |||
| 3d4482157a | |||
| 3cd71f1cd4 | |||
| eebf443c1d | |||
| 7aaf0b9375 | |||
| 5ff9675e60 | |||
| 646390edff | |||
| b1958c5442 | |||
| 01b9590e81 | |||
| 985a288bf3 | |||
| 01dcfb88e7 | |||
| 76415a8dec | |||
| 586b7c14f8 | |||
| 0ee4bf2b25 | |||
| 5673e8abe8 | |||
| c446e32a8a | |||
| eee9392fb4 | |||
| 82a3961608 | |||
| f8689fc409 | |||
| 7de93e1780 | |||
| 19e742b7d1 | |||
| e3f0250fd2 | |||
| e4b39b89f3 | |||
| d2888b49e8 | |||
| b97ed49a60 | |||
| 65936dc85b | |||
| b1d02ee302 | |||
| cca47abb9c | |||
| c810ba62b4 | |||
| dcce7ffacc | |||
| e9fb137146 | |||
| 7100c9c300 | |||
| e9b5496387 | |||
| 87ecde390d | |||
| 8b33c66e22 | |||
| b3bbb6624e | |||
| 2ddac45612 | |||
| ddd7d47516 | |||
| a770072809 | |||
| 5b38ce0b37 | |||
| 804708076a | |||
| a0ad71a524 | |||
| 9d03694ea7 | |||
| 7a619e0e9e | |||
| 6bd5eb7930 | |||
| 744d1859d5 | |||
| 8033e3fc8e | |||
| abb0b629f3 | |||
| 34a61f49f6 | |||
| 08c31171a0 | |||
| 40addbf595 | |||
| abd6da0a37 | |||
| 794d1c5019 | |||
| b8d048bf3c | |||
| bb7b650ebc | |||
| 5253de9279 | |||
| 094945e350 | |||
| eb6b58e6db | |||
| 7bcb6e30a7 | |||
| 7ed4024555 | |||
| 6fa45c5958 | |||
| a883f140d4 | |||
| 1bec52efcb | |||
| 674e58ec3f | |||
| 373d705387 | |||
| 46fb693020 | |||
| f1fe7d1545 | |||
| 44ab386835 | |||
| 51d736eba4 | |||
| dcc4f6bd03 | |||
| eef2482423 | |||
| f46a1fb9b5 | |||
| bae45af414 | |||
| aff3835f50 | |||
| 5f249f6032 | |||
| a185d7e9e8 | |||
| 3449caf0e2 | |||
| cc458cc916 | |||
| 2b6b56ace9 | |||
| 4d72c920c6 | |||
| b0186a1baa | |||
| bc3836b316 | |||
| 0a444efe55 | |||
| fba3988834 | |||
| b99e1abb03 | |||
| ab44237bb9 | |||
| 6d98dc073f | |||
| 9709ef4333 | |||
| cb60f6f735 | |||
| 5d0ab2e2db | |||
| 4e3b0b52c8 | |||
| 973590281c | |||
| 4f7defb85c | |||
| 59f4d8dc26 | |||
| be736e105a | |||
| 3be56ba462 | |||
| f7adf8457b | |||
| 80b8856f15 | |||
| 0860f47d88 | |||
| 382b75fccf | |||
| d3dacbd32d | |||
| 6c51e4b596 | |||
| d0bd4428be | |||
| 43ffcb7362 | |||
| ca5dfca290 | |||
| 8a495eff37 | |||
| bee6af12fb | |||
| 4a3dfc3eb7 | |||
| 4e87a4adc4 | |||
| d9bb9d5884 | |||
| 562be0cb89 | |||
| 2fdc86af97 | |||
| 220d1225b2 | |||
| e047a6e449 | |||
| 97b21a9d69 | |||
| 9be9570dea | |||
| cbfe37dbba | |||
| bf176a2c05 | |||
| 173c51eb26 | |||
| 71dbb090fb | |||
| 832133e6cc | |||
| 2730a92b01 | |||
| 40fb5c80eb | |||
| 6c65095961 | |||
| d02f7ae7bc | |||
| 9f0b4b24ba | |||
| 237357c1d5 | |||
| aefb1caf57 | |||
| a3328e7ef0 | |||
| db24588273 | |||
| 2cabeeb6bd | |||
| 88352202b0 | |||
| e02d8a949b | |||
| e7e5457cf2 | |||
| 29b98b0f7d | |||
| ae7378f474 | |||
| b05c17bee4 | |||
| a2d3dac226 | |||
| a8ec41e255 | |||
| 1d16062961 | |||
| 2a614e8656 | |||
| baaaaf01bf | |||
| 20f1fc51b1 | |||
| d749ebf981 | |||
| 04364c503c | |||
| ae2a040fe4 | |||
| 0debf23b5e | |||
| 55e7849b43 | |||
| 34d9a73677 | |||
| 0b9ccf5bc0 | |||
| 48e72bb9df | |||
| 35aed44bcb | |||
| e4cf693c0d | |||
| f0755f5f90 | |||
| bdfc6af688 | |||
| 31b4061a43 | |||
| a1b80f0323 | |||
| 1678686ce7 | |||
| c25d05196d | |||
| bdddc7ad03 | |||
| 1d1ad4f9fa | |||
| 099fcc7981 | |||
| 2ef44e45f6 | |||
| ebe62d0498 | |||
| 6472b20ea4 | |||
| 69cdb2d754 | |||
| f7d8000b48 | |||
| a44afbe1b9 | |||
| 662da1b8a9 | |||
| 2a3d743204 | |||
| 7241ee4c7a | |||
| 5c084d6e8b | |||
| 1060c7fdfc | |||
| b5d6c0e0b3 | |||
| d69644350e | |||
| 8634622a6a | |||
| cc73554350 | |||
| 8adf93d592 | |||
| 6bd60ddf96 | |||
| ea5bcbe4dd | |||
| 541b49069d | |||
| ba868866ec | |||
| 606ae48f13 | |||
| 09c5445f83 | |||
| 4a0a18a7b0 | |||
| ed9d1ab738 | |||
| a61e8084dd | |||
| e169380178 | |||
| b07d4ef6c3 | |||
| 4c713c7652 | |||
| 90e8c6b3c4 | |||
| 07bf05cb2c | |||
| 49d29902a8 | |||
| 32a90c2a9e | |||
| 16ed2c1517 | |||
| 8bb0f1c799 | |||
| f14fe2dd64 | |||
| 884f0ee0de | |||
| 744270f48c | |||
| c30849be3c | |||
| f295ac8b4d | |||
| 9469a8f43d | |||
| 786799ca40 | |||
| aa6238cf5a | |||
| 1ed31ee2e2 | |||
| 7d8c12977c | |||
| 81ee1d63a6 | |||
| cf1c1b3198 | |||
| bc7b1e8472 | |||
| 7319c41cc1 | |||
| 2f09fc32c0 | |||
| f85b3f54a1 | |||
| 1a68669d6d | |||
| 6345c2d414 | |||
| 87930cad75 | |||
| 3ab2232121 | |||
| 9719e25421 | |||
| 8e1f998c39 | |||
| deb1c224f8 | |||
| b2fa79898a | |||
| da2759a3c6 | |||
| f6bc696426 | |||
| da653e9b32 | |||
| 227e153bde | |||
| 12f906b9b0 | |||
| c8236d4d10 | |||
| e2006da50d | |||
| 433a7d3641 | |||
| 6b41c74b43 | |||
| cc3e7ff7c6 | |||
| a35a6bf642 | |||
| 9ecd523646 | |||
| e7f896c38e | |||
| 624ec743b1 | |||
| 553c5745ef | |||
| 3f2779ca3d | |||
| e51b962627 | |||
| f709e135b6 | |||
| 50b48aa247 | |||
| 508f2abc82 | |||
| 42ec56e578 | |||
| 92673dac6e | |||
| daf57aba35 | |||
| dee629c429 | |||
| 67f9092350 | |||
| 88691d47a5 | |||
| 0fdd9bc803 | |||
| 8452c607b4 | |||
| 4fc57c2d57 | |||
| 9f0b707a30 | |||
| 511bc351d5 | |||
| 56ee3e962c | |||
| 2781e8519d | |||
| 46575348fe | |||
| bae2ca646b | |||
| 0686c6ab00 | |||
| db04b66ce5 | |||
| c43c8a29af | |||
| aaea18abe1 | |||
| 0b6076cb61 | |||
| 3b6d02f02c | |||
| 625a0586a3 | |||
| 10d63ca586 | |||
| d2697bf9de | |||
| fa8c8843e2 | |||
| 7b59ea4b96 | |||
| 058215934e | |||
| 1d724d841f | |||
| 32db2100de | |||
| 095c77b65b | |||
| c1c07bff8f | |||
| 1206bd0e98 | |||
| 67bcd1b307 | |||
| 31ef70e433 | |||
| afb12961d2 | |||
| 3dbd40e334 | |||
| bc5d6e38c8 | |||
| 5a39143051 | |||
| 18e9571877 | |||
| a828f4c831 | |||
| 54ac12f250 | |||
| 9a6a72f605 | |||
| 03949489e8 | |||
| 8ebd735e7e | |||
| 39246da73f | |||
| bf33f60368 | |||
| 492a613769 | |||
| 2c02c3434e | |||
| a186ddd9d2 | |||
| 4b0c7a5e4e | |||
| 54cfbe3ab6 | |||
| 02015cf0b8 | |||
| 416e4d3598 | |||
| e0d9c4b0ea | |||
| 2659e3bc09 | |||
| d296167b6a | |||
| 89b0aa7fec | |||
| 787aadf444 | |||
| d67161a29b | |||
| 7f4777ead4 | |||
| 1e2147b143 | |||
| 9f0a2fbf63 | |||
| 7804851bc9 | |||
| adc50dd89e | |||
| 5378cf5ddc | |||
| a733bb605f | |||
| aaa1b5a264 | |||
| 5855cdee65 | |||
| c0d584e644 | |||
| 955f0f473e | |||
| 9ceb888124 | |||
| e902933693 | |||
| e07b47fb10 | |||
| 5bf08d9701 | |||
| 597fa1e6fa | |||
| dda3ec0ba0 | |||
| 991f08c749 | |||
| 923865cd7c | |||
| 606aee8ccd | |||
| 4a1cb27d0a | |||
| 26cd7f3a12 | |||
| f4045dbc56 | |||
| 33c2b24550 | |||
| cd86f3c8ea | |||
| 58026ebc2d | |||
| 9183275dad | |||
| 5e3a625d95 | |||
| 3297f9d2d2 | |||
| 29b8306a28 | |||
| d84746bac6 | |||
| 4eacf2ff0b | |||
| a68e0d6f61 | |||
| ddf44304c7 | |||
| 789d98f58e | |||
| 205574620d | |||
| d9bae12b31 | |||
| c951190794 | |||
| d4e1d69c3c | |||
| f93a32c07b | |||
| 8e7c152c0d | |||
| 6a64404f20 | |||
| 1a09589add | |||
| d0f9072ab5 | |||
| 011ed9f2d8 | |||
| 12332f83d9 | |||
| 8f75829948 | |||
| a7ffedb3b2 | |||
| 378d3b9747 | |||
| 5ba05409c4 | |||
| d86beef58f | |||
| 7315d67c9f | |||
| 59d79b8c7a | |||
| 3df97653a2 | |||
| 948d55e418 | |||
| 1d2c7281b1 | |||
| 35759b943c | |||
| 29da33e200 | |||
| 1d758f8126 | |||
| b663dac2ab | |||
| 485a3971e9 | |||
| 36b5d64ca6 | |||
| 8462a709bc | |||
| 20cabe8362 | |||
| 6967864361 | |||
| a6ee3840ae | |||
| 01de6139df | |||
| 6ba0924f6c | |||
| 00c75d1fd8 | |||
| 93f28fa9d5 | |||
| 0aed10eb8b | |||
| d84d687eee | |||
| d046f56832 | |||
| 0af1fbf313 | |||
| e9aaf4309e | |||
| 80fa1085d2 | |||
| 88195291fd | |||
| 39ad600d00 | |||
| c63a3aba6b | |||
| 782ecbe307 | |||
| f75bbfa048 | |||
| 3a772cc983 | |||
| 807113523e | |||
| 7df2af8611 | |||
| 10c1697b00 | |||
| 742e5d38ab | |||
| 1d3e7f8af0 | |||
| c99bacab64 | |||
| 95d7555ebc | |||
| 5854e0a23c | |||
| 81f7f32767 | |||
| c0374a7da1 | |||
| ed65e655b9 | |||
| d250e516b5 | |||
| e4f404b2c7 | |||
| 86571331f8 | |||
| 163d420af9 | |||
| 4925ea4af0 | |||
| 57b05ce348 | |||
| 427b53fcbd | |||
| 2ac7a31be6 | |||
| 4baed6409f | |||
| 2ea0ff0965 | |||
| 88b9a7edce | |||
| 538066b960 | |||
| b10dd1d75a | |||
| f743580b55 | |||
| 0688467302 | |||
| 29e972dbf2 | |||
| bd99697c5b | |||
| 1d5c431ff6 | |||
| 3736c510ff | |||
| 26c12c3a85 | |||
| 10bb08e5e6 | |||
| b50d5b6121 | |||
| 38b28752e1 | |||
| 87c51009de |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"agentCanUpdateSnapshot": true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
ВАЖНО!!! Мы работаем в МОНО-репозитории pnpm, любая установка пакетов производится ЧЕРЕЗ ФИЛЬТР компонента в корне: pnpm add glob --filter notificator2.
|
||||
|
||||
ВАЖНО!!! НИКОГДА НЕ ЗАПУСКАЙ ПРИЛОЖЕНИЕ ИЛИ ЕГО СБОРКУ!!! Просто отчитайся что всё сделал.
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
description:
|
||||
globs: components/controller/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# NestJS Controller и Чистая архитектура
|
||||
|
||||
## Основные принципы
|
||||
1. Чистая архитектура - главный архитектурный подход. Пиши код с комментариями.
|
||||
2. Типы IName, IChecksum256, ITimePointSec - это просто строки.
|
||||
3. Домен должен быть полностью изолирован от инфраструктурных деталей.
|
||||
4. Направление зависимостей - внутрь (к ядру, домену).
|
||||
5. Домен, инфраструктура и приложение связаны через App.module, НЕ НУЖНО импортировать их друг в друга, а достаточно просто импортировать домен на уровне приложения чтобы использовать все экспорты домена.
|
||||
|
||||
## Структура проекта
|
||||
- `domain/` - доменный слой (бизнес-логика, независимая от инфраструктуры)
|
||||
- `infrastructure/` - инфраструктурный слой (адаптеры к внешним системам)
|
||||
- `modules/` - слой приложения (DTO, резолверы, сервисы)
|
||||
|
||||
## Слои и их взаимодействие
|
||||
1. **Домен**:
|
||||
- Содержит чистые доменные интерфейсы и реализованные на них сущности
|
||||
- НЕ должен зависеть от `cooptypes` (инфраструктурный контракт)
|
||||
- Использует собственные доменные типы (например, Date вместо строковых timestamp)
|
||||
- Интерфейсы портов описывают взаимодействие с внешними системами
|
||||
|
||||
2. **Инфраструктура**:
|
||||
- Содержит адаптеры к внешним системам (блокчейн, БД и т.д.)
|
||||
- Осуществляет преобразование между доменными и инфраструктурными типами
|
||||
- Общая логика преобразования выносится в утилитарные классы (например, `DomainToBlockchainUtils`)
|
||||
|
||||
3. **Модули (приложение)**:
|
||||
- DTO имплементируют доменные интерфейсы напрямую
|
||||
- Резолвер вызывает сервис, который принимает DTO
|
||||
- Сервис передает объекты DTO в интерактор домена
|
||||
|
||||
## Поток данных
|
||||
1. Резолвер принимает входные данные и передает их в сервис
|
||||
2. Сервис вызывает интерактор домена, передавая ему объекты DTO (имплементирующие доменный интерфейс)
|
||||
3. Интерактор выполняет бизнес-логику и взаимодействует с портами домена
|
||||
4. Адаптеры (инфраструктурный слой) преобразуют доменные объекты в формат внешних систем (JSON для хранения мета-данных, ISO для дат и т.д.)
|
||||
5. Адаптеры возвращают результаты в домен, домен возвращает данные в сервис, который возвращает их в резолвер
|
||||
|
||||
## Правила преобразования данных
|
||||
1. Преобразование DTO → доменный объект: не требуется, если DTO имплементирует доменный интерфейс
|
||||
2. Преобразование доменный объект → инфраструктурный тип: происходит в адаптерах
|
||||
3. Преобразование документов, дат и других сложных объектов: используются утилитарные классы уровня инфраструктуры
|
||||
|
||||
## Работа с типами данных
|
||||
1. **Даты**:
|
||||
- В доменных интерфейсах используется тип `Date`
|
||||
- В DTO используется тип `Date` с декораторами `@IsDate()` и `@Type(() => Date)`
|
||||
- В инфраструктурном слое происходит преобразование `Date` → `string` (ISO формат)
|
||||
```typescript
|
||||
// Доменный интерфейс
|
||||
interface MeetDomainInterface {
|
||||
open_at: Date;
|
||||
}
|
||||
|
||||
// DTO
|
||||
@Field(() => Date)
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
open_at!: Date;
|
||||
|
||||
// Адаптер
|
||||
const blockchainData = {
|
||||
open_at: domainToBlockchainUtils.convertDateToBlockchainFormat(data.open_at)
|
||||
};
|
||||
```
|
||||
|
||||
2. **Документы**:
|
||||
- В доменных интерфейсах используется собственный тип `SignedDocumentDomainInterface<T>`
|
||||
- В DTO используется соответствующий DTO-класс (например, `SignedDigitalDocumentInputDTO`)
|
||||
- В инфраструктурном слое происходит преобразование между форматами (meta-поля в JSON и т.д.)
|
||||
|
||||
## Типовые ошибки
|
||||
1. **Нарушение изоляции домена**: домен должен быть изолирован от инфраструктуры. Не используйте импорты из `cooptypes` в доменных интерфейсах.
|
||||
```typescript
|
||||
// Неправильно
|
||||
import { MeetContract } from 'cooptypes';
|
||||
export type VoteDomainInterface = MeetContract.Actions.Vote.IInput;
|
||||
|
||||
// Правильно
|
||||
export interface VoteDomainInterface {
|
||||
coopname: string;
|
||||
hash: string;
|
||||
// ... доменные поля
|
||||
}
|
||||
```
|
||||
|
||||
2. **Преобразование в неправильном слое**: преобразование доменных объектов в инфраструктурные типы должно происходить в адаптерах, а не в доменном слое или сервисе.
|
||||
```typescript
|
||||
// Неправильно (в доменном интеракторе)
|
||||
async vote(data: VoteDomainInterface) {
|
||||
const blockchainData = { ...data, meta: JSON.stringify(data.meta) };
|
||||
// ...
|
||||
}
|
||||
|
||||
// Правильно (в адаптере)
|
||||
async vote(data: VoteDomainInterface) {
|
||||
const blockchainData = this.convertToBlockchainFormat(data);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
3. **Дублирование логики преобразования**: общая логика преобразования должна быть вынесена в утилитарные классы.
|
||||
```typescript
|
||||
// Неправильно (дублирование в разных адаптерах)
|
||||
// В MeetAdapter
|
||||
private convertDoc(doc) { return { ...doc, meta: JSON.stringify(doc.meta) }; }
|
||||
// В BranchAdapter
|
||||
private convertDoc(doc) { return { ...doc, meta: JSON.stringify(doc.meta) }; }
|
||||
|
||||
// Правильно (общий утилитарный класс)
|
||||
// DomainToBlockchainUtils
|
||||
convertSignedDocumentToBlockchainFormat(doc) {
|
||||
return { ...doc, meta: JSON.stringify(doc.meta) };
|
||||
}
|
||||
```
|
||||
|
||||
## Примеры
|
||||
```typescript
|
||||
// Доменный интерфейс - чистый, без зависимостей от инфраструктуры
|
||||
export interface VoteOnAnnualGeneralMeetInputDomainInterface {
|
||||
coopname: string;
|
||||
hash: string;
|
||||
member: string;
|
||||
ballot: VoteItemInputDomainInterface[];
|
||||
}
|
||||
|
||||
// DTO имплементирует доменный интерфейс напрямую
|
||||
@InputType('VoteOnAnnualGeneralMeetInput')
|
||||
export class VoteOnAnnualGeneralMeetInputDTO implements VoteOnAnnualGeneralMeetInputDomainInterface {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
// ... остальные поля
|
||||
}
|
||||
|
||||
// Адаптер преобразует доменный объект в инфраструктурный тип
|
||||
async vote(data: VoteOnAnnualGeneralMeetInputDomainInterface): Promise<TransactionResult> {
|
||||
// Преобразуем доменный объект в инфраструктурный тип
|
||||
const blockchainData: MeetContract.Actions.Vote.IInput = {
|
||||
coopname: data.coopname,
|
||||
hash: data.hash,
|
||||
// ... преобразование других полей
|
||||
};
|
||||
// ... отправка данных во внешнюю систему
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: SDK мутации и запросы для подключения на рабочем столе
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Бэкенд реализован здесь components/controller, sdk здесь components/sdk.
|
||||
|
||||
Пример создания Query к API через SDK:
|
||||
|
||||
``` ts
|
||||
async function loadBranches(data: IGetBranchesInput): Promise<IBranch[]> {
|
||||
const { [Queries.Branches.GetBranches.name]: output } = await client.Query(
|
||||
Queries.Branches.GetBranches.query,
|
||||
{
|
||||
variables: {
|
||||
data
|
||||
}
|
||||
}
|
||||
);
|
||||
return output;
|
||||
}
|
||||
```
|
||||
|
||||
Такие запросы обычно хранятся в папке Entities и вызываются из его store.
|
||||
|
||||
Пример мутации:
|
||||
|
||||
``` ts
|
||||
async function selectBranch(data: ISelectBranchInput): Promise<boolean>{
|
||||
const {[Mutations.Branches.SelectBranch.name]: result} = await client.Mutation(Mutations.Branches.SelectBranch.mutation, {variables: {
|
||||
data
|
||||
}})
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Мутации обычно храним в папке features.
|
||||
|
||||
Как можешь обратить внимание, все мутации и запросы строятся по одному шаблону. Вся необходимая информация уже есть в SDK.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
globs: **/desktop/**
|
||||
---
|
||||
# DESKTOP
|
||||
|
||||
- Пишем код фронтенда в архитектуре Feature Sliced Design (FSD).
|
||||
- Стиль шаблонов PUG на composite API.
|
||||
- Всегда создаём index.ts файлы для vue компонент.
|
||||
- Создавай индексные файлы для vue через export {default as NAME} from './where', а на уровне выше, если это необходимо, делай export * from './ui'
|
||||
- Всегда удаляем неиспользуемые импорты.
|
||||
- Не используем emit избыточно. Вместо них используем композабл функции моделей фич, если необходимо. Emit только в крайнем случае.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Файл [main.py](mdc:monocoop/monocoop/monocoop/components/docs/main.py) автоматически генерит ссылки на документацию SDK и GraphQL, которые формируются и публикуются автоматически. При создании документации к методам всегда применяй ссылки на SDK и GraphQL по форме:
|
||||
{{ get_sdk_doc("Mutations", "Accounts", "RegisterAccount") }} | {{ get_graphql_doc("Mutation.registerAccount") }}
|
||||
|
||||
### 🎯 Основные паттерны использования
|
||||
|
||||
**1. Стандартная структура для методов API:**
|
||||
```markdown
|
||||
## Название действия
|
||||
{{ get_sdk_doc("Namespace", "Module", "Method") }} | {{ get_graphql_doc("Type.methodName") }}
|
||||
|
||||
{{ get_typedoc_input("Namespace.Module.Method") }}
|
||||
|
||||
Результат:
|
||||
{{ get_typedoc_definition("Namespace.Module.Method", "IOutput") }}
|
||||
```
|
||||
|
||||
**2. Описание + пример (для сложных методов):**
|
||||
```markdown
|
||||
{{ get_typedoc_desc("Namespace.Module.Method") }}
|
||||
|
||||
{{ get_typedoc_input("Namespace.Module.Method") }}
|
||||
```
|
||||
|
||||
**3. Ссылки на типы данных:**
|
||||
```markdown
|
||||
У каждого аккаунта есть объект {{ get_graphql_definition("Account") }} в GraphQL-API
|
||||
```
|
||||
|
||||
### 🔧 Макросы и их применение
|
||||
|
||||
| Макрос | Назначение | Пример использования |
|
||||
|--------|------------|---------------------|
|
||||
| `get_sdk_doc` | Ссылка на SDK документацию | `{{ get_sdk_doc("Mutations", "Payments", "CreateDeposit") }}` |
|
||||
| `get_graphql_doc` | Ссылка на GraphQL операцию | `{{ get_graphql_doc("Query.getAccount") }}` |
|
||||
| `get_graphql_definition` | Ссылка на GraphQL тип | `{{ get_graphql_definition("Account") }}` |
|
||||
| `get_class_doc` | Ссылка на класс/метод SDK | `{{ get_class_doc("Document", "sign") }}` |
|
||||
| `get_typedoc_input` | **Полный пример** TypeScript вызова | `{{ get_typedoc_input("Mutations.Auth.Login") }}` |
|
||||
| `get_typedoc_definition` | **Структура интерфейса** | `{{ get_typedoc_definition("Mutations.Auth.Login", "IOutput") }}` |
|
||||
| `get_typedoc_desc` | Описание + примеры | `{{ get_typedoc_desc("Mutations.Auth.Login") }}` |
|
||||
| `get_typedoc_value` | Значение константы | `{{ get_typedoc_value("Constants.API_VERSION") }}` |
|
||||
|
||||
### 📝 Правила оформления
|
||||
|
||||
**ОБЯЗАТЕЛЬНО:**
|
||||
- Всегда используй `get_typedoc_input` для демонстрации **КАК вызывать** метод
|
||||
- Всегда используй `get_typedoc_definition` для показа **ЧТО возвращается**
|
||||
- Комбинируй SDK и GraphQL ссылки через ` | `: `{{ get_sdk_doc(...) }} | {{ get_graphql_doc(...) }}`
|
||||
|
||||
**ЖЕЛАТЕЛЬНО:**
|
||||
- Для сложных методов добавляй `get_typedoc_desc` в начало
|
||||
- Используй `get_graphql_definition` для ссылок на типы данных в тексте
|
||||
- Группируй связанные операции в одном разделе
|
||||
|
||||
**ФОРМАТ ССЫЛОК:**
|
||||
- SDK: `"Namespace", "Module", "Method"` (3 аргумента)
|
||||
- GraphQL: `"Type.methodName"` (точка между типом и методом)
|
||||
- Определения: просто имя типа `"TypeName"`
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Структура файлов шаблонов документов и алгоритм правки
|
||||
|
||||
## Основные директории и файлы
|
||||
|
||||
### 1. Определение типов и интерфейсов (cooptypes)
|
||||
**Путь:** `/cooptypes/src/cooperative/registry/[ID.DocumentName]/index.ts`
|
||||
|
||||
В этих файлах определяются:
|
||||
- Интерфейсы данных (`IAgendaMeet`, etc.)
|
||||
- Основная модель данных документа (`Model`)
|
||||
- HTML шаблон документа (`context`)
|
||||
- Переводы строк (`translations`)
|
||||
- Пример данных для тестирования (`exampleData`)
|
||||
|
||||
**Для изменения шаблона нужно править:**
|
||||
- Интерфейсы модели данных при изменении структуры
|
||||
- HTML в переменной `context` для изменения внешнего вида
|
||||
- Строки переводов в `translations.ru`
|
||||
|
||||
### 2. Схемы валидации (factory)
|
||||
**Путь:** `/factory/src/Schema/[SchemaName].ts`
|
||||
|
||||
Содержат:
|
||||
- JSON схемы для валидации данных
|
||||
- Определение обязательных полей
|
||||
|
||||
**При изменении структуры данных:**
|
||||
- Обновите соответствующую схему валидации
|
||||
- Обновите список обязательных полей в `required`
|
||||
|
||||
### 3. Фабрики документов (factory)
|
||||
**Путь:** `/factory/src/Actions/[ID.DocumentName].ts`
|
||||
|
||||
Обрабатывают:
|
||||
- Получение данных из разных источников
|
||||
- Сборку модели для шаблонизатора
|
||||
- Валидацию данных по схеме
|
||||
- Генерацию PDF
|
||||
|
||||
**При изменении логики сборки документа:**
|
||||
- Обновите метод `generateDocument()`
|
||||
- Добавьте новые источники данных
|
||||
|
||||
### 4. Шаблоны (factory)
|
||||
**Путь:** `/factory/src/Templates/[ID.DocumentName].ts`
|
||||
|
||||
Связывают:
|
||||
- Шаблон из cooptypes
|
||||
- Схему валидации
|
||||
- Метаданные документа
|
||||
|
||||
**После изменения в cooptypes:**
|
||||
- Убедитесь, что схема в Templates соответствует новой модели
|
||||
|
||||
### 5. Тесты (factory)
|
||||
**Путь:** `/factory/test/[category].test.ts`
|
||||
|
||||
Содержат:
|
||||
- Тестовые данные для генерации документов
|
||||
- Вызовы функции `testDocumentGeneration`
|
||||
|
||||
**После внесения изменений:**
|
||||
- Обновите тестовые данные в соответствии с новой структурой
|
||||
- Запустите тесты командой `pnpm test`
|
||||
|
||||
## Алгоритм внесения изменений
|
||||
|
||||
1. **Модификация типов**
|
||||
- Изменить файл в `/cooptypes/src/cooperative/registry/[ID.DocumentName]/index.ts`
|
||||
- Обновить интерфейсы, шаблон HTML и переводы
|
||||
|
||||
2. **Обновление схемы валидации**
|
||||
- Изменить соответствующую схему в `/factory/src/Schema/`
|
||||
- Убедиться, что обязательные поля совпадают с интерфейсом
|
||||
|
||||
3. **Компиляция библиотеки типов**
|
||||
- Выполнить `cd /cooptypes && pnpm build`
|
||||
|
||||
4. **Обновление тестовых данных**
|
||||
- Привести тестовые данные в соответствие с новой структурой
|
||||
|
||||
5. **Запуск тестов**
|
||||
- Выполнить `cd /factory && pnpm test [filename].test.ts`
|
||||
|
||||
## Особенности шаблонизации
|
||||
|
||||
- Используется Nunjucks для шаблонов
|
||||
- Поддерживаются конструкции: `{% if %}`, `{% for %}`, `{% trans %}`
|
||||
- Переменные вставляются через `{{ variable }}`
|
||||
- Переводы через `{% trans 'KEY' %}`
|
||||
@@ -0,0 +1,225 @@
|
||||
# Архитектура расширений в MonoCoop
|
||||
|
||||
## Основные принципы
|
||||
1. Расширения построены на модулях NestJS с использованием шаблона "Порты и адаптеры"
|
||||
2. Каждое расширение наследуется от `BaseExtModule` и реализует интерфейс `OnModuleInit`
|
||||
3. Расширения могут взаимодействовать с блокчейном через соответствующие порты
|
||||
4. Конфигурации расширений хранятся в БД и описываются с помощью Zod-схем
|
||||
5. Расширения регистрируются в глобальном реестре `AppRegistry`
|
||||
|
||||
## Структура расширения
|
||||
Минимальная структура расширения включает:
|
||||
- `XXX-extension.module.ts` - основной модуль расширения
|
||||
- `package.json` - информация о пакете
|
||||
- `README.md` - документация
|
||||
- `INSTALL.md` - инструкции по установке
|
||||
- `CHANGELOG.md` - история изменений
|
||||
|
||||
## Создание нового расширения
|
||||
1. Создайте директорию для расширения в `components/controller/src/extensions/`
|
||||
2. Создайте основной класс расширения, наследующийся от `BaseExtModule`
|
||||
3. Определите Zod-схему для конфигурации
|
||||
4. Реализуйте метод `initialize()`
|
||||
5. Зарегистрируйте расширение в `extensions.registry.ts`
|
||||
6. Добавьте расширение в список дефолтных приложений в `extension-domain.service.ts`
|
||||
|
||||
## Пример структуры модуля расширения
|
||||
```typescript
|
||||
// XXX-extension.module.ts
|
||||
export class XXXPlugin extends BaseExtModule {
|
||||
constructor(...) {
|
||||
super();
|
||||
}
|
||||
|
||||
name = 'xxx';
|
||||
plugin!: ExtensionDomainEntity<IConfig>;
|
||||
public configSchemas = Schema;
|
||||
|
||||
async initialize() {
|
||||
// Инициализация расширения
|
||||
// Настройка cron-задач
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [XXXPlugin],
|
||||
})
|
||||
export class XXXPluginModule {
|
||||
constructor(private readonly xxxPlugin: XXXPlugin) {}
|
||||
|
||||
async initialize() {
|
||||
await this.xxxPlugin.initialize();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Управление отображением конфигурации
|
||||
Zod-схемы используются для автоматического отображения формы настроек в интерфейсе пользователя.
|
||||
Через интерфейс `DeserializedDescriptionOfExtension` из `components/controller/src/types/shared/extension.types.ts`
|
||||
можно управлять отображением полей формы:
|
||||
|
||||
```typescript
|
||||
export const Schema = z.object({
|
||||
// Базовое поле с меткой
|
||||
simpleField: z.string().describe(
|
||||
describeField({
|
||||
label: 'Название поля',
|
||||
note: 'Подсказка под полем'
|
||||
})
|
||||
),
|
||||
|
||||
// Скрытое поле для служебного использования
|
||||
hiddenField: z.string().describe(
|
||||
describeField({
|
||||
label: 'Скрытое поле',
|
||||
visible: false
|
||||
})
|
||||
),
|
||||
|
||||
// Поле с проверкой значения
|
||||
validatedField: z.number().describe(
|
||||
describeField({
|
||||
label: 'Поле с валидацией',
|
||||
rules: ['val >= 5', 'val <= 100'],
|
||||
})
|
||||
),
|
||||
|
||||
// Форматированное поле с префиксом и суффиксом
|
||||
formattedField: z.number().describe(
|
||||
describeField({
|
||||
label: 'Форматированное поле',
|
||||
prepend: '$',
|
||||
append: 'USD',
|
||||
})
|
||||
),
|
||||
|
||||
// Многострочное текстовое поле
|
||||
multilineField: z.string().describe(
|
||||
describeField({
|
||||
label: 'Многострочное поле',
|
||||
maxRows: 5,
|
||||
minLength: 10,
|
||||
maxLength: 1000
|
||||
})
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Доступные поля для управления отображением:
|
||||
- `label` - название поля (обязательное)
|
||||
- `note` - пояснение или подсказка
|
||||
- `visible` - видимость поля (по умолчанию true)
|
||||
- `rules` - правила валидации в виде строковых выражений
|
||||
- `mask` - маска для ввода
|
||||
- `fillMask` - автозаполнение маски
|
||||
- `minLength` / `maxLength` - ограничения длины для текстовых полей
|
||||
- `maxRows` - количество строк для многострочного ввода
|
||||
- `append` / `prepend` - текст до/после значения поля
|
||||
|
||||
## Взаимодействие с блокчейном
|
||||
Для взаимодействия с блокчейном:
|
||||
1. Определите порт в доменном слое (например, `SovietBlockchainPort`)
|
||||
2. Инжектируйте порт в конструкторе расширения через DI
|
||||
3. Используйте методы порта для взаимодействия с блокчейном
|
||||
|
||||
```typescript
|
||||
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort
|
||||
// ...
|
||||
const decisions = await this.sovietBlockchainPort.getDecisions(coopname);
|
||||
```
|
||||
|
||||
## Настройка планировщика задач
|
||||
Расширения могут использовать cron-задачи для периодического выполнения операций:
|
||||
|
||||
```typescript
|
||||
import cron from 'node-cron';
|
||||
|
||||
// Регистрация cron-задачи (каждые N минут)
|
||||
const cronExpression = `*/${this.plugin.config.checkInterval} * * * *`;
|
||||
cron.schedule(cronExpression, () => {
|
||||
this.logger.info('Запуск запланированной задачи');
|
||||
this.runTask();
|
||||
});
|
||||
```
|
||||
|
||||
## Работа с конфигурацией
|
||||
1. Определите Zod-схему для конфигурации
|
||||
2. Используйте `describeField` для добавления UI-метаданных к полям
|
||||
3. Получайте и обновляйте конфигурацию через репозиторий `extensionRepository`
|
||||
|
||||
```typescript
|
||||
export const Schema = z.object({
|
||||
checkInterval: z.number().describe(
|
||||
describeField({
|
||||
label: 'Интервал проверки (в минутах)',
|
||||
note: 'Минимум: 5 минут',
|
||||
rules: ['val >= 5'],
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// Обновление конфигурации
|
||||
this.plugin.config.lastCheckDate = new Date().toISOString();
|
||||
await this.extensionRepository.update(this.plugin);
|
||||
```
|
||||
|
||||
## Логирование действий
|
||||
Расширения должны логировать свои действия:
|
||||
1. Используйте `WinstonLoggerService` для системного логирования
|
||||
2. Используйте `LogExtensionDomainRepository` для хранения логов в БД
|
||||
|
||||
```typescript
|
||||
// Системное логирование
|
||||
this.logger.info(`Выполнение операции для ${id}`);
|
||||
|
||||
// Сохранение лога в БД
|
||||
await this.logExtensionRepository.push(this.name, {
|
||||
type: 'operation',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { ... },
|
||||
});
|
||||
```
|
||||
|
||||
## Регистрация расширения
|
||||
После создания расширения, добавьте его в `extensions.registry.ts`:
|
||||
|
||||
```typescript
|
||||
export const AppRegistry: INamedExtension = {
|
||||
myExtension: {
|
||||
is_builtin: false,
|
||||
is_internal: true,
|
||||
is_available: true,
|
||||
is_desktop: false,
|
||||
title: 'Моё расширение',
|
||||
description: 'Описание функциональности.',
|
||||
image: 'https://example.com/image.png',
|
||||
class: MyExtensionPluginModule,
|
||||
schema: MyExtensionSchema,
|
||||
tags: ['тег1', 'тег2'],
|
||||
readme: getReadmeContent('./myExtension'),
|
||||
instructions: getInstructionsContent('./myExtension'),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Дефолтные настройки
|
||||
Добавьте расширение в список дефолтных приложений в `extension-domain.service.ts`:
|
||||
|
||||
```typescript
|
||||
getDefaultApps(): Partial<ExtensionDomainEntity>[] {
|
||||
return [
|
||||
// ...
|
||||
{
|
||||
name: 'myExtension',
|
||||
enabled: true,
|
||||
config: {
|
||||
// Дефолтные значения конфигурации
|
||||
parameter1: 'value1',
|
||||
parameter2: 42,
|
||||
},
|
||||
},
|
||||
// ...
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
description:
|
||||
globs: **/controller/src/extensions/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# Архитектура расширений в MonoCoop
|
||||
|
||||
## Основные принципы
|
||||
1. Расширения построены на модулях NestJS с использованием шаблона "Порты и адаптеры"
|
||||
2. Каждое расширение наследуется от `BaseExtModule` и реализует интерфейс `OnModuleInit`
|
||||
3. Расширения могут взаимодействовать с блокчейном через соответствующие порты
|
||||
4. Конфигурации расширений хранятся в БД и описываются с помощью Zod-схем
|
||||
5. Расширения регистрируются в глобальном реестре `AppRegistry`
|
||||
|
||||
## Структура расширения
|
||||
Минимальная структура расширения включает:
|
||||
- `XXX-extension.module.ts` - основной модуль расширения
|
||||
- `package.json` - информация о пакете
|
||||
- `README.md` - документация
|
||||
- `INSTALL.md` - инструкции по установке
|
||||
- `CHANGELOG.md` - история изменений
|
||||
|
||||
## Создание нового расширения
|
||||
1. Создайте директорию для расширения в `components/controller/src/extensions/`
|
||||
2. Создайте основной класс расширения, наследующийся от `BaseExtModule`
|
||||
3. Определите Zod-схему для конфигурации
|
||||
4. Реализуйте метод `initialize()`
|
||||
5. Зарегистрируйте расширение в `extensions.registry.ts`
|
||||
6. Добавьте расширение в список дефолтных приложений в `extension-domain.service.ts`
|
||||
|
||||
## Пример структуры модуля расширения
|
||||
```typescript
|
||||
// XXX-extension.module.ts
|
||||
export class XXXPlugin extends BaseExtModule {
|
||||
constructor(...) {
|
||||
super();
|
||||
}
|
||||
|
||||
name = 'xxx';
|
||||
plugin!: ExtensionDomainEntity<IConfig>;
|
||||
public configSchemas = Schema;
|
||||
|
||||
async initialize() {
|
||||
// Инициализация расширения
|
||||
// Настройка cron-задач
|
||||
}
|
||||
|
||||
// Дополнительные методы расширения
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [XXXPlugin],
|
||||
})
|
||||
export class XXXPluginModule {
|
||||
constructor(private readonly xxxPlugin: XXXPlugin) {}
|
||||
|
||||
async initialize() {
|
||||
await this.xxxPlugin.initialize();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Взаимодействие с блокчейном
|
||||
Для взаимодействия с блокчейном:
|
||||
1. Определите порт в доменном слое (например, `SovietBlockchainPort`)
|
||||
2. Инжектируйте порт в конструкторе расширения через DI
|
||||
3. Используйте методы порта для взаимодействия с блокчейном
|
||||
|
||||
```typescript
|
||||
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort
|
||||
// ...
|
||||
const decisions = await this.sovietBlockchainPort.getDecisions(coopname);
|
||||
```
|
||||
|
||||
## Настройка планировщика задач
|
||||
Расширения могут использовать cron-задачи для периодического выполнения операций:
|
||||
|
||||
```typescript
|
||||
import cron from 'node-cron';
|
||||
|
||||
// Регистрация cron-задачи (каждые N минут)
|
||||
const cronExpression = `*/${this.plugin.config.checkInterval} * * * *`;
|
||||
cron.schedule(cronExpression, () => {
|
||||
this.logger.info('Запуск запланированной задачи');
|
||||
this.runTask();
|
||||
});
|
||||
```
|
||||
|
||||
## Работа с конфигурацией
|
||||
1. Определите Zod-схему для конфигурации
|
||||
2. Используйте `describeField` для добавления UI-метаданных к полям
|
||||
3. Получайте и обновляйте конфигурацию через репозиторий `extensionRepository`
|
||||
|
||||
```typescript
|
||||
export const Schema = z.object({
|
||||
checkInterval: z.number().describe(
|
||||
describeField({
|
||||
label: 'Интервал проверки (в минутах)',
|
||||
note: 'Минимум: 5 минут',
|
||||
rules: ['val >= 5'],
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// Обновление конфигурации
|
||||
this.plugin.config.lastCheckDate = new Date().toISOString();
|
||||
await this.extensionRepository.update(this.plugin);
|
||||
```
|
||||
|
||||
## Логирование действий
|
||||
Расширения должны логировать свои действия:
|
||||
1. Используйте `WinstonLoggerService` для системного логирования
|
||||
2. Используйте `LogExtensionDomainRepository` для хранения логов в БД
|
||||
|
||||
```typescript
|
||||
// Системное логирование
|
||||
this.logger.info(`Выполнение операции для ${id}`);
|
||||
|
||||
// Сохранение лога в БД
|
||||
await this.logExtensionRepository.push(this.name, {
|
||||
type: 'operation',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { ... },
|
||||
});
|
||||
```
|
||||
|
||||
## Регистрация расширения
|
||||
После создания расширения, добавьте его в `extensions.registry.ts`:
|
||||
|
||||
```typescript
|
||||
export const AppRegistry: INamedExtension = {
|
||||
myExtension: {
|
||||
is_builtin: false,
|
||||
is_internal: true,
|
||||
is_available: true,
|
||||
is_desktop: false,
|
||||
title: 'Моё расширение',
|
||||
description: 'Описание функциональности.',
|
||||
image: 'https://example.com/image.png',
|
||||
class: MyExtensionPluginModule,
|
||||
schema: MyExtensionSchema,
|
||||
tags: ['тег1', 'тег2'],
|
||||
readme: getReadmeContent('./myExtension'),
|
||||
instructions: getInstructionsContent('./myExtension'),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Дефолтные настройки
|
||||
Добавьте расширение в список дефолтных приложений в `extension-domain.service.ts`:
|
||||
|
||||
```typescript
|
||||
getDefaultApps(): Partial<ExtensionDomainEntity>[] {
|
||||
return [
|
||||
// ...
|
||||
{
|
||||
name: 'myExtension',
|
||||
enabled: true,
|
||||
config: {
|
||||
// Дефолтные значения конфигурации
|
||||
parameter1: 'value1',
|
||||
parameter2: 42,
|
||||
},
|
||||
},
|
||||
// ...
|
||||
];
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
description:
|
||||
globs: src/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
# Фабрика Документов Кооперативов
|
||||
|
||||
## Общая Архитектура
|
||||
|
||||
Фабрика документов — это система генерации PDF документов для кооперативов, построенная на TypeScript с использованием MongoDB для хранения данных. Система состоит из трех основных частей:
|
||||
|
||||
1. **registry/** — JSON-шаблоны документов (статические данные)
|
||||
2. **factory/** — основная фабрика с логикой генерации
|
||||
3. **cooptypes/** — типы данных и интерфейсы
|
||||
|
||||
## Структура Registry
|
||||
|
||||
В корневой папке `registry/` находятся JSON файлы с номерными названиями, представляющие шаблоны документов:
|
||||
|
||||
### Основные документы:
|
||||
- `1.walletProgramAgreement.json` — соглашение о кошельке
|
||||
- `2.regulationElectronicSignature.json` — регламент электронной подписи
|
||||
- `3.privacyPolicy.json` — политика конфиденциальности
|
||||
- `4.userAgreement.json` — пользовательское соглашение
|
||||
- `50.CoopenomicsAgreement.json` — соглашение с партнерами
|
||||
- `100.participantApplication.json` — заявление участника
|
||||
- `101.selectBranchStatement.json` — заявление о выборе филиала
|
||||
|
||||
### Документы общих собраний (300-304):
|
||||
- `300.annualGeneralMeetingAgenda.json` — предложение повестки дня
|
||||
- `301.annualGeneralMeetingSovietDecision.json` — протокол заседания совета
|
||||
- `302.annualGeneralMeetingNotification.json` — уведомление о собрании
|
||||
- `303.annualGeneralMeetingVotingBallot.json` — бюллетень для голосования
|
||||
- `304.annualGeneralMeetingDecision.json` — протокол общего собрания
|
||||
|
||||
### Инвестиционные документы (1000+):
|
||||
- `1000.investAgreement.json` — инвестиционное соглашение
|
||||
- `1001.investByResultStatement.json` — заявление о зачете по результатам
|
||||
- `1002.investByResultAct.json` — акт зачета по результатам
|
||||
- `1005.investByMoneyStatement.json` — заявление о зачете денежных средств
|
||||
|
||||
### Структура JSON-шаблона:
|
||||
```json
|
||||
{
|
||||
"context": "<div>...HTML шаблон с переменными...</div>",
|
||||
"model": {...данные для примера...},
|
||||
"translation": {...переводы ключей...},
|
||||
"object_model": {...схема объектной модели...}
|
||||
}
|
||||
```
|
||||
|
||||
## Фабрика (factory/)
|
||||
|
||||
### Основные компоненты:
|
||||
|
||||
#### src/index.ts — Главный класс Generator
|
||||
```typescript
|
||||
export class Generator implements IGenerator {
|
||||
// Хранилище фабрик для каждого типа документа
|
||||
factories: { [K in Numbers]: DocFactory<IGenerate> }
|
||||
|
||||
// MongoDB коннектор
|
||||
public storage: MongoDBConnector
|
||||
|
||||
// Основной метод генерации
|
||||
async generate(data: IGenerate, options?: IGenerationOptions): Promise<IGeneratedDocument>
|
||||
}
|
||||
```
|
||||
|
||||
#### Архитектура Factory Pattern:
|
||||
- Базовый класс `DocFactory<T>` в `src/Factory/index.ts`
|
||||
- Каждый документ имеет свою фабрику в `src/Actions/`
|
||||
- Фабрики наследуются от `DocFactory` и реализуют метод `generateDocument()`
|
||||
|
||||
### Сервисы:
|
||||
|
||||
#### Services/Generator/ — PDF генерация
|
||||
- `PDFService` — конвертирует HTML в PDF через WeasyPrint
|
||||
- Использует шрифт Arial (base64)
|
||||
- Добавляет метаданные в PDF
|
||||
- Вычисляет SHA-256 хеш документа
|
||||
|
||||
#### Services/Templator/ — Шаблонизация
|
||||
- Основан на Nunjucks
|
||||
- Поддерживает кастомное расширение `{% trans %}` для переводов
|
||||
- Рендерит HTML из шаблона с подстановкой переменных
|
||||
|
||||
#### Services/Validator/ — Валидация
|
||||
- Использует AJV для JSON Schema валидации
|
||||
- Поддерживает кастомные форматы (телефон)
|
||||
- Локализация ошибок на русском языке
|
||||
|
||||
#### Services/Databazor/ — База данных
|
||||
- `MongoDBConnector` — работа с MongoDB
|
||||
- `DataService` — абстракция над данными
|
||||
- Коллекции: `deltas`, `actions`, `documents`, и другие
|
||||
|
||||
### Модели данных (src/Models/):
|
||||
|
||||
#### Основные типы пользователей:
|
||||
- `Individual` — физические лица (ФИО, паспорт, адрес)
|
||||
- `Organization` — организации (ИНН, ОГРН, представитель)
|
||||
- `Entrepreneur` — ИП (ФИО + ИНН/ОГРН)
|
||||
|
||||
#### Кооперативные данные:
|
||||
- `Cooperative` — данные кооператива
|
||||
- `PaymentMethod` — платежные методы
|
||||
- `Vars` — переменные кооператива
|
||||
- `Project` — проекты
|
||||
|
||||
### Система Action-ов:
|
||||
|
||||
Каждый документ имеет Action класс в `src/Actions/` с методом `generateDocument()`:
|
||||
|
||||
1. **Получение шаблона** — из локального Registry или MongoDB
|
||||
2. **Сбор данных** — пользователь, кооператив, переменные, специфичные данные
|
||||
3. **Валидация** — проверка по JSON схеме
|
||||
4. **Рендеринг** — HTML из шаблона + данные
|
||||
5. **PDF генерация** — HTML → PDF с метаданными
|
||||
6. **Сохранение** — в MongoDB (если не skip_save)
|
||||
|
||||
## Система типов (cooptypes/)
|
||||
|
||||
### cooperative/registry/ — Типы документов
|
||||
Каждый документ имеет папку с интерфейсами:
|
||||
- `Action` — входные данные для генерации
|
||||
- `Model` — модель данных для шаблона
|
||||
- `Template` — структура шаблона
|
||||
|
||||
### contracts/ — Блокчейн контракты
|
||||
- `registrator/` — регистрация кооперативов
|
||||
- `soviet/` — управление советом
|
||||
- `meet/` — общие собрания
|
||||
- `wallet/`, `capital/`, `fund/` — финансовые операции
|
||||
|
||||
## База данных MongoDB
|
||||
|
||||
### Основные коллекции:
|
||||
|
||||
#### deltas — Состояние блокчейна
|
||||
```javascript
|
||||
{
|
||||
block_num: number,
|
||||
present: boolean,
|
||||
code: string, // название контракта
|
||||
scope: string, // область действия
|
||||
table: string, // имя таблицы
|
||||
primary_key: string,
|
||||
value: {...} // данные записи
|
||||
}
|
||||
```
|
||||
|
||||
#### actions — Действия блокчейна
|
||||
```javascript
|
||||
{
|
||||
block_num: number,
|
||||
account: string,
|
||||
name: string, // имя действия
|
||||
receiver: string,
|
||||
data: {...} // данные действия
|
||||
}
|
||||
```
|
||||
|
||||
#### documents — Сгенерированные документы
|
||||
```javascript
|
||||
{
|
||||
hash: string, // SHA-256 хеш
|
||||
binary: Uint8Array, // PDF данные
|
||||
html: string, // HTML исходник
|
||||
meta: { // метаданные
|
||||
title: string,
|
||||
created_at: string,
|
||||
lang: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Таблицы данных:
|
||||
- `coops` — кооперативы
|
||||
- `meets` — собрания
|
||||
- `questions` — вопросы собраний
|
||||
- `decisions` — решения совета
|
||||
- `individuals` — физические лица
|
||||
- `organizations` — организации
|
||||
- `entrepreneurs` — ИП
|
||||
- `paymentMethods` — платежные методы
|
||||
- `vars` — переменные кооперативов
|
||||
|
||||
## Генерация документов
|
||||
|
||||
### Процесс генерации:
|
||||
1. **Вызов** `generator.generate(action, options)`
|
||||
2. **Поиск фабрики** по `registry_id`
|
||||
3. **Загрузка шаблона** (локально или из БД)
|
||||
4. **Сбор данных** из MongoDB по `coopname`, `username`, `block_num`
|
||||
5. **Создание модели** — объединение всех данных
|
||||
6. **Валидация** модели по JSON схеме
|
||||
7. **Рендеринг HTML** через Nunjucks
|
||||
8. **Генерация PDF** через WeasyPrint
|
||||
9. **Добавление метаданных** в PDF
|
||||
10. **Вычисление хеша** SHA-256
|
||||
11. **Сохранение** в MongoDB
|
||||
|
||||
### Пример использования:
|
||||
```typescript
|
||||
const generator = new Generator()
|
||||
await generator.connect(mongoUri)
|
||||
|
||||
const document = await generator.generate({
|
||||
registry_id: '300',
|
||||
coopname: 'voskhod',
|
||||
username: 'ant',
|
||||
block_num: 0,
|
||||
meet: {...},
|
||||
questions: [...]
|
||||
})
|
||||
```
|
||||
|
||||
## Особенности реализации
|
||||
|
||||
### Шаблонизация:
|
||||
- HTML шаблоны с CSS стилями
|
||||
- Переменные в формате `{{variable.field}}`
|
||||
- Условная логика `{% if condition %}`
|
||||
- Циклы `{% for item in array %}`
|
||||
- Переводы `{% trans 'KEY', var1, var2 %}`
|
||||
|
||||
### Подписи:
|
||||
- Цифровые подписи вместо физических
|
||||
- Текст "Подписано электронной подписью"
|
||||
- Убраны подчеркивания для подписей
|
||||
|
||||
### Типы собраний:
|
||||
- `regular` — очередное
|
||||
- `extraordinary` — внеочередное
|
||||
- Условная логика в шаблонах
|
||||
|
||||
### Филиалы:
|
||||
- `coop.is_branched` — проверка на наличие филиалов
|
||||
- "пайщиков" vs "уполномоченных" в зависимости от типа
|
||||
|
||||
### Форматирование дат:
|
||||
- Формат: "г. Москва, 15 декабря 2024 г."
|
||||
- Без кавычек вокруг дат
|
||||
- Запятая после города
|
||||
|
||||
## Тестирование
|
||||
|
||||
### test/utils/index.ts — Тестовые утилиты:
|
||||
- `preLoading()` — инициализация тестовых данных
|
||||
- Создание кооператива, пользователей, платежных методов
|
||||
- Настройка данных собраний и решений
|
||||
- Очистка временных файлов
|
||||
|
||||
### Тестовые данные:
|
||||
- Кооператив "ВОСХОД"
|
||||
- Пользователи: ant, individual, entrepreneur
|
||||
- Организации: voskhod, branch, exampleorg
|
||||
- Собрания с вопросами и решениями
|
||||
|
||||
Фабрика поддерживает полный цикл создания документов кооператива от заявлений до протоколов собраний с возможностью кастомизации под разные типы кооперативов и требования.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
description:
|
||||
globs: components/sdk/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Руководство по работе с GraphQL Zeus в SDK
|
||||
|
||||
## Основной процесс
|
||||
|
||||
1. **Анализ DTO бэкенда**:
|
||||
- Изучите структуру DTO классов (`@ObjectType`) в бэкенде
|
||||
- Обратите внимание на имена типов в декораторах `@ObjectType('ИмяТипа')`
|
||||
- Отметьте поля, связи и вложенные объекты
|
||||
|
||||
2. **Создание селекторов**:
|
||||
- Для каждого DTO создайте соответствующий селектор с именем `raw<ИмяТипа>Selector`
|
||||
- Селектор - это объект, где ключи соответствуют полям DTO, а значения - `true`
|
||||
- Для вложенных объектов используйте вложенные селекторы: `field: nestedSelector`
|
||||
- Для списков используйте один селектор без массива: `items: itemSelector`
|
||||
|
||||
3. **Валидация селекторов**:
|
||||
- Для каждого селектора создайте проверку типа:
|
||||
```typescript
|
||||
const _validate: MakeAllFieldsRequired<ValueTypes['ТочноеИмяГрафКьЭлТипа']> = rawSelector
|
||||
```
|
||||
- Имя типа должно точно совпадать с именем в декораторе `@ObjectType`
|
||||
|
||||
4. **Экспорт селекторов**:
|
||||
- Создайте финальный селектор с помощью функции `Selector`:
|
||||
```typescript
|
||||
export const typeSelector = Selector('ТочноеИмяГрафКьЭлТипа')(rawTypeSelector)
|
||||
```
|
||||
- Экспортируйте сырой селектор для переиспользования
|
||||
- Экспортируйте тип модели: `export type modelType = ModelTypes['ТочноеИмяГрафКьЭлТипа']`
|
||||
|
||||
5. **Создание запросов/мутаций**:
|
||||
- Используйте селекторы в запросах и мутациях:
|
||||
```typescript
|
||||
export const query = Selector('Query')({
|
||||
queryName: [{ data: $('data', 'ТочноеИмяВходногоТипа!') }, exportedSelector]
|
||||
})
|
||||
```
|
||||
- Для параметров используйте оператор `$` с точным именем входного типа
|
||||
- Создайте интерфейс входных данных:
|
||||
```typescript
|
||||
export interface IInput {
|
||||
data: ModelTypes['ТочноеИмяВходногоТипа']
|
||||
}
|
||||
```
|
||||
|
||||
## Особенности работы
|
||||
|
||||
- **Документы**: всегда сохраняйте структуру `{ hash, signatures, rawDocument }`
|
||||
- **Сложные DTO**: разбивайте на атомарные селекторы и комбинируйте их
|
||||
- **Типы в Zeus**: часто отличаются от имен классов в бэкенде, всегда проверяйте в `schema.gql`
|
||||
- **Массивы**: Zeus автоматически обрабатывает массивы, не используйте `[selector]`
|
||||
|
||||
Это руководство поможет правильно структурировать работу с SDK и избежать типичных ошибок при работе с Zeus.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
globs: notifications/src/workflows/**/*.ts
|
||||
---
|
||||
# Правила валидации воркфлоу
|
||||
|
||||
## 🚫 ID воркфлоу
|
||||
- **Длина ID не должна превышать 32 символа**
|
||||
- Используйте описательные, но короткие идентификаторы малыми латинскими буквами и тире.
|
||||
|
||||
## 🚫 Условия в шаблонах
|
||||
- **Запрещено использовать JavaScript выражения в шаблонах Novu**
|
||||
- Нельзя использовать:
|
||||
- Тернарные операторы: `{{condition ? "text1" : "text2"}}`
|
||||
- Логические операторы: `{{field && "text"}}`
|
||||
- Любые другие JS конструкции
|
||||
|
||||
## ✅ Рекомендации
|
||||
- Добавляйте текстовые поля в payload для условной логики
|
||||
- Вычисляйте значения на стороне сервера перед отправкой уведомления
|
||||
- Используйте только простые переменные: `{{payload.fieldName}}`
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Debug info
|
||||
run: |
|
||||
echo "Текущая ветка:"
|
||||
git branch --show-current
|
||||
echo "Последние коммиты:"
|
||||
git log -n 3 --oneline
|
||||
echo "Проверяем файлы в директории components/desktop/src-ssr:"
|
||||
ls -la components/desktop/src-ssr/ || echo "Директория не найдена!"
|
||||
echo "Проверяем файлы в middlewares:"
|
||||
ls -la components/desktop/src-ssr/middlewares/ || echo "Директория middlewares не найдена!"
|
||||
|
||||
- name: Set docker tags
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
echo "DOCKER_TAG=$TAG_NAME" >> $GITHUB_ENV
|
||||
|
||||
# Проверяем, является ли тег продакшн-тегом (не содержит alpha, beta, rc и т.д.)
|
||||
if [[ ! $TAG_NAME =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "IS_PRODUCTION_TAG=true" >> $GITHUB_ENV
|
||||
echo "Это продакшн тег, будем добавлять latest"
|
||||
else
|
||||
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
|
||||
echo "Это не продакшн тег, latest не добавляем"
|
||||
fi
|
||||
else
|
||||
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
|
||||
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Сначала собираем базовый образ с runtime
|
||||
- name: Build base image
|
||||
run: |
|
||||
docker build --target runtime -t dicoop/mono-base:${{ env.DOCKER_TAG }} .
|
||||
docker push dicoop/mono-base:${{ env.DOCKER_TAG }}
|
||||
|
||||
# Если это продакшн тег, добавляем latest
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/mono-base:${{ env.DOCKER_TAG }} dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
# Создаем сервисные образы на основе базового
|
||||
- name: Build desktop image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.desktop
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/desktop\", \"run\", \"start\"]" >> Dockerfile.desktop
|
||||
docker build -t dicoop/desktop:${{ env.DOCKER_TAG }} -f Dockerfile.desktop .
|
||||
docker push dicoop/desktop:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/desktop:${{ env.DOCKER_TAG }} dicoop/desktop:latest
|
||||
docker push dicoop/desktop:latest
|
||||
fi
|
||||
|
||||
- name: Build controller image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.coopback
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/controller\", \"run\", \"start\"]" >> Dockerfile.coopback
|
||||
docker build -t dicoop/coopback:${{ env.DOCKER_TAG }} -f Dockerfile.coopback .
|
||||
docker push dicoop/coopback:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/coopback:${{ env.DOCKER_TAG }} dicoop/coopback:latest
|
||||
docker push dicoop/coopback:latest
|
||||
fi
|
||||
|
||||
- name: Build parser image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
|
||||
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
|
||||
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
|
||||
docker push dicoop/cooparser:latest
|
||||
fi
|
||||
|
||||
- name: Build notificator image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notificator
|
||||
echo "CMD [\"pnpm\", \"-F\", \"coop-notificator\", \"run\", \"start\"]" >> Dockerfile.notificator
|
||||
docker build -t dicoop/notificator:${{ env.DOCKER_TAG }} -f Dockerfile.notificator .
|
||||
docker push dicoop/notificator:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/notificator:${{ env.DOCKER_TAG }} dicoop/notificator:latest
|
||||
docker push dicoop/notificator:latest
|
||||
fi
|
||||
|
||||
- name: Build notifications image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notifications
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/notifications\", \"run\", \"sync\"]" >> Dockerfile.notifications
|
||||
docker build -t dicoop/notifications:${{ env.DOCKER_TAG }} -f Dockerfile.notifications .
|
||||
docker push dicoop/notifications:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/notifications:${{ env.DOCKER_TAG }} dicoop/notifications:latest
|
||||
docker push dicoop/notifications:latest
|
||||
fi
|
||||
|
||||
# Отправка хука для деплоя
|
||||
- name: Trigger deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/*alpha* ]]; then
|
||||
# Хук для тестнета (alpha теги)
|
||||
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
# Хук для продакшена (остальные теги)
|
||||
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
fi
|
||||
|
||||
# Уведомление в Telegram об успехе
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
ADDITIONAL_INFO=" (с тегом latest)"
|
||||
else
|
||||
ADDITIONAL_INFO=""
|
||||
fi
|
||||
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
|
||||
|
||||
# Уведомление в Telegram об ошибке
|
||||
- name: Telegram notify failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]"
|
||||
@@ -0,0 +1,18 @@
|
||||
# .github/workflows/trigger-coopenomics.yml
|
||||
name: Trigger Contracts Docs Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main, capital] # или когда нужно триггерить
|
||||
|
||||
jobs:
|
||||
trigger-coopenomics:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics # укажи правильный owner/repo
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Publish Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- testnet
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build-and-publish-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install Python requirements
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm install
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Patch spectaql-config.yml for CI
|
||||
run: |
|
||||
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Show patched spectaql-config.yml
|
||||
run: cat spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Build cooptypes
|
||||
run: pnpm run build
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Generate controller docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Copy controller docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/graphql
|
||||
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
|
||||
|
||||
- name: Generate sdk docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/sdk
|
||||
|
||||
- name: Copy sdk docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/sdk
|
||||
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
|
||||
|
||||
- name: Generate cooptypes docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Copy cooptypes docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/cooptypes
|
||||
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
|
||||
|
||||
- name: Build docs (mkdocs)
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
mkdocs build
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Remove specific large file before publishing
|
||||
run: |
|
||||
# Удаляем конкретный большой файл sdk/typedoc.json
|
||||
rm -f ./components/docs/site/sdk/typedoc.json
|
||||
# Проверяем, что файл удален
|
||||
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
|
||||
echo "ERROR: typedoc.json still exists!"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: typedoc.json removed successfully"
|
||||
fi
|
||||
|
||||
- name: Publish to GitHub Pages
|
||||
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
|
||||
working-directory: ./components/docs
|
||||
env:
|
||||
GIT_AUTHOR_NAME: github-actions
|
||||
GIT_AUTHOR_EMAIL: github-actions@github.com
|
||||
GIT_COMMITTER_NAME: github-actions
|
||||
GIT_COMMITTER_EMAIL: github-actions@github.com
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger docs deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Publish Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
!contains(github.ref, '-alpha')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Та же мажорная линия pnpm, что и lockfile (lockfileVersion 6.0 = pnpm 8).
|
||||
# Иначе `npm i -g pnpm` тянет последний pnpm и переписывает pnpm-lock.yaml → Lerna EUNCOMMIT.
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.8
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lerna run build
|
||||
- run: pnpm lerna publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -1,2 +1,11 @@
|
||||
node_modules/
|
||||
lerna-debug.log
|
||||
components/controller/graph.png
|
||||
blockchain-data/
|
||||
scripts/changelog-prompt.md
|
||||
scripts/changelog-release.md
|
||||
scripts/release-info.md
|
||||
components/docs/docs/sdk
|
||||
dist/
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"plugins": [
|
||||
"prettier-plugin-sort-imports"
|
||||
]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar",
|
||||
"vue.vscode-typescript-vue-plugin"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.guides.bracketPairs": true,
|
||||
"editor.tabCompletion": "onlySnippets",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"eslint.run": "onType",
|
||||
"eslint.validate": ["javascript", "typescript", "vue"],
|
||||
"i18n-ally.localesPaths": ["src/i18n"],
|
||||
"typescript.format.enable": true,
|
||||
"typescript.format.indentSwitchCase": false,
|
||||
"typescript.validate.enable": true,
|
||||
"notebook.defaultFormatter": "Vue.volar",
|
||||
|
||||
// Оптимизация для монорепозитория
|
||||
"typescript.preferences.useAliasesForRenames": false,
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.suggest.paths": true,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"typescript.workspaceSymbols.scope": "currentProject",
|
||||
|
||||
// Настройки для снижения нагрузки
|
||||
"files.watcherExclude": {
|
||||
"**/node_modules/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/.git/objects/**": true,
|
||||
"**/.git/subtree-cache/**": true,
|
||||
"**/node_modules/*/**": true,
|
||||
"**/.cache/**": true,
|
||||
"**/.quasar/**": true,
|
||||
"**/*.tsbuildinfo": true
|
||||
},
|
||||
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/dist": true,
|
||||
"**/.cache": true,
|
||||
"**/.quasar": true,
|
||||
"**/*.tsbuildinfo": true
|
||||
},
|
||||
|
||||
"files.exclude": {
|
||||
"**/.cache": true,
|
||||
"**/*.tsbuildinfo": true,
|
||||
"**/node_modules/.cache": true
|
||||
},
|
||||
|
||||
// TypeScript server настройки для монорепозитория
|
||||
"typescript.tsserver.maxTsServerMemory": 8192,
|
||||
"typescript.tsserver.watchOptions": {
|
||||
"excludeDirectories": [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/.cache",
|
||||
"**/.quasar",
|
||||
"**/build"
|
||||
]
|
||||
},
|
||||
|
||||
// Использовать локальный TypeScript из монорепо
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
|
||||
// КРИТИЧЕСКИ ВАЖНО: включить project references для монорепозитория
|
||||
"typescript.tsserver.useSyntaxServer": "auto",
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||
|
||||
// Оптимизация для больших монорепозиториев
|
||||
"typescript.disableAutomaticTypeAcquisition": true,
|
||||
"typescript.surveys.enabled": false,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Обзор
|
||||
|
||||
Монорепозиторий «Цифровой Кооператив» (monocoop) — платформа управления кооперативами на блокчейне EOSIO. pnpm v9 + Lerna. Node.js v20.
|
||||
|
||||
### Сервисы
|
||||
|
||||
| Компонент | Контейнер | Порт | Описание |
|
||||
|-----------|-----------|------|----------|
|
||||
| controller | coopback | 2998 | NestJS GraphQL API |
|
||||
| desktop | desktop | 2999 | Vue 3 + Quasar SPA |
|
||||
| parser | cooparser | 4000 | Индексация блокчейна через SHiP |
|
||||
| blockchain | node | 8888, 8070 | EOSIO node + State History Plugin |
|
||||
| MongoDB | mongo | 27017 | Основная БД (replica set) |
|
||||
| Redis | monoredis | 6379 | Кэш и стримы |
|
||||
| PG | (см. compose) | 5532→5432 | Реляционная БД |
|
||||
|
||||
### Полный перезапуск (одна команда)
|
||||
|
||||
```
|
||||
pnpm run reboot
|
||||
```
|
||||
|
||||
Делает: останавливает контейнеры → чистит blockchain data и volumes → поднимает инфру → ждёт готовности → `pnpm run boot` → запускает parser и controller.
|
||||
|
||||
### Первоначальная настройка Cloud-окружения
|
||||
|
||||
1. **`/etc/hosts`** — обязательно для boot (запускается на хосте, обращается к MongoDB по docker hostname):
|
||||
```
|
||||
echo "127.0.0.1 mongo" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 monoredis" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
2. **WeasyPrint** — системная зависимость для генерации PDF:
|
||||
```
|
||||
sudo apt-get install -y python3 python3-venv libpango-1.0-0 libcairo2 libffi-dev libjpeg-dev libopenjp2-7-dev libharfbuzz-dev
|
||||
sudo python3 -m venv /opt/weasyprint-venv && sudo /opt/weasyprint-venv/bin/pip install WeasyPrint==67
|
||||
sudo ln -sf /opt/weasyprint-venv/bin/weasyprint /usr/local/bin/weasyprint
|
||||
```
|
||||
|
||||
3. **Контракты** (test-режим — позволяет boot с 1 членом совета):
|
||||
```
|
||||
cd components/contracts && sudo rm -rf build && bash build-all.sh test
|
||||
```
|
||||
|
||||
4. **Shared-библиотеки** (порядок важен):
|
||||
```
|
||||
pnpm --filter cooptypes run build
|
||||
pnpm --filter @coopenomics/factory run build
|
||||
pnpm --filter @coopenomics/sdk run build
|
||||
pnpm --filter @coopenomics/notifications run build
|
||||
```
|
||||
|
||||
5. **`.env` файлы** — скопировать из `.env-example`, адаптировать hostnames:
|
||||
- Controller/Parser (в Docker): хосты по именам контейнеров из docker-compose (порт БД 5432)
|
||||
- Boot (на хосте): `127.0.0.1`, PG порт `5532`, mongo через `/etc/hosts`
|
||||
- Desktop: `127.0.0.1`
|
||||
- Controller: `BACKEND_URL` (публичный URL API) и `FRONTEND_URL` (публичный URL SPA), см. `components/controller/.env-example`
|
||||
- **CHAIN_ID**: берётся из `curl http://localhost:8888/v1/chain/get_info` после старта ноды
|
||||
- Controller требует `VAPID_PUBLIC_KEY` и `VAPID_PRIVATE_KEY`
|
||||
|
||||
6. **Запуск**: `pnpm run reboot`, затем `docker compose up -d --force-recreate coopback cooparser` (если .env менялись)
|
||||
|
||||
### Запуск тестов
|
||||
|
||||
- **Factory** (`components/factory`): нужен только MongoDB. Запуск:
|
||||
```
|
||||
NODE_ENV=test SOURCE=local MONGO_URI=$MONGO_URI SKIP_BLOCK_FETCH=TRUE pnpm --filter @coopenomics/factory test
|
||||
```
|
||||
`MONGO_URI` по умолчанию: `mongodb://<host>:27017/cooperative-x`.
|
||||
- **Boot** (`components/boot`): требует полный EOSIO blockchain + MongoDB + PG. Запуск после `pnpm run reboot`:
|
||||
```
|
||||
pnpm --filter @coopenomics/boot test
|
||||
```
|
||||
- **Duplicate transaction** — в boot-тестах EOSIO отклоняет транзакции с одинаковым хешем (TAPOS block + action data). При повторном вызове `refreshSegment` для того же участника — добавить `await sleep(500)` перед ним. Паттерн уже используется (см. комментарий на строке ~720 capital.test.ts).
|
||||
|
||||
### Критические gotchas
|
||||
|
||||
- **SHiP порт 8070** — `state-history-endpoint = 0.0.0.0:8070` в config.ini. Парсер: `SHIP=ws://node:8070`.
|
||||
- **Парсер START_BLOCK**: при `START_BLOCK=1` на чистой БД стартует с HEAD и делает частичную инициализацию. Для полного replay: временно `START_BLOCK=2`, после первого запуска вернуть `1`.
|
||||
- **SSR desktop в dev** — расширения не рендерятся из-за Pinia SSR-сериализации компонентов. Dev — SPA (`quasar dev`), production build SSR работает.
|
||||
- **Тестовые учётные данные**: email `ivanov@example.com`, ключ — дефолтный EOSIO dev key (см. `components/boot/.env-example`), пользователь `ant` (председатель).
|
||||
- **Docker hostnames**: без `network_mode: host` — контейнеры обращаются друг к другу по именам контейнеров из docker-compose.
|
||||
- **Установка пакетов**: только через фильтр — `pnpm add <pkg> --filter <component>`.
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
# v2026.4.2-2
|
||||
|
||||
В этом релизе — стабилизация Благороста для вывода в продуктивную работу с результатами интеллектуальной деятельности. Отдельно заложена основа под отчётность в ФНС и ФСС и прототип поиска по документам.
|
||||
|
||||
**Благорост и проекты**
|
||||
|
||||
- Быстрые действия на странице программы: создать проект, компонент, задачу или требование.
|
||||
- Требования к компонентам в репозитории: поддержка **Mermaid**, **Draw.io** и **BPMN**.
|
||||
- Синхронизация проектов, компонентов, требований и задач с Git-репозиторием результатов.
|
||||
- Встраивание полноразмерного видео (iframe) на страницах проектов.
|
||||
- Скачивание пакета подписанных документов одной кнопкой.
|
||||
- Комнаты проектов в кооперативном мессенджере.
|
||||
|
||||
**Мессенджер и звонки**
|
||||
|
||||
- Автосекретарь: запись синхронных звонков, текстовых и голосовых сообщений в проектных комнатах.
|
||||
|
||||
**Отчётность и инфраструктура**
|
||||
|
||||
- Прототип фабрики отчётов ФНС/ФСС: выгрузка в XML для дальнейшей отправки.
|
||||
- Прототип поисковой системы по документам.
|
||||
- Установщик для развёртывания на своих серверах (разработка или эксплуатация).
|
||||
- Рефакторинг в сторону чистой архитектуры на бэкенде.
|
||||
- Ускорение сборки фронтенда за счёт перехода на **Vite 8**.
|
||||
|
||||
**Исправления**
|
||||
|
||||
- Повторное общее собрание больше не мешало завершить онбординг кооператива.
|
||||
- Центр уведомлений не блокировал загрузку рабочего стола при отключённом провайдере оповещений.
|
||||
- Уведомления о собрании совета по свободным вопросам снова доходят до членов совета.
|
||||
- В интерфейсе восстановлено отображение контактов кооператива.
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2026.4.2-2
|
||||
|
||||
В этом релизе — стабилизация Благороста для вывода в продуктивную работу с результатами интеллектуальной деятельности. Отдельно заложена основа под отчётность в ФНС и ФСС и прототип поиска по документам.
|
||||
|
||||
**Благорост и проекты**
|
||||
|
||||
- Быстрые действия на странице программы: создать проект, компонент, задачу или требование.
|
||||
- Требования к компонентам в репозитории: поддержка **Mermaid**, **Draw.io** и **BPMN**.
|
||||
- Синхронизация проектов, компонентов, требований и задач с Git-репозиторием результатов.
|
||||
- Встраивание полноразмерного видео (iframe) на страницах проектов.
|
||||
- Скачивание пакета подписанных документов одной кнопкой.
|
||||
- Комнаты проектов в кооперативном мессенджере.
|
||||
|
||||
**Мессенджер и звонки**
|
||||
|
||||
- Автосекретарь: запись синхронных звонков, текстовых и голосовых сообщений в проектных комнатах.
|
||||
|
||||
**Отчётность и инфраструктура**
|
||||
|
||||
- Прототип фабрики отчётов ФНС/ФСС: выгрузка в XML для дальнейшей отправки.
|
||||
- Прототип поисковой системы по документам.
|
||||
- Установщик для развёртывания на своих серверах (разработка или эксплуатация).
|
||||
- Рефакторинг в сторону чистой архитектуры на бэкенде.
|
||||
- Ускорение сборки фронтенда за счёт перехода на **Vite 8**.
|
||||
|
||||
**Исправления**
|
||||
|
||||
- Повторное общее собрание больше не мешало завершить онбординг кооператива.
|
||||
- Центр уведомлений не блокировал загрузку рабочего стола при отключённом провайдере оповещений.
|
||||
- Уведомления о собрании совета по свободным вопросам снова доходят до членов совета.
|
||||
- В интерфейсе восстановлено отображение контактов кооператива.
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.12.28-8
|
||||
|
||||
В этой версии представлен прототип трекера результатов интеллектуальной деятельности, реализован мост в 1С, обновлен интерфейс и существенно повышена стабильность системы.
|
||||
|
||||
---
|
||||
|
||||
### ✨ Новые функции
|
||||
- [#332](https://github.com/coopenomics/mono/issues/332): Прототип конструктора требований дополнительных документов при регистрации
|
||||
- [#330](https://github.com/coopenomics/mono/issues/330): Прототип моста в 1С:Бухгалтерию для передачи документов и проводок
|
||||
- [#329](https://github.com/coopenomics/mono/issues/329): Интеграция и тестирование LMS TUTOR для образовательных задач
|
||||
- [#328](https://github.com/coopenomics/mono/issues/328): Размещение прототипов мульти-лендингов на цифровой-кооператив.рф и coopenomics.world
|
||||
- [#326](https://github.com/coopenomics/mono/issues/326): Минимальный интерфейс трекера результатов интеллектуальной деятельности
|
||||
- [#324](https://github.com/coopenomics/mono/issues/324): Смарт-контракт генерации и капитализации результатов интеллектуальной деятельности ("Благорост")
|
||||
- [#322](https://github.com/coopenomics/mono/issues/322): Поставка обновлений ПО с нулевым даунтаймом по blue-green стратегии
|
||||
- [#321](https://github.com/coopenomics/mono/issues/321): Внедрение системы проводок по фондам для контрактов
|
||||
- [#319](https://github.com/coopenomics/mono/issues/319): Палитра команд и быстрый доступ к страницам рабочих столов (cmk+k)
|
||||
- [#316](https://github.com/coopenomics/mono/issues/316): Переход рабочего стола на GraphQL SDK
|
||||
- [#314](https://github.com/coopenomics/mono/issues/314): Развёртывание GlitchTip для мониторинга ошибок
|
||||
- [#306](https://github.com/coopenomics/mono/issues/306): Модуль запросов и мутаций для контракта капитализации
|
||||
|
||||
### 🐛 Исправления ошибок
|
||||
- [#312](https://github.com/coopenomics/mono/issues/312): Исправление подписки на изменение статуса коммитов
|
||||
- [#309](https://github.com/coopenomics/mono/issues/309): Исправление отображения чужих билетов времени в трекере
|
||||
|
||||
### 🔧 Улучшения
|
||||
- [#331](https://github.com/coopenomics/mono/issues/331): Настройка системы мониторинга сбоев и ошибок на базе GlitchTIP, Loki, Prometheus
|
||||
- [#327](https://github.com/coopenomics/mono/issues/327): Пользовательская документация по интерфейсам цифрового кооператива
|
||||
- [#325](https://github.com/coopenomics/mono/issues/325): Документирование смарт-контракта программы "Благорост"
|
||||
- [#308](https://github.com/coopenomics/mono/issues/308): Улучшение отображения рабочих столов в магазине приложений
|
||||
- [#307](https://github.com/coopenomics/mono/issues/307): Объединение настроек контракта с нативными настройками приложения
|
||||
- [#305](https://github.com/coopenomics/mono/issues/305): Объединение полей title и description в проекте
|
||||
- [#304](https://github.com/coopenomics/mono/issues/304): Доменная модель контракта капитализации на бэкенде
|
||||
- [#303](https://github.com/coopenomics/mono/issues/303): Пересмотр архитектуры парсера и формирования локальной истории
|
||||
- [#302](https://github.com/coopenomics/mono/issues/302): Рефакторинг архитектуры, внедрение двухконтурной шины данных и обработки микрофорков
|
||||
- [#301](https://github.com/coopenomics/mono/issues/301): Доработка и отладка контракта "Капитализация РИД" v0.2
|
||||
- [#222](https://github.com/coopenomics/mono/issues/222): Внедрение метода Водянова для распределения пула премий по программе "Благорост"
|
||||
- [#212](https://github.com/coopenomics/mono/issues/212): Снижение точности валютных значений до двух знаков после запятой в документах
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.12.28
|
||||
|
||||
В этой версии представлен прототип трекера результатов интеллектуальной деятельности, реализован мост в 1С, обновлен интерфейс и существенно повышена стабильность системы.
|
||||
|
||||
---
|
||||
|
||||
### ✨ Новые функции
|
||||
- [#332](https://github.com/coopenomics/mono/issues/332): Прототип конструктора требований дополнительных документов при регистрации
|
||||
- [#330](https://github.com/coopenomics/mono/issues/330): Прототип моста в 1С:Бухгалтерию для передачи документов и проводок
|
||||
- [#329](https://github.com/coopenomics/mono/issues/329): Интеграция и тестирование LMS TUTOR для образовательных задач
|
||||
- [#328](https://github.com/coopenomics/mono/issues/328): Размещение прототипов мульти-лендингов на цифровой-кооператив.рф и coopenomics.world
|
||||
- [#326](https://github.com/coopenomics/mono/issues/326): Минимальный интерфейс трекера результатов интеллектуальной деятельности
|
||||
- [#324](https://github.com/coopenomics/mono/issues/324): Смарт-контракт генерации и капитализации результатов интеллектуальной деятельности ("Благорост")
|
||||
- [#322](https://github.com/coopenomics/mono/issues/322): Поставка обновлений ПО с нулевым даунтаймом по blue-green стратегии
|
||||
- [#321](https://github.com/coopenomics/mono/issues/321): Внедрение системы проводок по фондам для контрактов
|
||||
- [#319](https://github.com/coopenomics/mono/issues/319): Палитра команд и быстрый доступ к страницам рабочих столов (cmk+k)
|
||||
- [#316](https://github.com/coopenomics/mono/issues/316): Переход рабочего стола на GraphQL SDK
|
||||
- [#314](https://github.com/coopenomics/mono/issues/314): Развёртывание GlitchTip для мониторинга ошибок
|
||||
- [#306](https://github.com/coopenomics/mono/issues/306): Модуль запросов и мутаций для контракта капитализации
|
||||
|
||||
### 🐛 Исправления ошибок
|
||||
- [#312](https://github.com/coopenomics/mono/issues/312): Исправление подписки на изменение статуса коммитов
|
||||
- [#309](https://github.com/coopenomics/mono/issues/309): Исправление отображения чужих билетов времени в трекере
|
||||
|
||||
### 🔧 Улучшения
|
||||
- [#331](https://github.com/coopenomics/mono/issues/331): Настройка системы мониторинга сбоев и ошибок на базе GlitchTIP, Loki, Prometheus
|
||||
- [#327](https://github.com/coopenomics/mono/issues/327): Пользовательская документация по интерфейсам цифрового кооператива
|
||||
- [#325](https://github.com/coopenomics/mono/issues/325): Документирование смарт-контракта программы "Благорост"
|
||||
- [#308](https://github.com/coopenomics/mono/issues/308): Улучшение отображения рабочих столов в магазине приложений
|
||||
- [#307](https://github.com/coopenomics/mono/issues/307): Объединение настроек контракта с нативными настройками приложения
|
||||
- [#305](https://github.com/coopenomics/mono/issues/305): Объединение полей title и description в проекте
|
||||
- [#304](https://github.com/coopenomics/mono/issues/304): Доменная модель контракта капитализации на бэкенде
|
||||
- [#303](https://github.com/coopenomics/mono/issues/303): Пересмотр архитектуры парсера и формирования локальной истории
|
||||
- [#302](https://github.com/coopenomics/mono/issues/302): Рефакторинг архитектуры, внедрение двухконтурной шины данных и обработки микрофорков
|
||||
- [#301](https://github.com/coopenomics/mono/issues/301): Доработка и отладка контракта "Капитализация РИД" v0.2
|
||||
- [#222](https://github.com/coopenomics/mono/issues/222): Внедрение метода Водянова для распределения пула премий по программе "Благорост"
|
||||
- [#212](https://github.com/coopenomics/mono/issues/212): Снижение точности валютных значений до двух знаков после запятой в документах
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.12.28
|
||||
|
||||
В этом релизе реализован смарт-контракт генерации и капитализации результатов интеллектуальной деятельности, завершена интеграция с учётными системами, улучшены интерфейсы и документация. Подробнее о контракте: https://coopenomics.world/contracts/group__public__capital.html
|
||||
|
||||
✨ Новые функции
|
||||
- [#324](https://github.com/coopenomics/mono/issues/324): Реализован смарт-контракт генерации и капитализации результатов интеллектуальной деятельности ("Благорост")
|
||||
- [#301](https://github.com/coopenomics/mono/issues/301): Контракт "Капитализация РИД" v0.2
|
||||
- [#330](https://github.com/coopenomics/mono/issues/330): Прототип моста в 1С:Бухгалтерию: выгрузка документов и проводки по счетам
|
||||
- [#322](https://github.com/coopenomics/mono/issues/322): Обновления ПО с нулевым даунтаймом по blue-green стратегии
|
||||
- [#319](https://github.com/coopenomics/mono/issues/319): Палитра команд и быстрый доступ к страницам рабочих столов (cmk+k)
|
||||
- [#308](https://github.com/coopenomics/mono/issues/308): Магазин приложений с поддержкой подключения нескольких рабочих столов одним приложением
|
||||
- [#326](https://github.com/coopenomics/mono/issues/326): Минимальный интерфейс трекера результатов интеллектуальной деятельности по программе "Благорост"
|
||||
- [#332](https://github.com/coopenomics/mono/issues/332): Прототип конструктора требований дополнительных документов при регистрации
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#312](https://github.com/coopenomics/mono/issues/312): Исправлена ошибка со статусом коммитов — подписка теперь работает корректно
|
||||
- [#309](https://github.com/coopenomics/mono/issues/309): Исправлен баг с отображением чужих билетов времени в трекере
|
||||
|
||||
🔧 Улучшения
|
||||
- [#325](https://github.com/coopenomics/mono/issues/325): Документирован смарт-контракт программы "Благорост"
|
||||
- [#327](https://github.com/coopenomics/mono/issues/327): Подготовлена пользовательская документация цифрового кооператива по интерфейсам
|
||||
- [#329](https://github.com/coopenomics/mono/issues/329): Интеграция и тестирование образовательной платформы LMS TUTOR на Wordpress
|
||||
- [#328](https://github.com/coopenomics/mono/issues/328): Размещены прототипы мульти-лендингов на цифровой-кооператив.рф и coopenomics.world
|
||||
- [#321](https://github.com/coopenomics/mono/issues/321): Встроена система проводок по фондам и интеграция с контрактами
|
||||
- [#318](https://github.com/coopenomics/mono/issues/318): Настроены Loki & Grafana для выгрузки логов из контейнеров
|
||||
- [#316](https://github.com/coopenomics/mono/issues/316): Завершён переход рабочего стола на GraphQL SDK
|
||||
- [#314](https://github.com/coopenomics/mono/issues/314): Развёрнут GlitchTip как альтернатива Sentry
|
||||
- [#307](https://github.com/coopenomics/mono/issues/307): Интеграция настроек контракта с нативными настройками приложения, поддержка импорта после конфигурации
|
||||
- [#306](https://github.com/coopenomics/mono/issues/306): Собран модуль запросов и мутаций контракта капитализации
|
||||
- [#305](https://github.com/coopenomics/mono/issues/305): Упрощена структура проекта — title & description объединены в одно поле
|
||||
- [#304](https://github.com/coopenomics/mono/issues/304): Реализована доменная модель контракта капитализации на бэкенде с поддержкой микрофорков
|
||||
- [#303](https://github.com/coopenomics/mono/issues/303): Пересмотрена архитектура парсера и формирования локальной истории
|
||||
- [#302](https://github.com/coopenomics/mono/issues/302): Рефакторинг архитектуры, реализована двухконтурная шина данных и обработка микрофорков
|
||||
- [#212](https://github.com/coopenomics/mono/issues/212): Уменьшена точность валютных значений в документах с четырёх до двух знаков
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.9.1
|
||||
|
||||
В системе Кооперативной Экономики развернут смарт-контракт CAPITAL v0.2 для генерации и капитализации результатов интеллектуальной деятельности. Контракт описывает и обеспечивает:
|
||||
- бизнес-процесс производства результатов интеллектуальной деятельности в кооперативах создателями, авторами, инвесторами, координаторами, мастерами и собственниками имущества в кооперации на проектах;
|
||||
- приём результатов интеллектуальной деятельности в качестве паевых взносов в кооператив;
|
||||
- распределение потока членских взносов среди вкладчиков;
|
||||
- капитализацию результатов интеллектуальной деятельности новыми результатами по модели золотого сечения;
|
||||
- оценку вкладов авторов и создателей по методу Водянова;
|
||||
|
||||
В основе математической модели контракта лежит принцип распределения справедливой выгоды между вкладчиками согласно концепции "Общественно-полезного времени", разработанной в рамках теорий Кузнецова и академика Глушкова при работе над проектом общегосударственной автоматизированной системы учета и обработки информации (ОГАС).
|
||||
|
||||
Подробнее в документации: https://coopenomics.world/contracts/group__public__capital.html
|
||||
|
||||
✨ Новые функции
|
||||
- [#301](https://github.com/coopenomics/mono/issues/301): Реализация контракта "Капитализация" v0.2: регистрация вкладчиков, создание и управление проектами, поддержка инвестиций, ссуд, членских взносов, проведение голосований, расчет капитализации, интеграция с внешними контрактами, поддержка импорта данных.
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.7.1-1
|
||||
|
||||
В этом релизе реализованы механизмы возврата паевого взноса пайщика и инструменты управления этим процессом для членов совета. Также внесены визуальные доработки интерфейсов для улучшения восприятия информации.
|
||||
|
||||
✨ Новые функции
|
||||
- [#276](https://github.com/coopenomics/mono/issues/276): Реализовать путь возврата паевого взноса из кошелька пайщика
|
||||
- [#281](https://github.com/coopenomics/mono/issues/281): Генерация заявления на возврат паевого взноса
|
||||
- [#282](https://github.com/coopenomics/mono/issues/282): Генерация решения совета на возврат паевого взноса
|
||||
- [#278](https://github.com/coopenomics/mono/issues/278): Введение методов управления возвратами паевых взносов в контроллере
|
||||
- [#279](https://github.com/coopenomics/mono/issues/279): Отобразить исходящие платежи с кнопками управления в реестре платежей для совета
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#286](https://github.com/coopenomics/mono/issues/286): Поправить ошибки склонений времени
|
||||
- [#262](https://github.com/coopenomics/mono/issues/262): Баг: рабочий стол совета включается, однако, страница всегда открывается со стола пайщика
|
||||
- [#261](https://github.com/coopenomics/mono/issues/261): Баг: первая загрузка переадресует на главную страницу всегда - прямой переход на собрание становится недоступен.
|
||||
|
||||
🔧 Улучшения
|
||||
- [#285](https://github.com/coopenomics/mono/issues/285): Корректировка дизайна кошелька, профиля, повестки совета, реестра документов, реестра платежей
|
||||
- [#284](https://github.com/coopenomics/mono/issues/284): Разместить кошелек на главную вместо профиля
|
||||
- [#283](https://github.com/coopenomics/mono/issues/283): Ввести лоадер на переходе между рабочими столами
|
||||
- [#280](https://github.com/coopenomics/mono/issues/280): Мигрировать имеющиеся данные о входящих платежах в новую модель
|
||||
- [#277](https://github.com/coopenomics/mono/issues/277): Рефакторинг модуля платежей: переход на унифицированную модель входящего и исходящего платежа
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.6.14
|
||||
|
||||
Разработан модуль для проведения очередных общих собраний пайщиков. Исправлены баги, внесены улучшения пользовательского интерфейса.
|
||||
|
||||
✨ Новые функции
|
||||
- [#264](https://github.com/coopenomics/mono/issues/264): Разработан смарт-контракт общего собрания пайщиков (meet)
|
||||
- [#263](https://github.com/coopenomics/mono/issues/263): Реализован модуль оповещений на электронные почты по жизненному циклу общего собрания пайщиков
|
||||
- [#273](https://github.com/coopenomics/mono/issues/273): Встроены реальные шаблоны документов общего собрания
|
||||
- [#268](https://github.com/coopenomics/mono/issues/268): Добавлена страница результатов общего собрания
|
||||
- [#267](https://github.com/coopenomics/mono/issues/267): Добавлена страница просмотра и скачивания бюллетеней и уведомлений по собранию
|
||||
- [#270](https://github.com/coopenomics/mono/issues/270): Ссылка в оповещении ведет на страницу собрания с документом-уведомлением для подписи
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#262](https://github.com/coopenomics/mono/issues/262): Исправлено некорректное открытие рабочего стола совета
|
||||
- [#261](https://github.com/coopenomics/mono/issues/261): Исправлена ошибка с редиректом при первой загрузке и прямом переходе на собрание
|
||||
- [#259](https://github.com/coopenomics/mono/issues/259): Исправлены отступы в мобильной карточке пайщика
|
||||
- [#258](https://github.com/coopenomics/mono/issues/258): Убран hover-эффект на документе и пайщике в таблице
|
||||
|
||||
🔧 Улучшения
|
||||
- [#274](https://github.com/coopenomics/mono/issues/274): Настроена рассылка оповещений на почту при получении решения о проведении собрания
|
||||
- [#272](https://github.com/coopenomics/mono/issues/272): Подписанные уведомления сохраняются и извлекаются из реестра по Graph-QL
|
||||
- [#271](https://github.com/coopenomics/mono/issues/271): Ведется подсчет количества пайщиков в каждом кооперативе при добавлении и удалении
|
||||
- [#260](https://github.com/coopenomics/mono/issues/260): Введен счетчик количества пайщиков в кооперативе на контракте регистратора
|
||||
- [#256](https://github.com/coopenomics/mono/issues/256): Отображается статус членства каждого пайщика
|
||||
- [#255](https://github.com/coopenomics/mono/issues/255): В разделе Платежи отображается ФИО/Наименование плательщика
|
||||
- [#269](https://github.com/coopenomics/mono/issues/269): Реализована рассылка уведомлений перед началом собрания
|
||||
- [#265](https://github.com/coopenomics/mono/issues/265): Проведена отладка и тестирование процесса общего собрания пайщиков
|
||||
- [#266](https://github.com/coopenomics/mono/issues/266): Протестированы все процессы общего собрания
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.5.14
|
||||
|
||||
В этом релизе реализован новый стандарт передачи и хранения документов по блокчейну с поддержкой неограниченного количества подписей и их валидацией. Также внесены улучшения в интерфейс и исправлены ошибки.
|
||||
|
||||
✨ Новые функции
|
||||
- [#252](https://github.com/coopenomics/mono/issues/252): Внедрение обновленного стандарта хранения и передачи документов по блокчейну
|
||||
- [#251](https://github.com/coopenomics/mono/issues/251): Реализация версионированного мигратора данных для контроллера кооператива
|
||||
- [#244](https://github.com/coopenomics/mono/issues/244): Обновление стандарта сборки документов и переход на хэш-идентификаторы
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#249](https://github.com/coopenomics/mono/issues/249): Исправлена спутанная маршрутизация между рабочими столами кооперативов
|
||||
- [#253](https://github.com/coopenomics/mono/issues/253): Исправлена избыточная точность суммы оплаты в заявлении на вступление
|
||||
|
||||
🔧 Улучшения
|
||||
- [#259](https://github.com/coopenomics/mono/issues/259): Исправлены отступы в мобильной карточке пайщика на странице пайщиков
|
||||
- [#258](https://github.com/coopenomics/mono/issues/258): Удалён hover-эффект для документов и пайщиков в таблице
|
||||
- [#257](https://github.com/coopenomics/mono/issues/257): Добавлено сохранение светлой/тёмной темы в localStorage и восстановление при загрузке страницы
|
||||
- [#256](https://github.com/coopenomics/mono/issues/256): Отображение статуса членства каждого пайщика в разделе "Пайщики"
|
||||
- [#255](https://github.com/coopenomics/mono/issues/255): Отображение ФИО/Наименования плательщика в разделе "Платежи"
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.5.2
|
||||
|
||||
В этом релизе рабочие столы переведены на серверный рендеринг, что улучшает стабильность развертывания и упрощает автоматизацию поставки ПО. Данный релиз является подготовительным к переходу на новый стандарт цифровых документов на платформе.
|
||||
|
||||
✨ Новые функции
|
||||
- [#245](https://github.com/coopenomics/mono/issues/245): Перевод десктопа на серверный рендеринг с поддержкой динамических переменных окружения
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#247](https://github.com/coopenomics/mono/issues/247): Исправлен баг с повторной поставкой ПО при обрывах соединения между серверами
|
||||
|
||||
🔧 Улучшения
|
||||
- [#246](https://github.com/coopenomics/mono/issues/246): Перевод CI/CD на сборку и поставку предсобранных докер-контейнеров
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# MONO v2025.4.29
|
||||
|
||||
В этом релизе реализована новая архитектура рабочих столов с установкой через маркетплейс. Добавлены стол пайщика и стол совета, переработаны разделы документов и подписей, улучшено разделение кошелька и профиля для повышения удобства пользователей.
|
||||
|
||||
✨ Новые функции
|
||||
- [#234](https://github.com/coopenomics/mono/issues/234): Маркетплейс рабочих столов с поддержкой разных ролей и микросервисной архитектурой
|
||||
- [#238](https://github.com/coopenomics/mono/issues/238): Контроллер общего собрания пайщиков с поддержкой документооборота и подписей
|
||||
- [#233](https://github.com/coopenomics/mono/issues/233): Минимальный смарт-контракт общих собраний пайщиков
|
||||
- [#232](https://github.com/coopenomics/mono/issues/232): Бэкенд полного обозревателя блоков
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#231](https://github.com/coopenomics/mono/issues/231): Исправления ошибок регистрации, выхода из системы, отображения платежей и редактирования организации
|
||||
|
||||
🔧 Улучшения
|
||||
- [#243](https://github.com/coopenomics/mono/issues/243): Контроль прав доступа на получении документов пайщика
|
||||
- [#242](https://github.com/coopenomics/mono/issues/242): Бесконечный скролл на документах пайщика и кооператива
|
||||
- [#241](https://github.com/coopenomics/mono/issues/241): Раздел "Документы" для пайщика
|
||||
- [#240](https://github.com/coopenomics/mono/issues/240): Множественные подписи на одном документе
|
||||
- [#239](https://github.com/coopenomics/mono/issues/239): Последовательные методы подписи протокола общего собрания
|
||||
- [#237](https://github.com/coopenomics/mono/issues/237): Мобильная вёрстка на страницах стола совета
|
||||
- [#236](https://github.com/coopenomics/mono/issues/236): Пересобран лендинг для MONO
|
||||
- [#235](https://github.com/coopenomics/mono/issues/235): Настроен флоу гитхаб-релизов с описаниями
|
||||
- [#226](https://github.com/coopenomics/mono/issues/226): Встроено редактирование пайщиков
|
||||
- [#224](https://github.com/coopenomics/mono/issues/224): Добавлено ТЗ по контракту капитализации
|
||||
|
||||
---
|
||||
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.2.10](https://github.com/coopenomics/mono/compare/v2.2.9...v2.2.10) (2025-03-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add Zeus type ([7bcb6e3](https://github.com/coopenomics/mono/commit/7bcb6e30a77b0ab89c5293188b58f08f19c8761e))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.9](https://github.com/coopenomics/mono/compare/v2.2.8...v2.2.9) (2025-03-12)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.8](https://github.com/coopenomics/monocoop/compare/v2.2.7...v2.2.8) (2025-02-10)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.7](https://github.com/coopenomics/monocoop/compare/v2.2.6...v2.2.7) (2025-02-07)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.6](https://github.com/coopenomics/monocoop/compare/v2.2.6-alpha.0...v2.2.6) (2025-01-27)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.5](https://github.com/coopenomics/monocoop/compare/v2.2.4...v2.2.5) (2025-01-18)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.4](https://github.com/coopenomics/monocoop/compare/v2.2.0...v2.2.4) (2025-01-17)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.3](https://github.com/coopenomics/monocoop/compare/v2.2.0...v2.2.3) (2025-01-16)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.1](https://github.com/coopenomics/monocoop/compare/v2.2.0...v2.2.1) (2025-01-14)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.9](https://github.com/coopenomics/monocoop/compare/v2.1.8...v2.1.9) (2025-01-14)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.8](https://github.com/coopenomics/monocoop/compare/v2.1.6...v2.1.8) (2024-12-24)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.7](https://github.com/coopenomics/monocoop/compare/v2.1.6...v2.1.7) (2024-12-03)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.6](https://github.com/coopenomics/monocoop/compare/v2.1.5...v2.1.6) (2024-10-30)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.5](https://github.com/coopenomics/monocoop/compare/v2.1.4...v2.1.5) (2024-10-28)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.4](https://github.com/coopenomics/monocoop/compare/v2.1.4-alpha.2...v2.1.4) (2024-10-28)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.3](https://github.com/coopenomics/monocoop/compare/v2.1.2-alpha.10...v2.1.3) (2024-10-26)
|
||||
|
||||
|
||||
|
||||
## [2.1.2](https://github.com/coopenomics/monocoop/compare/v2.1.1...v2.1.2) (2024-10-19)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.2](https://github.com/coopenomics/monocoop/compare/v2.1.1...v2.1.2) (2024-10-19)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.1.0](https://github.com/coopenomics/monocoop/compare/v2.0.10-alpha.3...v2.1.0) (2024-10-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* запрос соглашений ([9a6a72f](https://github.com/coopenomics/monocoop/commit/9a6a72f605ba52eef2ed6f18ccee6fbed287ea00))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.9](https://github.com/coopenomics/monocoop/compare/v2.0.8...v2.0.9) (2024-10-10)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.8](https://github.com/coopenomics/monocoop/compare/v2.0.7...v2.0.8) (2024-10-09)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.7](https://github.com/coopenomics/monocoop/compare/v2.0.6...v2.0.7) (2024-09-30)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.6](https://github.com/coopenomics/monocoop/compare/v2.0.5...v2.0.6) (2024-09-30)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.5](https://github.com/coopenomics/monocoop/compare/v2.0.5-alpha.0...v2.0.5) (2024-09-30)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.2](https://github.com/coopenomics/monocoop/compare/v2.0.2-alpha.1...v2.0.2) (2024-09-29)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 2.1.0 (2024-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **importers:** исправлена ошибка в импорте модуля @wharfkit/contract ([bf89032](https://github.com/coopenomics/monocoop/commit/bf89032d8f66444804a2521f4eb96ffc75b0f605))
|
||||
* **terminal:** исправлен шрифт в README ([889447a](https://github.com/coopenomics/monocoop/commit/889447a93ceadb577613ddb5b1cb2ef1cc3d54b6))
|
||||
* **terminal:** исправлено описание проекта ([989b318](https://github.com/coopenomics/monocoop/commit/989b3180ded99a871e018cf26e6c493449223c01))
|
||||
* **terminal:** fix typo in README.md ([fdf9996](https://github.com/coopenomics/monocoop/commit/fdf999619d2d69e960b062fe6815f5b057c95f48))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* добавлен docker-compose.yaml ([6a46697](https://github.com/coopenomics/monocoop/commit/6a46697c9d6cc3cde14dbce8f70997c00f9850de))
|
||||
* **package:** добавлен скрипт gpt-commit для удобного коммита ([e0b5107](https://github.com/coopenomics/monocoop/commit/e0b510799bb0ac68890d572deb652beefd0651c4))
|
||||
* **terminal:** добавлена поддержка новых команд ([73e4ca2](https://github.com/coopenomics/monocoop/commit/73e4ca226acebcbea3ae59a62def99d86efb1353))
|
||||
@@ -0,0 +1,39 @@
|
||||
k# Conventional Commits
|
||||
|
||||
### Основные типы коммитов:
|
||||
|
||||
- **`feat`**: Добавление новой функциональности (feature).
|
||||
- Пример: `feat: добавлена возможность загрузки файлов`
|
||||
|
||||
- **`fix`**: Исправление бага.
|
||||
- Пример: `fix: исправлена ошибка отображения кнопки на мобильных устройствах`
|
||||
|
||||
- **`chore`**: Изменения, не влияющие на исходный код (например, обновление зависимостей или инструментария).
|
||||
- Пример: `chore: обновление зависимостей`
|
||||
|
||||
- **`docs`**: Изменения в документации.
|
||||
- Пример: `docs: обновлено руководство пользователя`
|
||||
|
||||
- **`style`**: Изменения стиля кода, не влияющие на его функциональность (форматирование, пробелы, и т.д.).
|
||||
- Пример: `style: исправлены отступы в коде`
|
||||
|
||||
- **`refactor`**: Изменения в коде, которые не исправляют баги и не добавляют новую функциональность.
|
||||
- Пример: `refactor: улучшена структура класса`
|
||||
|
||||
- **`perf`**: Изменения, направленные на улучшение производительности.
|
||||
- Пример: `perf: оптимизирована работа с массивами`
|
||||
|
||||
- **`test`**: Добавление или изменение тестов.
|
||||
- Пример: `test: добавлен тест для проверки функции отправки сообщений`
|
||||
|
||||
### Дополнительные типы коммитов:
|
||||
|
||||
- **`build`**: Изменения, связанные с процессом сборки или зависимостями.
|
||||
- Пример: `build: обновление конфигурации Webpack`
|
||||
|
||||
- **`ci`**: Изменения, касающиеся настроек непрерывной интеграции (например, Travis, Jenkins).
|
||||
- Пример: `ci: настройка Travis для автоматических сборок`
|
||||
|
||||
- **`revert`**: Откат на предыдущие изменения.
|
||||
- Пример: `revert: откат коммита e0b5107`
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Сразу копируем все файлы
|
||||
COPY . .
|
||||
|
||||
# Устанавливаем инструменты
|
||||
RUN npm install -g pnpm lerna
|
||||
|
||||
# Установка зависимостей
|
||||
# Используем версию pnpm, совместимую с существующим lock-файлом
|
||||
RUN pnpm install
|
||||
|
||||
# Установка системных зависимостей для WeasyPrint и диагностических утилит (Debian/Ubuntu версии)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libcairo2-dev \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
zlib1g-dev \
|
||||
libjpeg-dev \
|
||||
libopenjp2-7-dev \
|
||||
procps \
|
||||
wget \
|
||||
&& python3 -m venv /venv \
|
||||
&& /venv/bin/pip install WeasyPrint==67 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Сборка всех компонентов
|
||||
RUN lerna run build
|
||||
|
||||
# Финальный образ
|
||||
FROM builder AS runtime
|
||||
|
||||
# Настройка переменных окружения
|
||||
ENV PATH="/venv/bin:$PATH"
|
||||
|
||||
# Проверка WeasyPrint
|
||||
RUN weasyprint --version
|
||||
@@ -1,4 +1,120 @@
|
||||
# MONOCOOP
|
||||
# Цифровой Кооператив
|
||||
|
||||
Моно-репозиторий компонент Цифрового Кооператива.
|
||||
|
||||
<!-- badges -->
|
||||

|
||||

|
||||

|
||||
|
||||
Платформа «Цифровой Кооператив» — комплексное программное обеспечение для управления кооперативными организациями на основе блокчейна EOSIO. Система обеспечивает полный цикл управления кооперативом: от регистрации пайщиков и электронного документооборота до проведения собраний и финансового учёта. Построена на принципах прозрачности, децентрализации и простой электронной подписи.
|
||||
|
||||
Проект является частью экосистемы [Кооперативная Экономика](https://coopenomics.world).
|
||||
|
||||
## Архитектура
|
||||
|
||||
| Компонент | Пакет | Описание |
|
||||
|-----------|-------|----------|
|
||||
| [boot](components/boot) | `@coopenomics/boot` | CLI для инициализации и управления блокчейн-инфраструктурой |
|
||||
| [cleos](components/cleos) | `@coopenomics/cleos` | Утилита командной строки для работы с блокчейн-кошельком |
|
||||
| [contracts](components/contracts) | `@coopenomics/contracts` | Смарт-контракты EOSIO на C++ |
|
||||
| [controller](components/controller) | `@coopenomics/controller` | GraphQL API сервер (NestJS) |
|
||||
| [cooptypes](components/cooptypes) | `cooptypes` | Общие типы и интерфейсы блокчейн-контрактов |
|
||||
| [desktop](components/desktop) | `@coopenomics/desktop` | Рабочий стол кооператива (Vue 3 + Quasar) |
|
||||
| [factory](components/factory) | `@coopenomics/factory` | Генератор юридических документов |
|
||||
| [migrator](components/migrator) | `migrator` | Утилита миграции данных |
|
||||
| [notifications](components/notifications) | `@coopenomics/notifications` | Библиотека уведомлений на основе Novu |
|
||||
| [parser](components/parser) | `@coopenomics/parser` | Индексатор блокчейна через State History Plugin |
|
||||
| [sdk](components/sdk) | `@coopenomics/sdk` | TypeScript SDK для GraphQL API |
|
||||
| [setup](components/setup) | `@coopenomics/setup` | Мастер первоначальной настройки |
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Предварительные требования
|
||||
|
||||
- Node.js >= 20
|
||||
- pnpm 9
|
||||
- Docker и Docker Compose
|
||||
- [WeasyPrint](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation) (для генерации PDF)
|
||||
|
||||
### Установка
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Конфигурация
|
||||
|
||||
```bash
|
||||
pnpm run setup
|
||||
```
|
||||
|
||||
Интерактивный мастер создаст необходимые `.env` файлы для всех компонентов.
|
||||
|
||||
### Запуск инфраструктуры
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
pnpm run reboot
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
### Бэкенд (controller + parser)
|
||||
|
||||
```bash
|
||||
pnpm run dev:backend
|
||||
```
|
||||
|
||||
### Фронтенд (desktop)
|
||||
|
||||
```bash
|
||||
pnpm run dev:desktop
|
||||
```
|
||||
|
||||
### Библиотеки (factory + cooptypes)
|
||||
|
||||
```bash
|
||||
pnpm run dev:lib
|
||||
```
|
||||
|
||||
### Все сервисы одновременно
|
||||
|
||||
```bash
|
||||
pnpm run dev:all
|
||||
```
|
||||
|
||||
> **Примечание:** установка пакетов производится только через фильтр: `pnpm add <пакет> --filter <компонент>`
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
# Все тесты
|
||||
pnpm run test
|
||||
|
||||
# Юнит-тесты (cooptypes, parser, notifications)
|
||||
pnpm run test:unit
|
||||
|
||||
# Компонентные тесты (factory)
|
||||
pnpm run test:component
|
||||
|
||||
# Интеграционные тесты (boot + blockchain)
|
||||
pnpm run test:integration
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
# Библиотеки (cooptypes, factory)
|
||||
pnpm run build:lib
|
||||
|
||||
# Смарт-контракты
|
||||
pnpm run build:contracts:all
|
||||
|
||||
# Desktop (SSR)
|
||||
pnpm --filter @coopenomics/desktop run build
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
Продукт Потребительского Кооператива «ВОСХОД» распространяется по лицензии [BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.ru).
|
||||
|
||||
Разрешено делиться, копировать и распространять материал, адаптировать и создавать производные произведения при условии указания авторства и сохранения той же лицензии. Коммерческое использование запрещено.
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# 📋 Резюме создания компонентов Notifications
|
||||
|
||||
## ✅ Что создано
|
||||
|
||||
### 1. 📚 Библиотека `@monocoop/notifications`
|
||||
**Расположение:** `components/notifications/`
|
||||
|
||||
**Функции:**
|
||||
- ✅ Типизированные интерфейсы для workflow с Zod
|
||||
- ✅ Builder паттерн для создания workflow
|
||||
- ✅ Базовые шаблоны для email, in-app, push, SMS
|
||||
- ✅ Автоматическая конвертация Zod схем в JSON Schema для Novu
|
||||
- ✅ Экспорт всех workflow для использования в других пакетах
|
||||
|
||||
**Структура:**
|
||||
```
|
||||
src/
|
||||
├── types/ # Базовые типы и интерфейсы
|
||||
├── base/ # Утилиты и настройки по умолчанию
|
||||
├── workflows/ # Папки с workflow
|
||||
│ └── welcome/ # Пример приветственного workflow
|
||||
└── index.ts # Главный экспорт
|
||||
```
|
||||
|
||||
### 2. 🚀 NestJS приложение `@monocoop/notificator2`
|
||||
**Расположение:** `components/notificator2/`
|
||||
|
||||
**Функции:**
|
||||
- ✅ Автоматический upsert всех workflow в Novu при запуске
|
||||
- ✅ RESTful API для триггера уведомлений
|
||||
- ✅ Типизированная валидация payload
|
||||
- ✅ Health check endpoints
|
||||
- ✅ Использует библиотеку notifications для типов
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/notifications/health` - Health check
|
||||
- `GET /api/notifications/workflows` - Список workflow
|
||||
- `POST /api/notifications/trigger` - Универсальный триггер
|
||||
- `POST /api/notifications/trigger/welcome` - Триггер welcome workflow
|
||||
- `POST /api/notifications/workflows/upsert-all` - Принудительный upsert
|
||||
|
||||
## 🔧 Как использовать
|
||||
|
||||
### 1. Настройка библиотеки notifications
|
||||
```bash
|
||||
cd components/notifications
|
||||
pnpm install # или npm install
|
||||
pnpm build # для компиляции TypeScript
|
||||
```
|
||||
|
||||
### 2. Настройка notificator2
|
||||
```bash
|
||||
cd components/notificator2
|
||||
pnpm install # или npm install
|
||||
|
||||
# Настройка окружения
|
||||
cp .env.example .env
|
||||
# Добавить NOVU_API_KEY в .env
|
||||
|
||||
# Запуск
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
### 3. Добавление нового workflow
|
||||
|
||||
**В библиотеке notifications:**
|
||||
```typescript
|
||||
// components/notifications/src/workflows/order/order-workflow.ts
|
||||
export const orderWorkflow = WorkflowBuilder
|
||||
.create<OrderPayload>()
|
||||
.name('Order Confirmation')
|
||||
.workflowId('order-confirmation')
|
||||
.payloadSchema(orderPayloadSchema)
|
||||
.addSteps([...])
|
||||
.build();
|
||||
```
|
||||
|
||||
**Регистрация в index.ts:**
|
||||
```typescript
|
||||
// components/notifications/src/workflows/index.ts
|
||||
import { orderWorkflow } from './order';
|
||||
|
||||
export const allWorkflows = [
|
||||
welcomeWorkflow,
|
||||
orderWorkflow, // ← добавить новый workflow
|
||||
];
|
||||
```
|
||||
|
||||
### 4. Использование API
|
||||
|
||||
**Триггер welcome workflow:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/notifications/trigger/welcome \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"subscriberId": "user-123",
|
||||
"email": "user@example.com",
|
||||
"payload": {
|
||||
"userName": "Иван Иванов",
|
||||
"userEmail": "user@example.com",
|
||||
"age": 25
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 🎯 Преимущества архитектуры
|
||||
|
||||
1. **Типобезопасность** - Zod схемы обеспечивают валидацию на уровне TypeScript и runtime
|
||||
2. **Разделение ответственности** - Библиотека содержит только типы, сервер - логику
|
||||
3. **Расширяемость** - Легко добавлять новые workflow
|
||||
4. **Автоматизация** - Workflow автоматически синхронизируются с Novu
|
||||
5. **Переиспользование** - Типы можно использовать в других частях системы
|
||||
|
||||
## 🔄 Workflow при запуске
|
||||
|
||||
1. **Запуск notificator2** →
|
||||
2. **Чтение всех workflow из библиотеки** →
|
||||
3. **Проверка существования в Novu** →
|
||||
4. **Создание/обновление workflow** →
|
||||
5. **Готов к приёму запросов на триггеры**
|
||||
|
||||
## 📁 Структура как в testFramework2.ts
|
||||
|
||||
Вся логика из `testFramework2.ts` была перенесена в structured архитектуру:
|
||||
- ✅ `buildWorkflowData` → `WorkflowBuilder`
|
||||
- ✅ `baseSteps` → `createEmailStep`, `createInAppStep`, etc.
|
||||
- ✅ `upsertWorkflow` → `NovuService.upsertWorkflow`
|
||||
- ✅ `triggerWorkflow` → `NovuService.triggerWorkflow`
|
||||
- ✅ Типизация payload → Zod схемы + TypeScript типы
|
||||
@@ -0,0 +1,55 @@
|
||||
# TASKS.md — Прогресс выполнения задач
|
||||
|
||||
## Завершённые задачи
|
||||
|
||||
### 1-6. Предыдущие задачи (см. git history)
|
||||
- ✅ Dev-окружение, Security, Тесты, README, AGENTS.md, Setup
|
||||
- ✅ Поисковая система документов (OpenSearch)
|
||||
- ✅ Процессы (Capital extension)
|
||||
|
||||
---
|
||||
|
||||
## Текущая задача: Генерация отчётов ФНС (расширение reports)
|
||||
|
||||
### Документы ФНС для генерации:
|
||||
1. **6-НДФЛ** — ежеквартально (XSD: NO_NDFL6.2)
|
||||
2. **4-ФСС (ЕФС-1)** — ежеквартально
|
||||
3. **РСВ** — ежеквартально (XSD: NO_RASCHSV)
|
||||
4. **ПСВ** — ежемесячно (XSD: NO_PERSSVFL)
|
||||
5. **Бухгалтерский баланс** — ежегодно (XSD: NO_BUHOTCH) — КЛЮЧЕВОЙ
|
||||
6. **ДУСН** — декларация УСН ежегодно (XSD: NO_USN)
|
||||
7. **Уведомление о страховых взносах** — ежемесячно с 2026 (XSD: UT_UVISCHSUMNAL)
|
||||
8. **УУСН** — уведомление УСН
|
||||
### 10. Генерация отчётов ФНС (доработка) ✅
|
||||
- [x] Фабрика генераторов (ReportRegistryService)
|
||||
- [x] 8 генераторов (Бухбаланс, 6-НДФЛ, РСВ, ПСВ, ДУСН, 4-ФСС, Увед. взносы, УУСН)
|
||||
- [x] GraphQL API (getAvailableReports, generateReport)
|
||||
- [x] Генераторы переписаны по XSD — структура соответствует схемам ФНС
|
||||
- [x] 48 unit-тестов для всех генераторов
|
||||
- [x] Desktop UI (страница отчётов) — расширение reports
|
||||
- [x] Интеграция с реальными данными ledger через LedgerInteractor
|
||||
- [x] OrganizationDataInput DTO для передачи данных организации
|
||||
|
||||
### Архитектура:
|
||||
- Расширение `reports` в `components/controller/src/extensions/`
|
||||
- Фабрика XML отчётов: на вход данные за период → на выходе XML
|
||||
- Валидация по XSD схемам
|
||||
- Desktop UI: магазин приложений → установка → рабочий стол отчётов
|
||||
|
||||
### Подзадачи:
|
||||
|
||||
- [x] **8.1 Исследование**: Все XSD разобраны, format.nalog.ru изучен
|
||||
- [x] **8.2 Инфраструктура**: Расширение reports, ReportRegistryService (фабрика)
|
||||
- [x] **8.3 XML генератор**: Фабричный подход — IReportGenerator interface
|
||||
- [x] **8.4 Бухбаланс**: BuhotchGenerator — счета 51, 80, 86 из ledger
|
||||
- [x] **8.5 6-НДФЛ**: Ndfl6Generator — нулевая
|
||||
- [x] **8.6 4-ФСС**: Zero generator — нулевая
|
||||
- [x] **8.7 РСВ**: Zero generator — нулевая
|
||||
- [x] **8.8 ПСВ**: Zero generator — нулевая
|
||||
- [x] **8.9 ДУСН**: Zero generator — нулевая
|
||||
- [x] **8.10 Уведомление о взносах**: Zero generator — нулевая
|
||||
- [x] **8.11 УУСН**: Zero generator — нулевая
|
||||
- [x] **8.12 GraphQL API**: getAvailableReports + generateReport
|
||||
- [ ] **8.13 XSD валидация + тесты**: Проверка по схемам
|
||||
- [ ] **8.14 Desktop UI**: Страница отчётов в магазине приложений
|
||||
- [ ] **8.15 Ledger интеграция**: Реальные данные из ledger_operations
|
||||
@@ -0,0 +1,64 @@
|
||||
# Test Plan — pnpm run test
|
||||
|
||||
## Архитектура
|
||||
```
|
||||
pnpm run test
|
||||
├── cooptypes — vitest run (smoke tests exports) ✅ 4/4
|
||||
├── parser — vitest run (config smoke tests) ✅ 3/3
|
||||
├── factory — vitest run (document generation tests) 🔧 17/85 → need mocks
|
||||
├── sdk — vitest run (API integration tests) 🔧 TODO
|
||||
├── notifications — vitest run (workflow tests) 🔧 TODO
|
||||
├── controller — jest / vitest (NestJS unit tests) 🔧 TODO
|
||||
└── boot — vitest run (blockchain integration) 🔧 53/60
|
||||
```
|
||||
|
||||
## Статус по компонентам
|
||||
|
||||
### cooptypes ✅ DONE
|
||||
- 4 smoke-теста экспортов
|
||||
- Не требует инфраструктуры
|
||||
|
||||
### parser ✅ DONE
|
||||
- 3 smoke-теста конфигурации
|
||||
- Не требует инфраструктуры
|
||||
|
||||
### factory 🔧 IN PROGRESS
|
||||
- **Проблема**: тесты обращаются к parser API (`SIMPLE_EXPLORER_API`) для get-tables/get-actions
|
||||
- **Решение**: Уже есть мок-система в `src/Utils/testMocks.ts` + `matchMock.ts`
|
||||
- **Нужно**: Добавить моки для ВСЕХ документов:
|
||||
- [ ] cooperative data mock (registrator.coops table)
|
||||
- [ ] soviet boards mock — уже есть через test setup в MongoDB
|
||||
- [ ] draft templates mock (draft.drafts + draft.translations tables)
|
||||
- [ ] decision data mocks (soviet.decisions table)
|
||||
- [ ] Мок для ReturnByMoney документов
|
||||
- [ ] Все документы 1000+ серии
|
||||
- **Текущие рабочие моки**: meet tables, votefor actions, returnByMoneyDecision actions
|
||||
- **После мокирования**: все 85 тестов должны проходить
|
||||
|
||||
### boot 🔧 NEEDS REBOOT
|
||||
- capital.test — 53/60 тестов проходят (после чистого reboot)
|
||||
- wallet.test — нужен полный boot с agreements
|
||||
- registrator.test — нужен полный boot
|
||||
- capital-import.test — отдельный тест импорта
|
||||
- **Требует**: pnpm run reboot перед запуском
|
||||
|
||||
### sdk 🔧 TODO
|
||||
- 1 тест файл с login + fetch extensions
|
||||
- Требует запущенный controller
|
||||
- Нужно: обновить chain_id, api_url, credentials
|
||||
|
||||
### controller 🔧 TODO
|
||||
- Все старые тесты удалены (устаревшие)
|
||||
- NestJS-приложение — нужны тесты через @nestjs/testing
|
||||
- Минимум: unit-тесты domain-логики, smoke-тест GraphQL API
|
||||
|
||||
### notifications 🔧 TODO
|
||||
- Нет тестов
|
||||
- Нужно: smoke-тесты workflow builder
|
||||
|
||||
### desktop — SKIP (нет тестов, UI-тестирование)
|
||||
|
||||
## Root script требования
|
||||
- `pnpm run test` — запускает ВСЕ тесты
|
||||
- fail-fast: если один пакет падает — весь pipeline падает
|
||||
- Последовательный запуск (не параллельный)
|
||||
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
# @coopenomics/blago-cli
|
||||
|
||||
CLI синхронизации артефактов Благорост (проекты, задачи, требования) с бэкендом через `@coopenomics/sdk`.
|
||||
|
||||
## Базовый каталог и корень копии
|
||||
|
||||
Порядок выбора **базового каталога**:
|
||||
|
||||
1. **`--dir <path>`** (или **`--dir=<path>`**) — можно указать **в любом месте** командной строки, до или после подкоманды, например `blago pull --dir ~/results` или `blago --dir ~/results pull`.
|
||||
2. Переменная окружения **`BLAGO_WORKSPACE`**.
|
||||
3. Текущий рабочий каталог (**cwd**).
|
||||
|
||||
**Корень рабочей копии** — каталог, в котором (или выше по дереву от базового каталога) лежит `.blago/config.json`. Поиск идёт вверх от базы, пока не найден файл.
|
||||
|
||||
Команда **`blago init [directory]`** создаёт `.blago` в `path.resolve(база, directory)`; по умолчанию `[directory]` — `.`.
|
||||
|
||||
## Справка по командам
|
||||
|
||||
```text
|
||||
blago --help
|
||||
blago <команда> --help
|
||||
```
|
||||
|
||||
У подкоманд в help выводится блок **Global Options** (в том числе `--dir` и версия), если смотрите справку не с корневого уровня.
|
||||
|
||||
В конце help добавляется строка **текущей сессии** (активная среда и пользователь из сохранённого `blago login`), если найдена копия.
|
||||
|
||||
## Прочее
|
||||
|
||||
- Форматы сущностей и коммиты в репозитории кода: в корне копии **`blago docs`** → `BLAGO-FORMATS.md`, `BLAGO-COMMITS.md`.
|
||||
- Артефакты для агентов: **`blago skills install`** → целиком `~/.claude/skills/blago/ai/` (скилл `skills/cli/SKILL.md`, каталог `commands/` и т.д.).
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: blago-cli
|
||||
description: |
|
||||
Синхронизация задач (issue) и требований (story) в копии Благорост через blago CLI — pull, правки, add, push, черновики create, конфликты по updated_at.
|
||||
Триггеры: blago, Capital, Благорост, issue.md, story, requirements, staging, push/pull, YAML frontmatter задач.
|
||||
---
|
||||
|
||||
# blago CLI — задачи и требования
|
||||
|
||||
Корень копии: `.blago/config.json` вверх от базового каталога. База: `blago --dir <path> …` (в любом месте строки, приоритет над `BLAGO_WORKSPACE`), иначе `BLAGO_WORKSPACE`, иначе cwd. Сетевые команды — готовая сессия у оператора.
|
||||
|
||||
В установке путь к этому скиллу: **`…/ai/skills/cli/SKILL.md`**. Каталог **`ai/commands/`** (рядом с `skills/` в дереве `ai/`) — slash-команды и сценарии; в **`ai/skills/`** при необходимости — другие скиллы.
|
||||
|
||||
## Поток данных
|
||||
|
||||
| Действие | Команды |
|
||||
|----------|---------|
|
||||
| Забрать с сервера | `blago pull` |
|
||||
| Увидеть staging и расхождения с индексом | `blago status`, `blago diff` |
|
||||
| Пометить изменённые .md к отправке | `blago add <пути…>` (каталог — рекурсия по .md) |
|
||||
| Отправить | `blago push` (только staging) |
|
||||
| Убрать из staging | `blago remove <пути…>` или `blago remove --all` |
|
||||
| Перезаписать файл с сервера | `blago restore <относительный-путь>` |
|
||||
|
||||
Цепочка после правок: `add` → `push`. `add` берёт только файлы, изменённые относительно `.blago/index.json`, и новые без записи в индексе.
|
||||
|
||||
## Конфликт версий
|
||||
|
||||
Если push падает с **другой версией на сервере (updated_at)**:
|
||||
|
||||
1. `blago pull`
|
||||
2. Вручную свести содержимое и frontmatter с актуальным сервером (не трогать `hash` у существующих сущностей без понимания последствий)
|
||||
3. `blago add …` → `blago push`
|
||||
|
||||
Локальный откат одного файла к серверу: `blago restore <path>`.
|
||||
|
||||
## Создание
|
||||
|
||||
| Сущность | Команда |
|
||||
|----------|---------|
|
||||
| Черновик задачи | `blago create issue <basePath> "<title>"` — `basePath`: каталог проекта/компонента или путь к `project.md` / `component.md` |
|
||||
| Черновик требования (story) | `blago create req <basePath> "<title>"` — опции `--format markdown\|mermaid\|drawio\|bpmn` |
|
||||
|
||||
Черновик попадает в staging; первый `push` создаёт сущность на сервере. Опции `--set-self`, `--creators`, `--submaster` (issue) / `--set-self` (req) — см. `blago create … --help`.
|
||||
|
||||
## Обновление существующих
|
||||
|
||||
1. После `pull` править тело Markdown и допустимые поля frontmatter (см. ниже). **Поле `hash` у уже синхронизированных сущностей не менять.**
|
||||
2. `blago add <файл>` → `blago push`.
|
||||
|
||||
Тип **result** через этот CLI не синхронизируется.
|
||||
|
||||
## Frontmatter (YAML между `---` в начале файла)
|
||||
|
||||
Общее: все сущности — Markdown + YAML; `hash` — стабильный id сущности на сервере.
|
||||
|
||||
### `project` — `…/project.md` или `…/component.md`
|
||||
|
||||
Порядок полей: `type`, `id`, `title`; у компонента сразу `parent_title`, `parent_hash`; далее `coopname`, `status`, `hash`, `created_at`, `updated_at` (ISO-8601). Тело — описание.
|
||||
|
||||
### `issue` — `issues/<id>-<slug>.md`
|
||||
|
||||
Порядок: `type`, `id`, `title`, `project_title`, `component_title`, `status`, `priority`, `estimate`, `creators`, `labels`, опционально `cycle_id`, `submaster`, затем `hash`, `project_hash`, даты. Поля `created_by` и `sort_order` в файле обычно не задаются (при push `sort_order` может уйти как 0). Тело — описание задачи.
|
||||
|
||||
### `story` (требование) — `requirements/<prefix>-<slug>.md` или под `issues/…/requirements/…`
|
||||
|
||||
`type: story`, при наличии серверного id — поле `id`; далее `title`, `hash`, `content_format` (например `MARKDOWN`), `status`, `created_by`, `sort_order`, привязка `project_hash` и/или `issue_hash`. Тело — текст требования.
|
||||
|
||||
## Коммиты в Git репозитория кода (не blago)
|
||||
|
||||
В сообщении указывать минимум: **username** участника и **hash задачи** из frontmatter issue.
|
||||
|
||||
Примеры: `ant CAPITAL-42 правка API`, `ant | проект x | задача CAPITAL-12 | фикс`.
|
||||
|
||||
## Справка в дереве пакета
|
||||
|
||||
Полные тексты форматов: `blago docs` в корне копии пишет `BLAGO-FORMATS.md` и `BLAGO-COMMITS.md`.
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineBuildConfig } from 'unbuild'
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: ['src/index'],
|
||||
declaration: true,
|
||||
clean: true,
|
||||
rollup: {
|
||||
emitCJS: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// @ts-check
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
type: 'lib',
|
||||
rules: {
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
'no-console': 'off',
|
||||
'node/prefer-global/buffer': 'off',
|
||||
'node/prefer-global/process': 'off',
|
||||
'perfectionist/sort-named-imports': 'off',
|
||||
'regexp/no-obscure-range': 'off',
|
||||
'sort-imports': 'off',
|
||||
'ts/explicit-function-return-type': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'package.json', 'tsconfig.json'],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.4.6-4",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"blago": "./dist/index.mjs"
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"ai",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "unbuild",
|
||||
"dev": "unbuild --stub",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"test": "echo \"No tests yet\" && exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coopenomics/sdk": "workspace:*",
|
||||
"commander": "^12.1.0",
|
||||
"cooptypes": "workspace:*",
|
||||
"gray-matter": "^4.0.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"yaml": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.3.2",
|
||||
"@types/node": "^22.5.4",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.9.3",
|
||||
"unbuild": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Commander: подкоманды; базовый каталог — --dir, иначе BLAGO_WORKSPACE, иначе cwd; корень копии — поиск .blago/config.json вверх.
|
||||
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Command } from 'commander'
|
||||
|
||||
import pkg from '../../package.json'
|
||||
|
||||
import {
|
||||
type BlagoConfigFile,
|
||||
type BlagoRemoteProfile,
|
||||
getActiveProfile,
|
||||
initBlagoWorkspace,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
} from '../config/index.js'
|
||||
import { findBlagoRoot } from '../config/paths.js'
|
||||
import { peelBlagoDirFromArgv, resolveBlagoStartDir, setBlagoCliExplicitStartDir } from '../config/start-dir.js'
|
||||
import { runCreateIssue } from '../create/run-create-issue.js'
|
||||
import { runCreateStory } from '../create/run-create-story.js'
|
||||
import { writeLlmDocs } from '../llm-docs/index.js'
|
||||
import { applySession, createClient, ensureAuthenticatedContext, loginInteractive, promptLine } from '../session/index.js'
|
||||
import {
|
||||
describeBlagoSessionLine,
|
||||
formatBlagoSessionStatusHelpExtra,
|
||||
readSessionUsernameSync,
|
||||
} from '../session/status-text.js'
|
||||
import { runAdd } from '../sync/add.js'
|
||||
import { runClean } from '../sync/clean.js'
|
||||
import { runDiff } from '../sync/diff.js'
|
||||
import { normalizeRelativePath } from '../sync/index-store.js'
|
||||
import { runPull } from '../sync/pull.js'
|
||||
import { runPush } from '../sync/push.js'
|
||||
import { runClearStaging, runRemove } from '../sync/remove.js'
|
||||
import { runRestore } from '../sync/restore.js'
|
||||
import { runStatus } from '../sync/status.js'
|
||||
import { error, formatThrownValue, info, success, warn } from '../ui/output.js'
|
||||
import { runSkillsInstall } from './run-skills-install.js'
|
||||
|
||||
function startDir(): string {
|
||||
return resolveBlagoStartDir()
|
||||
}
|
||||
|
||||
function requireRoot(): string {
|
||||
const root = findBlagoRoot(startDir())
|
||||
if (!root) {
|
||||
throw new Error(
|
||||
'Не найдена рабочая копия blago (.blago/config.json). Задайте «blago --dir <каталог> …», переменную BLAGO_WORKSPACE или выполните «blago init» в корне проекта.',
|
||||
)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
export async function runCli(originalArgv: string[]): Promise<void> {
|
||||
const { argv: argvForCommander, dir: dirFromArgv } = peelBlagoDirFromArgv(originalArgv)
|
||||
setBlagoCliExplicitStartDir(dirFromArgv)
|
||||
|
||||
const program = new Command()
|
||||
.name('blago')
|
||||
.description('Синхронизация проектов, задач и требований Благорост с бэкендом (GraphQL SDK). Подробности: README пакета.')
|
||||
.configureHelp({ showGlobalOptions: true })
|
||||
.version(pkg.version)
|
||||
.option(
|
||||
'--dir <path>',
|
||||
'базовый каталог: поиск .blago/config.json вверх и относительные пути; выше приоритета, чем BLAGO_WORKSPACE; допускается до или после подкоманды',
|
||||
)
|
||||
.addHelpText('afterAll', () => formatBlagoSessionStatusHelpExtra())
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description('Создать .blago/config.json в текущем или указанном каталоге')
|
||||
.argument('[directory]', 'каталог для инициализации', '.')
|
||||
.option(
|
||||
'--coopname <name>',
|
||||
'записать это имя кооператива во все среды в config (потом можно развести по-разному в JSON)',
|
||||
)
|
||||
.option('--force', 'перезаписать config.json значениями по умолчанию')
|
||||
.option('--with-llm-docs', 'создать BLAGO-FORMATS.md и BLAGO-COMMITS.md в корне')
|
||||
.action(async (directory: string, opts: { coopname?: string, force?: boolean, withLlmDocs?: boolean }) => {
|
||||
const base = path.resolve(startDir(), directory)
|
||||
await initBlagoWorkspace(base, { coopname: opts.coopname, force: opts.force })
|
||||
if (opts.withLlmDocs) {
|
||||
await writeLlmDocs(base)
|
||||
}
|
||||
success(`Инициализировано: ${path.join(base, '.blago')}`)
|
||||
})
|
||||
|
||||
const createCmd = program
|
||||
.command('create')
|
||||
.description(
|
||||
'Локальный черновик задачи или требования (без сети). Файл попадает в staging; первый push — мутация create на сервере.',
|
||||
)
|
||||
|
||||
createCmd
|
||||
.command('issue')
|
||||
.description('Создать файл задачи (type: issue) и запись в .blago/pending-create.json')
|
||||
.argument('<basePath>', 'каталог проекта/компонента или путь к project.md / component.md')
|
||||
.argument('<title>', 'заголовок')
|
||||
.option(
|
||||
'--set-self',
|
||||
'добавить username из сессии активной среды первым в creators (нужен blago login); created_by не задаётся',
|
||||
)
|
||||
.option(
|
||||
'--creators <list>',
|
||||
'список создателей через запятую; с --set-self — после текущего пользователя; без флагов — creators: []',
|
||||
)
|
||||
.option('--submaster <username>', 'явный submaster (только с этим флагом)')
|
||||
.action(
|
||||
async (
|
||||
basePath: string,
|
||||
title: string,
|
||||
opts: { setSelf?: boolean, creators?: string, submaster?: string },
|
||||
) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const { relativePath } = await runCreateIssue(root, cfg, basePath, title, {
|
||||
setSelf: opts.setSelf,
|
||||
creatorsCsv: opts.creators,
|
||||
submaster: opts.submaster,
|
||||
})
|
||||
success(`Создан черновик задачи: ${relativePath} (добавлено в staging)`)
|
||||
},
|
||||
)
|
||||
|
||||
createCmd
|
||||
.command('req')
|
||||
.alias('requirement')
|
||||
.description('Создать файл требования / story (type: story)')
|
||||
.argument('<basePath>', 'каталог проекта/компонента или путь к project.md / component.md')
|
||||
.argument('<title>', 'заголовок')
|
||||
.option(
|
||||
'--set-self',
|
||||
'заполнить created_by username из сессии активной среды (нужен blago login)',
|
||||
)
|
||||
.option(
|
||||
'--format <name>',
|
||||
'содержимое: markdown | mermaid | drawio | bpmn',
|
||||
'markdown',
|
||||
)
|
||||
.action(
|
||||
async (basePath: string, title: string, opts: { setSelf?: boolean, format?: string }) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const { relativePath } = await runCreateStory(root, cfg, basePath, title, {
|
||||
setSelf: opts.setSelf,
|
||||
format: opts.format,
|
||||
})
|
||||
success(`Создан черновик требования: ${relativePath} (добавлено в staging)`)
|
||||
},
|
||||
)
|
||||
|
||||
program
|
||||
.command('login')
|
||||
.description('Интерактивный вход (email + WIF); сохраняет сессию для активной среды')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const profile = getActiveProfile(cfg)
|
||||
const client = createClient(profile)
|
||||
const session = await loginInteractive(client, root, cfg.activeEnv)
|
||||
await applySession(client, session)
|
||||
success('Вход выполнен, сессия сохранена в .blago/')
|
||||
})
|
||||
|
||||
program
|
||||
.command('pull')
|
||||
.description('Скачать с сервера проекты, задачи и требования (coopname в config или из blago init)')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const ctx = await ensureAuthenticatedContext(root, cfg)
|
||||
await runPull(ctx)
|
||||
success('pull завершён')
|
||||
})
|
||||
|
||||
program
|
||||
.command('add')
|
||||
.description(
|
||||
'Добавить в staging только изменённые относительно индекса .md (или без записи в индексе); каталог рекурсивно',
|
||||
)
|
||||
.argument('<paths...>', 'пути относительно корня рабочей копии')
|
||||
.action(async (paths: string[]) => {
|
||||
const root = requireRoot()
|
||||
const { stagedPaths, skippedUnchanged, skippedIgnored } = await runAdd(root, paths)
|
||||
success(
|
||||
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}.`,
|
||||
)
|
||||
})
|
||||
|
||||
program
|
||||
.command('remove')
|
||||
.alias('rm')
|
||||
.description('Убрать файлы .md из staging (каталог — рекурсивно все .md внутри)')
|
||||
.option('-a, --all', 'очистить staging целиком')
|
||||
.argument('[paths...]', 'пути относительно корня рабочей копии')
|
||||
.action(async (paths: string[], opts: { all?: boolean }) => {
|
||||
const root = requireRoot()
|
||||
if (opts.all) {
|
||||
await runClearStaging(root)
|
||||
success('Staging очищен (--all).')
|
||||
return
|
||||
}
|
||||
const { removedCount, remainingPaths, notStagedCount } = await runRemove(root, paths)
|
||||
success(
|
||||
`Из staging убрано путей: ${removedCount}. Осталось в списке: ${remainingPaths.length}. Не было в staging: ${notStagedCount}.`,
|
||||
)
|
||||
})
|
||||
|
||||
program
|
||||
.command('restore')
|
||||
.description('Восстановить один .md с сервера по пути из индекса (перезапись файла и обновление индекса)')
|
||||
.argument('<path>', 'относительный путь к файлу, например 2-proekt-2/project.md')
|
||||
.action(async (filePath: string) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const ctx = await ensureAuthenticatedContext(root, cfg)
|
||||
await runRestore(ctx, filePath)
|
||||
success(`Восстановлено с сервера: ${normalizeRelativePath(filePath)}`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('push')
|
||||
.description('Отправить staged файлы на сервер (проверка версии, без тихой перезаписи)')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const ctx = await ensureAuthenticatedContext(root, cfg)
|
||||
await runPush(ctx)
|
||||
success('push завершён')
|
||||
})
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Показать staging и файлы с локальными изменениями относительно индекса')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
await runStatus(root)
|
||||
})
|
||||
|
||||
program
|
||||
.command('diff')
|
||||
.description('Файлы, где содержимое разошлось с индексом: SHA256 и превью строк (не сравнение с сервером)')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
await runDiff(root)
|
||||
})
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description(
|
||||
'Удалить верхнеуровневые каталоги проектов из индекса (целиком) и обнулить index/staging; .blago не трогает',
|
||||
)
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
const line = await promptLine(
|
||||
'Будут удалены каталоги проектов (по индексу) и очищены index/staging. Введите yes для подтверждения: ',
|
||||
)
|
||||
if (line.trim().toLowerCase() !== 'yes') {
|
||||
warn('Отменено.')
|
||||
return
|
||||
}
|
||||
await runClean(root)
|
||||
success('Индекс и staging очищены, каталоги проектов удалены.')
|
||||
})
|
||||
|
||||
const envCmd = program
|
||||
.command('env')
|
||||
.description(
|
||||
'Управление именованными средами (URL API и блокчейна). Без подкоманды — показать активную среду и сессию.',
|
||||
)
|
||||
|
||||
envCmd
|
||||
.command('use')
|
||||
.description('Сделать среду активной')
|
||||
.argument('<name>', 'имя среды')
|
||||
.action(async (name: string) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
if (!cfg.environments[name]) {
|
||||
throw new Error(`Среда «${name}» не описана. Сначала: blago env set ${name} …`)
|
||||
}
|
||||
const next: BlagoConfigFile = { ...cfg, activeEnv: name }
|
||||
await saveConfig(root, next)
|
||||
success(`Активная среда: ${name}`)
|
||||
})
|
||||
|
||||
envCmd
|
||||
.command('set')
|
||||
.description('Задать параметры среды')
|
||||
.argument('<name>', 'имя среды')
|
||||
.requiredOption('--api-url <url>', 'URL GraphQL, например http://127.0.0.1:2998/v1/graphql')
|
||||
.requiredOption('--chain-url <url>', 'URL ноды EOSIO')
|
||||
.option('--chain-id <id>', 'CHAIN_ID сети')
|
||||
.option('--label <text>', 'подпись для человека')
|
||||
.action(async (name: string, opts: { apiUrl: string, chainUrl: string, chainId?: string, label?: string }) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const prev: BlagoRemoteProfile = cfg.environments[name] ?? {
|
||||
api_url: '',
|
||||
chain_url: '',
|
||||
chain_id: '',
|
||||
}
|
||||
const profile: BlagoRemoteProfile = {
|
||||
api_url: opts.apiUrl,
|
||||
chain_url: opts.chainUrl,
|
||||
chain_id: opts.chainId ?? prev.chain_id ?? '',
|
||||
label: opts.label ?? prev.label,
|
||||
}
|
||||
const next: BlagoConfigFile = {
|
||||
...cfg,
|
||||
environments: { ...cfg.environments, [name]: profile },
|
||||
}
|
||||
await saveConfig(root, next)
|
||||
success(`Среда «${name}» обновлена`)
|
||||
})
|
||||
|
||||
envCmd.action(async () => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
info(describeBlagoSessionLine(cfg, readSessionUsernameSync(root, cfg.activeEnv)))
|
||||
info(`Среды в config: ${Object.keys(cfg.environments).sort().join(', ')}`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('docs')
|
||||
.description('Записать в корень копии BLAGO-FORMATS.md и BLAGO-COMMITS.md')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
await writeLlmDocs(root)
|
||||
success('Документы для LLM обновлены')
|
||||
})
|
||||
|
||||
program
|
||||
.command('skills')
|
||||
.description('Скиллы для агентов (Claude и др.)')
|
||||
.command('install')
|
||||
.description('Скопировать ~/.claude/skills/blago/ai/ из пакета (skills/cli/SKILL.md, commands/, …; каталог целиком)')
|
||||
.action(async () => {
|
||||
const { dest } = await runSkillsInstall()
|
||||
success(`Установлено: ${dest}`)
|
||||
})
|
||||
|
||||
try {
|
||||
await program.parseAsync(argvForCommander, { from: 'node' })
|
||||
}
|
||||
catch (e) {
|
||||
error(formatThrownValue(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
setBlagoCliExplicitStartDir(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Копирует каталог ai/ (skills/cli/SKILL.md, commands/, …) в ~/.claude/skills/blago/ai/ целиком.
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** dist/index.mjs → ../ai; dist/cmd/*.mjs → ../../ai; src/cmd/*.ts (stub) → ../../../ai */
|
||||
const AI_DIR_REL_TO_META = ['../ai', '../../ai', '../../../ai'] as const
|
||||
|
||||
function bundledAiDir(): string {
|
||||
for (const rel of AI_DIR_REL_TO_META) {
|
||||
const dir = fileURLToPath(new URL(rel, import.meta.url))
|
||||
const skillMd = path.join(dir, 'skills', 'cli', 'SKILL.md')
|
||||
try {
|
||||
fs.accessSync(skillMd)
|
||||
return dir
|
||||
}
|
||||
catch {
|
||||
/* следующий кандидат */
|
||||
}
|
||||
}
|
||||
const tried = AI_DIR_REL_TO_META.map(rel => fileURLToPath(new URL(rel, import.meta.url))).join('; ')
|
||||
throw new Error(
|
||||
`Не найден каталог ai/ пакета blago-cli (ожидается skills/cli/SKILL.md). Проверены пути: ${tried}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function runSkillsInstall(): Promise<{ source: string, dest: string }> {
|
||||
const source = bundledAiDir()
|
||||
const dest = path.join(os.homedir(), '.claude', 'skills', 'blago', 'ai')
|
||||
await fsp.rm(dest, { recursive: true, force: true })
|
||||
await fsp.mkdir(path.dirname(dest), { recursive: true })
|
||||
await fsp.cp(source, dest, { recursive: true })
|
||||
return { source, dest }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Desktop передаёт в SDK `BACKEND_URL + '/v1/graphql'`. В config blago ожидаем тот же базовый BACKEND_URL.
|
||||
*/
|
||||
export function toGraphqlApiUrl(apiUrl: string): string {
|
||||
const base = apiUrl.trim().replace(/\/+$/, '')
|
||||
if (base.length === 0) {
|
||||
return apiUrl.trim()
|
||||
}
|
||||
if (/\/v1\/graphql$/i.test(base) || /\/graphql$/i.test(base)) {
|
||||
return base
|
||||
}
|
||||
return `${base}/v1/graphql`
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// .blago/config.json: activeEnv, coopname, environments { api_url, chain_url, chain_id, coopname? }.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { blagoDir, CONFIG_FILE, configPath, gitignorePath } from './paths.js'
|
||||
|
||||
export type BlagoEnvironmentName = 'dev' | 'testnet' | 'production' | (string & {})
|
||||
|
||||
/**
|
||||
* Имя кооператива в шаблоне среды (новый config). Поменяйте per-env в .blago/config.json под каждую среду.
|
||||
*/
|
||||
export const DEFAULT_COOPNAME = 'voskhod'
|
||||
|
||||
export interface BlagoRemoteProfile {
|
||||
/** Базовый URL контроллера (как BACKEND_URL у desktop); без суффикса к пути добавится /v1/graphql. */
|
||||
readonly api_url: string
|
||||
readonly chain_url: string
|
||||
readonly chain_id: string
|
||||
readonly label?: string
|
||||
/** Short name кооператива для этой среды (фильтры Capital / pull). У разных сред может быть разное значение. */
|
||||
readonly coopname?: string
|
||||
}
|
||||
|
||||
export interface BlagoConfigFile {
|
||||
/** Имя активной среды */
|
||||
activeEnv: string
|
||||
/**
|
||||
* Запасной coopname, если у активной среды поле environments.<activeEnv>.coopname пустое (legacy / общий fallback).
|
||||
*/
|
||||
coopname?: string
|
||||
environments: Record<string, BlagoRemoteProfile>
|
||||
}
|
||||
|
||||
const DEFAULT_ENVS: Record<string, BlagoRemoteProfile> = {
|
||||
dev: {
|
||||
api_url: 'http://127.0.0.1:2998',
|
||||
chain_url: 'http://127.0.0.1:8888',
|
||||
chain_id: 'cae86058a6d8698833afb474ab8a5ad8599c6cf54f9ebcf275dbac7055c16fe1',
|
||||
label: 'Локальная разработка',
|
||||
coopname: DEFAULT_COOPNAME,
|
||||
},
|
||||
testnet: {
|
||||
api_url: 'https://testnet.coopenomics.world/backend',
|
||||
chain_url: 'https://testnet.coopenomics.world/api',
|
||||
chain_id: 'f0364a3f9fd913081f1c0b05c6f8f50a59b2ba60bb928cb321ba3a9a36316624',
|
||||
label: 'Testnet',
|
||||
coopname: DEFAULT_COOPNAME,
|
||||
},
|
||||
production: {
|
||||
api_url: 'https://лк.цифровой-кооператив.рф/backend',
|
||||
chain_url: 'https://лк.цифровой-кооператив.рф/api',
|
||||
chain_id: '6e37f9ac0f0ea717bfdbf57d1dd5d7f0e2d773227d9659a63bbf86eec0326c1b',
|
||||
label: 'Production',
|
||||
coopname: DEFAULT_COOPNAME,
|
||||
},
|
||||
}
|
||||
|
||||
export function defaultConfig(): BlagoConfigFile {
|
||||
return {
|
||||
activeEnv: 'dev',
|
||||
environments: { ...DEFAULT_ENVS },
|
||||
}
|
||||
}
|
||||
|
||||
/** Эффективный coopname: сначала у активной среды, иначе верхний уровень config (fallback). */
|
||||
export function resolveCoopname(cfg: BlagoConfigFile): string | undefined {
|
||||
const profile = cfg.environments[cfg.activeEnv]
|
||||
const fromEnv = profile?.coopname?.trim()
|
||||
if (fromEnv) {
|
||||
return fromEnv
|
||||
}
|
||||
return cfg.coopname?.trim() || undefined
|
||||
}
|
||||
|
||||
function environmentsWithOptionalCoopname(
|
||||
envs: Record<string, BlagoRemoteProfile>,
|
||||
coopname: string | undefined,
|
||||
): Record<string, BlagoRemoteProfile> {
|
||||
if (coopname === undefined) {
|
||||
return envs
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(envs).map(([name, profile]) => [name, { ...profile, coopname }]),
|
||||
) as Record<string, BlagoRemoteProfile>
|
||||
}
|
||||
|
||||
export async function loadConfig(root: string): Promise<BlagoConfigFile> {
|
||||
const raw = await fs.readFile(configPath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as BlagoConfigFile
|
||||
if (!parsed.activeEnv || typeof parsed.environments !== 'object') {
|
||||
throw new Error('Некорректный config.json: нужны activeEnv и environments')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function saveConfig(root: string, cfg: BlagoConfigFile): Promise<void> {
|
||||
await fs.mkdir(blagoDir(root), { recursive: true })
|
||||
await fs.writeFile(configPath(root), `${JSON.stringify(cfg, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
const BLAGO_SESSION_GITIGNORE_LINE = 'session.*.json'
|
||||
|
||||
const BLAGO_GITIGNORE_SNIPPET = `# Токены blago login — не коммитить в репозиторий
|
||||
${BLAGO_SESSION_GITIGNORE_LINE}
|
||||
`
|
||||
|
||||
/** Создаёт или дополняет .blago/.gitignore, чтобы не коммитить файлы сессии. */
|
||||
export async function ensureBlagoGitignore(root: string): Promise<void> {
|
||||
const p = gitignorePath(root)
|
||||
let existing = ''
|
||||
try {
|
||||
existing = await fs.readFile(p, 'utf8')
|
||||
}
|
||||
catch {
|
||||
/* файла ещё нет */
|
||||
}
|
||||
if (existing.includes(BLAGO_SESSION_GITIGNORE_LINE)) {
|
||||
return
|
||||
}
|
||||
const body = existing.trim().length > 0
|
||||
? `${existing.replace(/\s+$/, '')}\n\n${BLAGO_GITIGNORE_SNIPPET}`
|
||||
: BLAGO_GITIGNORE_SNIPPET
|
||||
await fs.writeFile(p, body.endsWith('\n') ? body : `${body}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export async function initBlagoWorkspace(
|
||||
root: string,
|
||||
options?: { coopname?: string, force?: boolean },
|
||||
): Promise<void> {
|
||||
const dir = blagoDir(root)
|
||||
const cfgFile = path.join(dir, CONFIG_FILE)
|
||||
if (!options?.force && (await fileExists(cfgFile))) {
|
||||
await ensureBlagoGitignore(root)
|
||||
return
|
||||
}
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const cfg = defaultConfig()
|
||||
const next: BlagoConfigFile = {
|
||||
...cfg,
|
||||
environments: environmentsWithOptionalCoopname(cfg.environments, options?.coopname),
|
||||
}
|
||||
await saveConfig(root, next)
|
||||
await ensureBlagoGitignore(root)
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveProfile(cfg: BlagoConfigFile): BlagoRemoteProfile {
|
||||
const profile = cfg.environments[cfg.activeEnv]
|
||||
if (!profile) {
|
||||
throw new Error(`Среда «${cfg.activeEnv}» не найдена в config.json`)
|
||||
}
|
||||
if (!profile.api_url?.trim() || !profile.chain_url?.trim()) {
|
||||
throw new Error(`У среды «${cfg.activeEnv}» задайте api_url и chain_url (blago env set …)`)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Пути под .blago/ и поиск корня по наличию config.json вверх по дереву.
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
export const BLAGO_DIR = '.blago'
|
||||
export const GITIGNORE_FILE = '.gitignore'
|
||||
export const CONFIG_FILE = 'config.json'
|
||||
export const INDEX_FILE = 'index.json'
|
||||
export const STAGING_FILE = 'staging.json'
|
||||
export const PENDING_CREATE_FILE = 'pending-create.json'
|
||||
|
||||
export function configPath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, CONFIG_FILE)
|
||||
}
|
||||
|
||||
export function indexPath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, INDEX_FILE)
|
||||
}
|
||||
|
||||
export function stagingPath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, STAGING_FILE)
|
||||
}
|
||||
|
||||
export function pendingCreatePath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, PENDING_CREATE_FILE)
|
||||
}
|
||||
|
||||
export function sessionPath(root: string, envName: string): string {
|
||||
return path.join(root, BLAGO_DIR, `session.${envName}.json`)
|
||||
}
|
||||
|
||||
export function blagoDir(root: string): string {
|
||||
return path.join(root, BLAGO_DIR)
|
||||
}
|
||||
|
||||
export function gitignorePath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, GITIGNORE_FILE)
|
||||
}
|
||||
|
||||
export function findBlagoRoot(startDir: string): string | null {
|
||||
let current = path.resolve(startDir)
|
||||
for (;;) {
|
||||
const candidate = configPath(current)
|
||||
if (fs.existsSync(candidate)) {
|
||||
return current
|
||||
}
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) {
|
||||
return null
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Базовый каталог для поиска .blago/ и относительных путей: --dir (в любом месте argv) > BLAGO_WORKSPACE > cwd.
|
||||
|
||||
import * as path from 'node:path'
|
||||
|
||||
let explicitDirFromArgv: string | null = null
|
||||
|
||||
/** Вызывается из runCli: снять --dir с argv до parse, чтобы подкоманды не падали на «unknown option». */
|
||||
export function peelBlagoDirFromArgv(argv: readonly string[]): { argv: string[], dir: string | null } {
|
||||
let dir: string | null = null
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === undefined) {
|
||||
continue
|
||||
}
|
||||
if (a === '--dir') {
|
||||
const next = argv[i + 1]
|
||||
if (next !== undefined && !next.startsWith('-')) {
|
||||
dir = next
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (a.startsWith('--dir=')) {
|
||||
const v = a.slice('--dir='.length).trim()
|
||||
dir = v.length > 0 ? v : null
|
||||
continue
|
||||
}
|
||||
out.push(a)
|
||||
}
|
||||
return { argv: out, dir }
|
||||
}
|
||||
|
||||
export function setBlagoCliExplicitStartDir(dir: string | null): void {
|
||||
explicitDirFromArgv = dir !== null && dir.trim().length > 0 ? path.resolve(dir.trim()) : null
|
||||
}
|
||||
|
||||
export function resolveBlagoStartDir(): string {
|
||||
if (explicitDirFromArgv !== null) {
|
||||
return explicitDirFromArgv
|
||||
}
|
||||
if (process.env.BLAGO_WORKSPACE) {
|
||||
return path.resolve(process.env.BLAGO_WORKSPACE)
|
||||
}
|
||||
return process.cwd()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Поиск project.md / component.md относительно базового пути (архитектура §13.2).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
import { normalizeRelativePath } from '../sync/index-store.js'
|
||||
|
||||
async function fileExists(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolvedProjectMarker {
|
||||
/** hash проекта/компонента из frontmarker. */
|
||||
readonly project_hash: string
|
||||
/** coopname из файла маркера (может быть пустым). */
|
||||
readonly coopnameFromFile: string
|
||||
/** Каталог с project.md или component.md, относительно корня копии. */
|
||||
readonly markerRelDir: string
|
||||
}
|
||||
|
||||
export async function resolveProjectMarker(root: string, basePathInput: string): Promise<ResolvedProjectMarker> {
|
||||
const normalized = normalizeRelativePath(basePathInput)
|
||||
const absInput = path.resolve(root, normalized)
|
||||
const rootAbs = path.resolve(root)
|
||||
|
||||
const stat = await fs.stat(absInput).catch(() => null)
|
||||
let searchDir = absInput
|
||||
if (stat?.isFile()) {
|
||||
const base = path.basename(absInput)
|
||||
if (base !== 'project.md' && base !== 'component.md') {
|
||||
throw new Error(
|
||||
`Ожидался каталог или файл project.md / component.md, получено: «${basePathInput}»`,
|
||||
)
|
||||
}
|
||||
searchDir = path.dirname(absInput)
|
||||
}
|
||||
else if (!stat?.isDirectory()) {
|
||||
throw new Error(`Путь не найден: «${basePathInput}»`)
|
||||
}
|
||||
|
||||
if (
|
||||
!(path.resolve(searchDir) === rootAbs
|
||||
|| path.resolve(searchDir).startsWith(rootAbs + path.sep))
|
||||
) {
|
||||
throw new Error('Базовый путь должен быть внутри корня рабочей копии blago')
|
||||
}
|
||||
|
||||
let current: string | null = searchDir
|
||||
for (let depth = 0; depth < 500 && current; depth += 1) {
|
||||
for (const name of ['component.md', 'project.md'] as const) {
|
||||
const f = path.join(current, name)
|
||||
if (await fileExists(f)) {
|
||||
const raw = await fs.readFile(f, 'utf8')
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'project') {
|
||||
continue
|
||||
}
|
||||
const project_hash = String(parsed.data.hash ?? '').trim()
|
||||
if (!project_hash) {
|
||||
throw new Error(`В «${path.relative(root, f)}» отсутствует hash проекта/компонента`)
|
||||
}
|
||||
const coopnameFromFile = String(parsed.data.coopname ?? '').trim()
|
||||
return {
|
||||
project_hash,
|
||||
coopnameFromFile,
|
||||
markerRelDir: normalizeRelativePath(path.relative(root, current)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (path.resolve(current) === rootAbs) {
|
||||
break
|
||||
}
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) {
|
||||
break
|
||||
}
|
||||
const parentRes = path.resolve(parent)
|
||||
if (!(parentRes === rootAbs || parentRes.startsWith(rootAbs + path.sep))) {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Не найден project.md или component.md. Укажите каталог проекта/компонента или выполните «blago pull».',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Локальное создание черновика задачи (FR-016).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import type { BlagoConfigFile } from '../config/index.js'
|
||||
import { resolveCoopname } from '../config/index.js'
|
||||
import { serializeBlagoMarkdown } from '../format/index.js'
|
||||
import { generateEntityHashHex64 } from '../lib/generate-entity-hash.js'
|
||||
import { loadSession } from '../session/index.js'
|
||||
import { appendPendingItem } from '../sync/pending-create.js'
|
||||
import { loadProjectMapsFromIndex } from '../sync/project-index-map.js'
|
||||
import { appendPathsToStaging, loadIndex, normalizeRelativePath } from '../sync/index-store.js'
|
||||
import { generateSlug, issueFileRelativePath, workspaceBasePath } from '../sync/layout.js'
|
||||
|
||||
import { resolveProjectMarker } from './resolve-base.js'
|
||||
import { buildDraftIssueFrontmatterAndBody } from './templates.js'
|
||||
|
||||
async function fileExistsAbs(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function parseCreatorsCsv(v: string | undefined): string[] {
|
||||
if (v === undefined || v.trim() === '') {
|
||||
return []
|
||||
}
|
||||
return v.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function pickIssueDraftRelativePath(
|
||||
root: string,
|
||||
title: string,
|
||||
basePath: string,
|
||||
pendingHash: string,
|
||||
): Promise<string> {
|
||||
const primary = issueFileRelativePath(title, basePath, '0')
|
||||
const absPrimary = path.join(root, primary)
|
||||
if (!(await fileExistsAbs(absPrimary))) {
|
||||
return normalizeRelativePath(primary)
|
||||
}
|
||||
const slug = generateSlug(title) || 'issue'
|
||||
const dir = `${basePath.replace(/\\/g, '/')}/issues`
|
||||
const alt = `${dir}/0-${slug}-${pendingHash.slice(0, 8)}.md`
|
||||
const absAlt = path.join(root, alt)
|
||||
if (!(await fileExistsAbs(absAlt))) {
|
||||
return normalizeRelativePath(alt)
|
||||
}
|
||||
throw new Error(`Не удалось подобрать свободное имя файла в «${dir}»`)
|
||||
}
|
||||
|
||||
export interface RunCreateIssueOptions {
|
||||
readonly setSelf?: boolean
|
||||
readonly creatorsCsv?: string
|
||||
readonly submaster?: string
|
||||
}
|
||||
|
||||
export async function runCreateIssue(
|
||||
root: string,
|
||||
cfg: BlagoConfigFile,
|
||||
basePathArg: string,
|
||||
title: string,
|
||||
options: RunCreateIssueOptions,
|
||||
): Promise<{ relativePath: string }> {
|
||||
const coopCfg = resolveCoopname(cfg)
|
||||
if (!coopCfg) {
|
||||
throw new Error('Задайте coopname в config активной среды (или «blago init --coopname»)')
|
||||
}
|
||||
|
||||
const index = await loadIndex(root)
|
||||
const { projectByHash, projectRowByHash } = await loadProjectMapsFromIndex(root, index)
|
||||
const marker = await resolveProjectMarker(root, basePathArg)
|
||||
const proj = projectByHash.get(marker.project_hash)
|
||||
if (!proj) {
|
||||
throw new Error(
|
||||
`Проект «${marker.project_hash}» не найден в индексе. Выполните «blago pull» и повторите create.`,
|
||||
)
|
||||
}
|
||||
|
||||
const fromCsv = parseCreatorsCsv(options.creatorsCsv)
|
||||
let creators: string[]
|
||||
if (options.setSelf) {
|
||||
const session = await loadSession(root, cfg.activeEnv)
|
||||
if (!session) {
|
||||
throw new Error(
|
||||
'Для --set-self нужна сохранённая сессия активной среды. Выполните «blago login».',
|
||||
)
|
||||
}
|
||||
const u = session.username.trim()
|
||||
creators = [u, ...fromCsv.filter(c => c !== u)]
|
||||
}
|
||||
else {
|
||||
creators = fromCsv
|
||||
}
|
||||
|
||||
const pendingHash = generateEntityHashHex64()
|
||||
const { data, body } = buildDraftIssueFrontmatterAndBody({
|
||||
title,
|
||||
pendingHashHex64: pendingHash,
|
||||
project_hash: marker.project_hash,
|
||||
projectRowByHash,
|
||||
creators,
|
||||
submaster: options.submaster,
|
||||
})
|
||||
|
||||
const wsBase = workspaceBasePath(proj, projectByHash)
|
||||
const rel = await pickIssueDraftRelativePath(root, title, wsBase, pendingHash)
|
||||
const abs = path.join(root, rel)
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
await fs.writeFile(abs, serializeBlagoMarkdown(data, body), 'utf8')
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
await appendPendingItem(root, {
|
||||
kind: 'issue',
|
||||
entity_hash: pendingHash,
|
||||
relative_path: rel,
|
||||
created_at: createdAt,
|
||||
})
|
||||
await appendPathsToStaging(root, [rel])
|
||||
return { relativePath: rel }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Локальное создание черновика требования (story) (FR-016).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import type { BlagoConfigFile } from '../config/index.js'
|
||||
import { resolveCoopname } from '../config/index.js'
|
||||
import { serializeBlagoMarkdown } from '../format/index.js'
|
||||
import { generateEntityHashHex64 } from '../lib/generate-entity-hash.js'
|
||||
import { loadSession } from '../session/index.js'
|
||||
import { appendPendingItem } from '../sync/pending-create.js'
|
||||
import { loadProjectMapsFromIndex } from '../sync/project-index-map.js'
|
||||
import { appendPathsToStaging, loadIndex, normalizeRelativePath } from '../sync/index-store.js'
|
||||
import { storyRequirementIdPrefix2 } from '../lib/capital-id-path.js'
|
||||
import { generateSlug, storyFileRelativePath, workspaceBasePath } from '../sync/layout.js'
|
||||
|
||||
import { resolveProjectMarker } from './resolve-base.js'
|
||||
import { storyContentFormatFromCliOption } from './story-format.js'
|
||||
import { buildDraftStoryFrontmatterAndBody } from './templates.js'
|
||||
|
||||
async function fileExistsAbs(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pickStoryDraftRelativePath(
|
||||
root: string,
|
||||
title: string,
|
||||
basePath: string,
|
||||
storyHash: string,
|
||||
): Promise<string> {
|
||||
const primary = storyFileRelativePath(title, basePath, '', storyHash, undefined)
|
||||
const absPrimary = path.join(root, primary)
|
||||
if (!(await fileExistsAbs(absPrimary))) {
|
||||
return normalizeRelativePath(primary)
|
||||
}
|
||||
const slug = generateSlug(title) || 'requirement'
|
||||
const p2 = storyRequirementIdPrefix2('', storyHash)
|
||||
const dir = `${basePath.replace(/\\/g, '/')}/requirements`
|
||||
const alt = `${dir}/${p2}-${slug}-${storyHash.slice(0, 6)}.md`
|
||||
const absAlt = path.join(root, alt)
|
||||
if (!(await fileExistsAbs(absAlt))) {
|
||||
return normalizeRelativePath(alt)
|
||||
}
|
||||
throw new Error(`Не удалось подобрать свободное имя файла в «${dir}»`)
|
||||
}
|
||||
|
||||
export interface RunCreateStoryOptions {
|
||||
readonly setSelf?: boolean
|
||||
/** markdown | mermaid | drawio | bpmn */
|
||||
readonly format?: string
|
||||
}
|
||||
|
||||
export async function runCreateStory(
|
||||
root: string,
|
||||
cfg: BlagoConfigFile,
|
||||
basePathArg: string,
|
||||
title: string,
|
||||
options: RunCreateStoryOptions,
|
||||
): Promise<{ relativePath: string }> {
|
||||
const coopCfg = resolveCoopname(cfg)
|
||||
if (!coopCfg) {
|
||||
throw new Error('Задайте coopname в config активной среды (или «blago init --coopname»)')
|
||||
}
|
||||
|
||||
const index = await loadIndex(root)
|
||||
const { projectByHash } = await loadProjectMapsFromIndex(root, index)
|
||||
const marker = await resolveProjectMarker(root, basePathArg)
|
||||
const proj = projectByHash.get(marker.project_hash)
|
||||
if (!proj) {
|
||||
throw new Error(
|
||||
`Проект «${marker.project_hash}» не найден в индексе. Выполните «blago pull» и повторите create.`,
|
||||
)
|
||||
}
|
||||
|
||||
let created_by: string | undefined
|
||||
if (options.setSelf) {
|
||||
const session = await loadSession(root, cfg.activeEnv)
|
||||
if (!session) {
|
||||
throw new Error(
|
||||
'Для --set-self нужна сохранённая сессия активной среды. Выполните «blago login».',
|
||||
)
|
||||
}
|
||||
created_by = session.username
|
||||
}
|
||||
|
||||
const content_format = storyContentFormatFromCliOption(options.format ?? 'markdown')
|
||||
const storyHash = generateEntityHashHex64()
|
||||
const { data, body } = buildDraftStoryFrontmatterAndBody({
|
||||
title,
|
||||
story_hash: storyHash,
|
||||
project_hash: marker.project_hash,
|
||||
content_format,
|
||||
created_by,
|
||||
})
|
||||
|
||||
const wsBase = workspaceBasePath(proj, projectByHash)
|
||||
const rel = await pickStoryDraftRelativePath(root, title, wsBase, storyHash)
|
||||
const abs = path.join(root, rel)
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
await fs.writeFile(abs, serializeBlagoMarkdown(data, body), 'utf8')
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
await appendPendingItem(root, {
|
||||
kind: 'story',
|
||||
entity_hash: storyHash,
|
||||
relative_path: rel,
|
||||
created_at: createdAt,
|
||||
})
|
||||
await appendPathsToStaging(root, [rel])
|
||||
return { relativePath: rel }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Значения CapitalStoryContentFormat в frontmatter (MARKDOWN, …). */
|
||||
|
||||
export function storyContentFormatFromCliOption(s: string): string {
|
||||
const k = s.trim().toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
markdown: 'MARKDOWN',
|
||||
mermaid: 'MERMAID',
|
||||
drawio: 'DRAWIO',
|
||||
bpmn: 'BPMN',
|
||||
}
|
||||
const v = map[k]
|
||||
if (!v) {
|
||||
throw new Error(
|
||||
`Неизвестный --format «${s}». Допустимо: markdown, mermaid, drawio, bpmn`,
|
||||
)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Минимальный BPMN 2.0 (стартовое событие + DI), как в bpmn.io. */
|
||||
export const storyStubBpmn = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Coopenomics" exporterVersion="1.0">
|
||||
<bpmn:process id="Process_1" isExecutable="true">
|
||||
<bpmn:startEvent id="StartEvent_1" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1">
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="159" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
||||
`
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Минимальная схема draw.io / diagrams.net (одна фигура «Старт»). */
|
||||
export const storyStubDrawio = `<mxfile host="app.diagrams.net" agent="blago-cli" version="22.1.0">
|
||||
<diagram name="Страница-1" id="stub-page">
|
||||
<mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="2" value="Старт" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="360" y="240" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
`
|
||||
@@ -0,0 +1,22 @@
|
||||
// Рыбы тел story по content_format (Capital) — чтобы черновик сразу отображался в UI.
|
||||
|
||||
import { storyStubBpmn } from './bpmn.js'
|
||||
import { storyStubDrawio } from './drawio.js'
|
||||
import { storyStubMarkdown } from './markdown.js'
|
||||
import { storyStubMermaid } from './mermaid.js'
|
||||
|
||||
const STUB_BY_FORMAT: Record<'MARKDOWN' | 'MERMAID' | 'DRAWIO' | 'BPMN', string> = {
|
||||
MARKDOWN: storyStubMarkdown,
|
||||
MERMAID: storyStubMermaid,
|
||||
DRAWIO: storyStubDrawio,
|
||||
BPMN: storyStubBpmn,
|
||||
}
|
||||
|
||||
export function storyStubBodyForFormat(contentFormat: string): string {
|
||||
const key = contentFormat as keyof typeof STUB_BY_FORMAT
|
||||
const body = STUB_BY_FORMAT[key]
|
||||
if (body === undefined) {
|
||||
throw new Error(`Нет рыбы для content_format «${contentFormat}»`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Минимальное тело MARKDOWN: корректный заголовок и место для текста. */
|
||||
export const storyStubMarkdown = `# Новое требование`
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Минимальная диаграмма MERMAID (отображается в превью). */
|
||||
export const storyStubMermaid = `flowchart TD
|
||||
A[Старт]
|
||||
`
|
||||
@@ -0,0 +1,79 @@
|
||||
// Черновики frontmatter для локального create (без API).
|
||||
|
||||
import type { ProjectRowLite } from '../sync/project-index-map.js'
|
||||
import { issueWorkspaceTitlesFromProjects } from '../format/index.js'
|
||||
import { storyStubBodyForFormat } from './story-stubs/index.js'
|
||||
|
||||
export interface DraftIssueFields {
|
||||
readonly title: string
|
||||
readonly pendingHashHex64: string
|
||||
readonly project_hash: string
|
||||
readonly projectRowByHash: Map<string, ProjectRowLite>
|
||||
readonly creators: string[]
|
||||
readonly submaster?: string
|
||||
}
|
||||
|
||||
export function buildDraftIssueFrontmatterAndBody(f: DraftIssueFields): {
|
||||
data: Record<string, unknown>
|
||||
body: string
|
||||
} {
|
||||
const workspace = issueWorkspaceTitlesFromProjects(f.project_hash, f.projectRowByHash)
|
||||
const now = new Date().toISOString()
|
||||
const data: Record<string, unknown> = {
|
||||
type: 'issue',
|
||||
id: '0',
|
||||
title: f.title,
|
||||
status: 'BACKLOG',
|
||||
priority: 'MEDIUM',
|
||||
estimate: 0,
|
||||
creators: f.creators,
|
||||
labels: [],
|
||||
hash: f.pendingHashHex64,
|
||||
project_hash: f.project_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
if (workspace.project_title.trim() !== '') {
|
||||
data.project_title = workspace.project_title.trim()
|
||||
}
|
||||
if (workspace.component_title !== undefined && workspace.component_title.trim() !== '') {
|
||||
data.component_title = workspace.component_title.trim()
|
||||
}
|
||||
if (f.submaster !== undefined && f.submaster.trim() !== '') {
|
||||
data.submaster = f.submaster.trim()
|
||||
}
|
||||
return { data, body: '' }
|
||||
}
|
||||
|
||||
export interface DraftStoryFields {
|
||||
readonly title: string
|
||||
readonly story_hash: string
|
||||
readonly project_hash: string
|
||||
readonly content_format: string
|
||||
readonly created_by?: string
|
||||
/** Явное тело; иначе подставляется рыба под content_format (см. create/story-stubs/). */
|
||||
readonly body?: string
|
||||
}
|
||||
|
||||
export function buildDraftStoryFrontmatterAndBody(f: DraftStoryFields): {
|
||||
data: Record<string, unknown>
|
||||
body: string
|
||||
} {
|
||||
const now = new Date().toISOString()
|
||||
const data: Record<string, unknown> = {
|
||||
type: 'story',
|
||||
title: f.title,
|
||||
hash: f.story_hash,
|
||||
content_format: f.content_format,
|
||||
status: 'PENDING',
|
||||
sort_order: 0,
|
||||
project_hash: f.project_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
if (f.created_by !== undefined && f.created_by.trim() !== '') {
|
||||
data.created_by = f.created_by.trim()
|
||||
}
|
||||
const body = f.body ?? storyStubBodyForFormat(f.content_format)
|
||||
return { data, body }
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Frontmatter ↔ тело; сериализация как у Capital FileFormatService.
|
||||
|
||||
import matter from 'gray-matter'
|
||||
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
|
||||
export type EntityFrontmatterType = 'project' | 'issue' | 'story'
|
||||
|
||||
export interface ParsedBlagoFile {
|
||||
readonly type: EntityFrontmatterType
|
||||
readonly data: Record<string, unknown>
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
function toIso(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
|
||||
const file = matter(raw)
|
||||
const typeRaw = file.data.type
|
||||
if (typeRaw !== 'project' && typeRaw !== 'issue' && typeRaw !== 'story') {
|
||||
throw new Error(`Неизвестный или отсутствующий type во frontmatter: ${String(typeRaw)}`)
|
||||
}
|
||||
return {
|
||||
type: typeRaw,
|
||||
data: file.data as Record<string, unknown>,
|
||||
body: file.content.trimEnd(),
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
|
||||
const trimmed = body.endsWith('\n') ? body : `${body}\n`
|
||||
return matter.stringify(trimmed, data)
|
||||
}
|
||||
|
||||
export function issueLabelsFromMetadata(metadata: unknown): string[] {
|
||||
if (metadata && typeof metadata === 'object' && metadata !== null && 'labels' in metadata) {
|
||||
const labels = (metadata as { labels?: unknown }).labels
|
||||
if (Array.isArray(labels)) {
|
||||
return labels.map(x => String(x)).filter(s => s.trim() !== '')
|
||||
}
|
||||
}
|
||||
if (typeof metadata === 'string' && metadata.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata) as { labels?: unknown }
|
||||
if (Array.isArray(parsed.labels)) {
|
||||
return parsed.labels.map(x => String(x)).filter(s => s.trim() !== '')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Цепочка родителей: [проект задачи, …, корневой проект] — для подписей в issue. */
|
||||
export function issueWorkspaceTitlesFromProjects(
|
||||
issueProjectHash: string,
|
||||
rowByHash: ReadonlyMap<string, { title?: string | null, parent_hash?: string | null }>,
|
||||
): { project_title: string, component_title?: string } {
|
||||
const titles: string[] = []
|
||||
let cur: string | undefined = issueProjectHash
|
||||
const seen = new Set<string>()
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur)
|
||||
const row = rowByHash.get(cur)
|
||||
if (!row) {
|
||||
break
|
||||
}
|
||||
titles.push(String(row.title ?? '').trim())
|
||||
const parentRef = effectiveParentHash(row.parent_hash)
|
||||
cur = parentRef || undefined
|
||||
}
|
||||
const project_title = titles.length > 0 ? titles[titles.length - 1] : ''
|
||||
const component_title = titles.length > 1 ? titles[0] : undefined
|
||||
return {
|
||||
project_title,
|
||||
...(component_title !== undefined && component_title !== '' ? { component_title } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Проект из ответа capitalProjects / capitalProject */
|
||||
export function projectToFrontmatterAndBody(project: {
|
||||
id?: number | null
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
project_hash: string
|
||||
coopname: string
|
||||
parent_hash?: string | null
|
||||
parent_title?: string | null
|
||||
status?: string | null
|
||||
created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}): { data: Record<string, unknown>, body: string } {
|
||||
const data: Record<string, unknown> = {}
|
||||
data.type = 'project'
|
||||
if (project.id !== undefined && project.id !== null && Number.isFinite(Number(project.id))) {
|
||||
data.id = Number(project.id)
|
||||
}
|
||||
data.title = project.title ?? ''
|
||||
const parentRef = effectiveParentHash(project.parent_hash)
|
||||
if (parentRef) {
|
||||
const pt = project.parent_title
|
||||
if (pt !== undefined && pt !== null && String(pt).trim() !== '') {
|
||||
data.parent_title = String(pt).trim()
|
||||
}
|
||||
data.parent_hash = parentRef
|
||||
}
|
||||
data.coopname = project.coopname
|
||||
data.status = project.status ?? undefined
|
||||
data.hash = project.project_hash
|
||||
const c = toIso(project.created_at)
|
||||
if (c) {
|
||||
data.created_at = c
|
||||
}
|
||||
const u = toIso(project._updated_at)
|
||||
if (u) {
|
||||
data.updated_at = u
|
||||
}
|
||||
return { data, body: project.description ?? '' }
|
||||
}
|
||||
|
||||
export function issueToFrontmatterAndBody(
|
||||
issue: {
|
||||
id?: string | null
|
||||
title: string
|
||||
description?: string | null
|
||||
issue_hash: string
|
||||
project_hash: string
|
||||
cycle_id?: string | null
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
estimate?: number | null
|
||||
submaster?: string | null
|
||||
creators?: string[] | null
|
||||
metadata?: unknown
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
},
|
||||
workspace: { project_title: string, component_title?: string },
|
||||
): { data: Record<string, unknown>, body: string } {
|
||||
const data: Record<string, unknown> = {}
|
||||
data.type = 'issue'
|
||||
const capitalIssueId
|
||||
= issue.id !== undefined && issue.id !== null && String(issue.id).trim() !== ''
|
||||
? String(issue.id).trim()
|
||||
: issue.issue_hash
|
||||
data.id = capitalIssueId
|
||||
data.title = issue.title
|
||||
if (workspace.project_title.trim() !== '') {
|
||||
data.project_title = workspace.project_title.trim()
|
||||
}
|
||||
if (workspace.component_title !== undefined && workspace.component_title.trim() !== '') {
|
||||
data.component_title = workspace.component_title.trim()
|
||||
}
|
||||
data.status = issue.status ?? 'backlog'
|
||||
data.priority = issue.priority ?? 'medium'
|
||||
data.estimate = issue.estimate ?? 0
|
||||
data.creators = issue.creators ?? []
|
||||
data.labels = issueLabelsFromMetadata(issue.metadata)
|
||||
if (issue.cycle_id) {
|
||||
data.cycle_id = issue.cycle_id
|
||||
}
|
||||
if (issue.submaster) {
|
||||
data.submaster = issue.submaster
|
||||
}
|
||||
data.hash = issue.issue_hash
|
||||
data.project_hash = issue.project_hash
|
||||
const c = toIso(issue._created_at)
|
||||
if (c) {
|
||||
data.created_at = c
|
||||
}
|
||||
const u = toIso(issue._updated_at)
|
||||
if (u) {
|
||||
data.updated_at = u
|
||||
}
|
||||
return { data, body: issue.description ?? '' }
|
||||
}
|
||||
|
||||
export function storyToFrontmatterAndBody(story: {
|
||||
_id?: string | null
|
||||
title: string
|
||||
description?: string | null
|
||||
story_hash: string
|
||||
content_format?: string | null
|
||||
status?: string | null
|
||||
created_by?: string | null
|
||||
sort_order?: number | null
|
||||
project_hash?: string | null
|
||||
issue_hash?: string | null
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}): { data: Record<string, unknown>, body: string } {
|
||||
const data: Record<string, unknown> = {}
|
||||
data.type = 'story'
|
||||
if (story._id !== undefined && story._id !== null && String(story._id).trim() !== '') {
|
||||
data.id = String(story._id)
|
||||
}
|
||||
data.title = story.title
|
||||
data.hash = story.story_hash
|
||||
data.content_format = story.content_format ?? 'MARKDOWN'
|
||||
data.status = story.status ?? 'pending'
|
||||
data.created_by = story.created_by ?? ''
|
||||
data.sort_order = story.sort_order ?? 0
|
||||
if (story.project_hash) {
|
||||
data.project_hash = story.project_hash
|
||||
}
|
||||
if (story.issue_hash) {
|
||||
data.issue_hash = story.issue_hash
|
||||
}
|
||||
const c = toIso(story._created_at)
|
||||
if (c) {
|
||||
data.created_at = c
|
||||
}
|
||||
const u = toIso(story._updated_at)
|
||||
if (u) {
|
||||
data.updated_at = u
|
||||
}
|
||||
return { data, body: story.description ?? '' }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
// Точка входа бинарника → runCli.
|
||||
|
||||
import { runCli } from './cmd/run-cli.js'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await runCli(process.argv)
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Префикс сегмента пути из Capital id (число проекта или строка задачи вида PREFIX-12). */
|
||||
export function capitalIdPathPrefix(capitalId: string | number): string {
|
||||
const s = String(capitalId).trim()
|
||||
if (s === '') {
|
||||
return '0'
|
||||
}
|
||||
const t = s.replace(/[^\w.-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
return t.length > 0 ? t : 'id'
|
||||
}
|
||||
|
||||
/**
|
||||
* Два символа в начале имени файла требования (как короткий префикс id у задач):
|
||||
* из букв/цифр `_id`; если меньше 2 — добираем из story_hash (только запасной пул символов).
|
||||
*/
|
||||
export function storyRequirementIdPrefix2(recordId: string, storyHashFallback: string): string {
|
||||
const alnum = (s: string) => s.replace(/[^a-z0-9]/gi, '').toLowerCase()
|
||||
let pool = alnum(recordId)
|
||||
if (pool.length < 2) {
|
||||
pool = `${pool}${alnum(storyHashFallback)}`
|
||||
}
|
||||
if (pool.length >= 2) {
|
||||
return pool.slice(0, 2)
|
||||
}
|
||||
return `${pool}00`.slice(0, 2)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 64 hex — как generateUniqueHash на desktop / канон Capital для story_hash.
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export function generateEntityHashHex64(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Эталон содержимого файла в индексе (content_etag_local).
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export function sha256Hex(content: string): string {
|
||||
return createHash('sha256').update(content, 'utf8').digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* В Capital родитель у корневого проекта часто приходит как null/«» или нулевой checksum256 (64׫0»).
|
||||
* Такая строка truthy в JS — без нормализации сущность ошибочно считается вложенным компонентом.
|
||||
*/
|
||||
export function effectiveParentHash(parent_hash: string | null | undefined): string | undefined {
|
||||
if (parent_hash === undefined || parent_hash === null) {
|
||||
return undefined
|
||||
}
|
||||
const t = String(parent_hash).trim()
|
||||
if (t === '') {
|
||||
return undefined
|
||||
}
|
||||
if (/^0+$/.test(t)) {
|
||||
return undefined
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// BLAGO-FORMATS.md / BLAGO-COMMITS.md в корень копии.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
const FORMATS = `# Форматы файлов blago-cli
|
||||
|
||||
Синхронизируются типы: **project**, **issue**, **story**. Тип **result** через CLI не синхронизируется.
|
||||
|
||||
Все файлы — Markdown с YAML frontmatter между тройными дефисами (---) в первой строке файла. Поле **hash** — стабильный идентификатор сущности; **не менять вручную**.
|
||||
|
||||
## project (каталог '<capital_id>-<slug>/project.md' или '…/components/<capital_id>-<slug>/component.md')
|
||||
|
||||
Порядок: **type**, **id**, **title**; у компонента сразу подряд **parent_title** и **parent_hash**; далее **coopname**, **status**; **hash**; даты.
|
||||
|
||||
- **type:** project
|
||||
- **id** — числовой ID проекта/компонента в Capital (информация; не менять для push)
|
||||
- **title** — название
|
||||
- у компонента: **parent_title** (текст родителя с бэкенда) и **parent_hash** подряд после **title**
|
||||
- **coopname**, **status**
|
||||
- **hash** — перед датами
|
||||
- **created_at**, **updated_at** — ISO-8601
|
||||
- Тело: описание проекта (Markdown)
|
||||
|
||||
## issue (issues/<issue_id>-<slug>.md — уникальность по человекочитаемому id задачи)
|
||||
|
||||
Порядок: **type**, **id**, **title** (название задачи), затем **project_title** / **component_title**; далее **status**, **priority**, **estimate**, **creators**, **labels**, **cycle_id**, **submaster**; внизу **hash** и **project_hash** перед датами.
|
||||
|
||||
- **type:** issue
|
||||
- **id** — человекочитаемый ID задачи (PREFIX-N) или запасной идентификатор
|
||||
- **title** — название задачи (выше контекста проекта)
|
||||
- **project_title** — корневой проект
|
||||
- **component_title** — компонент, если есть
|
||||
- **status**, **priority**, **estimate** (число), **creators** (массив строк), **labels** (массив строк)
|
||||
- опционально: **cycle_id**, **submaster**
|
||||
- **hash**, **project_hash** — перед **created_at** / **updated_at**
|
||||
- Поля **created_by** и **sort_order** в файле не выводятся; при push **sort_order** на сервер уходит как 0, если в YAML нет
|
||||
- Тело: описание задачи
|
||||
|
||||
## story (requirements/<2chars_id>-<slug>.md или issues/<issue_id>-<issueSlug>-requirements/<2chars_id>-<slug>.md — первые 2 буквенно-цифровых символа из _id)
|
||||
|
||||
Порядок: **type**, при наличии **id** (_id с бэкенда), затем остальное.
|
||||
|
||||
- **type:** story
|
||||
- **id** — внутренний '_id' записи требования в Capital (строка), если есть
|
||||
- **title**, **hash**, **content_format** (например MARKDOWN), **status**, **created_by**, **sort_order**
|
||||
- **project_hash** и/или **issue_hash**
|
||||
- Тело: текст требования
|
||||
|
||||
После правок: **blago add** добавляет в staging только **изменённые** относительно индекса .md (и файлы без записи в индексе), затем **blago push**. Просмотр отличий от эталона индекса: blago diff; краткий статус: blago status.
|
||||
`
|
||||
|
||||
const COMMITS = `# Сообщения коммитов в репозитории кода (FR-012)
|
||||
|
||||
В **прикладном** Git-репозитории с кодом указывайте в сообщении коммита как минимум:
|
||||
|
||||
1. **username** участника (блокчейн-аккаунт / логин в кооперативе)
|
||||
2. **hash задачи** из frontmatter файла задачи (поле **hash** в issue)
|
||||
|
||||
Расширенный вариант (до четырёх параметров): username, проект, компонент, задача — для однозначной трассировки.
|
||||
|
||||
Примеры:
|
||||
|
||||
- ant CAPITAL-42 правка API оплат
|
||||
- ant | проект blago-cli | задача CAPITAL-12 | фикс валидации
|
||||
|
||||
Username берите из контекста входа; hash задачи — из выгруженного файла issues/…md.
|
||||
`
|
||||
|
||||
export async function writeLlmDocs(root: string): Promise<void> {
|
||||
await fs.writeFile(path.join(root, 'BLAGO-FORMATS.md'), FORMATS, 'utf8')
|
||||
await fs.writeFile(path.join(root, 'BLAGO-COMMITS.md'), COMMITS, 'utf8')
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Сессия: login (email+WIF), токены в .blago/session.<env>.json, refresh при 401.
|
||||
|
||||
import type { ReadStream } from 'node:tty'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as readline from 'node:readline'
|
||||
|
||||
import { Client, Mutations, Queries } from '@coopenomics/sdk'
|
||||
|
||||
import { toGraphqlApiUrl } from '../config/api-url.js'
|
||||
import { type BlagoConfigFile, type BlagoRemoteProfile, getActiveProfile } from '../config/index.js'
|
||||
import { sessionPath } from '../config/paths.js'
|
||||
|
||||
export interface BlagoSessionFile {
|
||||
readonly accessToken: string
|
||||
readonly refreshToken: string
|
||||
readonly username: string
|
||||
}
|
||||
|
||||
export async function promptLine(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptSecret(question: string): Promise<string> {
|
||||
const stdin = process.stdin as ReadStream
|
||||
const stdout = process.stdout
|
||||
if (!stdin.isTTY) {
|
||||
throw new Error('Скрытый ввод ключа возможен только в интерактивном TTY')
|
||||
}
|
||||
stdout.write(question)
|
||||
|
||||
let onData: ((chunk: string | Buffer) => void) | undefined
|
||||
|
||||
try {
|
||||
stdin.setRawMode(true)
|
||||
stdin.resume()
|
||||
stdin.setEncoding('utf8')
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let result = ''
|
||||
onData = (chunk: string | Buffer): void => {
|
||||
const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
||||
for (const ch of s) {
|
||||
const code = ch.charCodeAt(0)
|
||||
if (code === 3) {
|
||||
stdout.write('\n')
|
||||
reject(new Error('прервано'))
|
||||
return
|
||||
}
|
||||
if (code === 13 || code === 10) {
|
||||
stdout.write('\n')
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
if (code === 127 || code === 8) {
|
||||
result = result.slice(0, -1)
|
||||
}
|
||||
else {
|
||||
result += ch
|
||||
}
|
||||
}
|
||||
}
|
||||
stdin.on('data', onData)
|
||||
})
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
stdin.setRawMode(false)
|
||||
}
|
||||
catch {
|
||||
/* Windows или stdin уже не TTY */
|
||||
}
|
||||
if (onData) {
|
||||
stdin.off('data', onData)
|
||||
}
|
||||
stdin.pause()
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSession(root: string, envName: string, data: BlagoSessionFile): Promise<void> {
|
||||
const p = sessionPath(root, envName)
|
||||
await fs.writeFile(p, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
||||
try {
|
||||
await fs.chmod(p, 0o600)
|
||||
}
|
||||
catch {
|
||||
/* Windows и др. */
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSession(root: string, envName: string): Promise<BlagoSessionFile | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(sessionPath(root, envName), 'utf8')
|
||||
const parsed = JSON.parse(raw) as BlagoSessionFile
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.username) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createClient(profile: BlagoRemoteProfile): Client {
|
||||
return Client.create({
|
||||
api_url: toGraphqlApiUrl(profile.api_url),
|
||||
chain_url: profile.chain_url,
|
||||
chain_id: profile.chain_id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function loginInteractive(
|
||||
client: Client,
|
||||
root: string,
|
||||
envName: string,
|
||||
): Promise<BlagoSessionFile> {
|
||||
const email = await promptLine('Email (логин в кооперативе): ')
|
||||
if (!email) {
|
||||
throw new Error('Email не может быть пустым')
|
||||
}
|
||||
const wif = await promptSecret('Приватный ключ (WIF), ввод скрыт: ')
|
||||
if (!wif) {
|
||||
throw new Error('Ключ не может быть пустым')
|
||||
}
|
||||
const loginResult = await client.login(email, wif)
|
||||
const access = loginResult.tokens.access.token
|
||||
const refresh = loginResult.tokens.refresh.token
|
||||
const username = loginResult.account.username
|
||||
const session: BlagoSessionFile = { accessToken: access, refreshToken: refresh, username }
|
||||
await saveSession(root, envName, session)
|
||||
return session
|
||||
}
|
||||
|
||||
export async function applySession(client: Client, session: BlagoSessionFile): Promise<void> {
|
||||
client.setToken(session.accessToken)
|
||||
}
|
||||
|
||||
export async function refreshSession(client: Client, session: BlagoSessionFile): Promise<BlagoSessionFile> {
|
||||
const { [Mutations.Auth.Refresh.name]: result } = await client.Mutation(Mutations.Auth.Refresh.mutation, {
|
||||
variables: {
|
||||
data: {
|
||||
access_token: session.accessToken,
|
||||
refresh_token: session.refreshToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
const next: BlagoSessionFile = {
|
||||
accessToken: result.tokens.access.token,
|
||||
refreshToken: result.tokens.refresh.token,
|
||||
username: result.account.username,
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export interface AuthenticatedContext {
|
||||
readonly root: string
|
||||
readonly config: BlagoConfigFile
|
||||
readonly client: Client
|
||||
readonly session: BlagoSessionFile
|
||||
}
|
||||
|
||||
// Токен из файла → проверка запросом; при сбое — refresh или интерактивный login.
|
||||
export async function ensureAuthenticatedContext(
|
||||
root: string,
|
||||
cfg: BlagoConfigFile,
|
||||
options?: { allowInteractiveLogin?: boolean },
|
||||
): Promise<AuthenticatedContext> {
|
||||
const profile = getActiveProfile(cfg)
|
||||
const client = createClient(profile)
|
||||
let session = await loadSession(root, cfg.activeEnv)
|
||||
if (!session) {
|
||||
if (!options?.allowInteractiveLogin) {
|
||||
throw new Error('Нет сохранённой сессии. Выполните: blago login')
|
||||
}
|
||||
session = await loginInteractive(client, root, cfg.activeEnv)
|
||||
}
|
||||
await applySession(client, session)
|
||||
try {
|
||||
await client.Query(Queries.Desktop.GetDesktop.query, { variables: {} })
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
session = await refreshSession(client, session)
|
||||
await applySession(client, session)
|
||||
await saveSession(root, cfg.activeEnv, session)
|
||||
}
|
||||
catch {
|
||||
if (!options?.allowInteractiveLogin) {
|
||||
throw new Error('Сессия устарела. Выполните: blago login')
|
||||
}
|
||||
session = await loginInteractive(client, root, cfg.activeEnv)
|
||||
await applySession(client, session)
|
||||
}
|
||||
}
|
||||
return { root, config: cfg, client, session }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Синхронное описание активной среды и сессии (для help Commander и «blago env»).
|
||||
|
||||
import type { BlagoConfigFile } from '../config/index.js'
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { configPath, findBlagoRoot, sessionPath } from '../config/paths.js'
|
||||
import { resolveBlagoStartDir } from '../config/start-dir.js'
|
||||
|
||||
interface SessionFileShape {
|
||||
username?: string
|
||||
}
|
||||
|
||||
function readJsonIfExists<T>(filePath: string): T | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8')
|
||||
return JSON.parse(raw) as T
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionUsernameSync(root: string, envName: string): string | null {
|
||||
const session = readJsonIfExists<SessionFileShape>(sessionPath(root, envName))
|
||||
const u = session?.username?.trim()
|
||||
return u && u.length > 0 ? u : null
|
||||
}
|
||||
|
||||
/** Одна строка: активная среда и пользователь (или «вход не выполнен»). */
|
||||
export function describeBlagoSessionLine(cfg: BlagoConfigFile, username: string | null): string {
|
||||
const envName = cfg.activeEnv
|
||||
const profile = cfg.environments[envName]
|
||||
const label = profile?.label?.trim()
|
||||
const envHuman = label ? `«${envName}» (${label})` : `«${envName}»`
|
||||
const userHuman = username
|
||||
? `пользователь: ${username}`
|
||||
: 'вход не выполнен (blago login)'
|
||||
return `Сессия: среда ${envHuman}; ${userHuman}.`
|
||||
}
|
||||
|
||||
/** Текст в конец blago --help и blago <cmd> --help (синхронно). */
|
||||
export function formatBlagoSessionStatusHelpExtra(): string {
|
||||
const root = findBlagoRoot(resolveBlagoStartDir())
|
||||
if (!root) {
|
||||
return `
|
||||
|
||||
Копия blago не найдена: нет .blago/config.json вверх от базового каталога (--dir, иначе BLAGO_WORKSPACE, иначе cwd).`
|
||||
}
|
||||
|
||||
const cfg = readJsonIfExists<BlagoConfigFile>(configPath(root))
|
||||
if (!cfg?.activeEnv || typeof cfg.environments !== 'object') {
|
||||
return `
|
||||
|
||||
Корень: ${root}
|
||||
Файл .blago/config.json некорректен (нужны activeEnv и environments).`
|
||||
}
|
||||
|
||||
const line = describeBlagoSessionLine(cfg, readSessionUsernameSync(root, cfg.activeEnv))
|
||||
return `
|
||||
|
||||
${line}`
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// add: только .md, с учётом .blagoignore; в staging — только файлы, изменённые относительно индекса (или без записи в индексе).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
|
||||
import { findByRelativePath, loadIndex, loadStaging, normalizeRelativePath, saveStaging } from './index-store.js'
|
||||
|
||||
async function collectMarkdownFiles(absDir: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
const entries = await fs.readdir(absDir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
const abs = path.join(absDir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
const nested = await collectMarkdownFiles(abs)
|
||||
out.push(...nested)
|
||||
}
|
||||
else if (e.isFile() && e.name.endsWith('.md')) {
|
||||
out.push(abs)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
|
||||
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
|
||||
const entry = findByRelativePath(index, rel)
|
||||
if (!entry) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, rel), 'utf8')
|
||||
return sha256Hex(raw) !== entry.content_etag_local
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunAddResult {
|
||||
/** Пути, прошедшие фильтр и попавшие в объединённый staging */
|
||||
stagedPaths: string[]
|
||||
skippedUnchanged: number
|
||||
skippedIgnored: number
|
||||
}
|
||||
|
||||
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
|
||||
if (targets.length === 0) {
|
||||
throw new Error('Укажите файлы или каталоги: blago add <путь> …')
|
||||
}
|
||||
const rules = await loadBlagoIgnoreRules(root)
|
||||
const index = await loadIndex(root)
|
||||
const staging = await loadStaging(root)
|
||||
const set = new Set(staging.paths.map(p => normalizeRelativePath(p)))
|
||||
|
||||
let skippedUnchanged = 0
|
||||
let skippedIgnored = 0
|
||||
|
||||
for (const t of targets) {
|
||||
const abs = path.resolve(root, t)
|
||||
const st = await fs.stat(abs)
|
||||
const files: string[] = []
|
||||
if (st.isDirectory()) {
|
||||
files.push(...(await collectMarkdownFiles(abs)))
|
||||
}
|
||||
else if (st.isFile()) {
|
||||
files.push(abs)
|
||||
}
|
||||
for (const file of files) {
|
||||
const rel = normalizeRelativePath(path.relative(root, file))
|
||||
if (isIgnoredRelativePath(rel, rules)) {
|
||||
skippedIgnored += 1
|
||||
continue
|
||||
}
|
||||
if (!(await isDirtyVsIndex(root, rel, index))) {
|
||||
skippedUnchanged += 1
|
||||
continue
|
||||
}
|
||||
set.add(rel)
|
||||
}
|
||||
}
|
||||
|
||||
const stagedPaths = [...set].sort()
|
||||
await saveStaging(root, { paths: stagedPaths })
|
||||
return { stagedPaths, skippedUnchanged, skippedIgnored }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Сброс копии: удалить верхнеуровневые каталоги проектов (всё от pull лежит внутри них) + обнулить index/staging.
|
||||
// Не поднимаемся по дереву от файлов — только rm одного уровня под корнём копии. .blago не трогаем.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { loadIndex, normalizeRelativePath, saveIndex, saveStaging } from './index-store.js'
|
||||
|
||||
function isInsideRoot(rootAbs: string, absPath: string): boolean {
|
||||
const rel = path.relative(rootAbs, absPath)
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
||||
}
|
||||
|
||||
/** Запись индекса: без «..», без абсолютного пути и NUL. */
|
||||
function isSafeIndexRelativePath(rel: string): boolean {
|
||||
if (!rel || rel.includes('\0')) {
|
||||
return false
|
||||
}
|
||||
const posix = rel.replace(/\\/g, '/')
|
||||
if (posix.startsWith('/') || /^[a-z]:\//i.test(posix)) {
|
||||
return false
|
||||
}
|
||||
const segments = posix.split('/').filter(Boolean)
|
||||
return !segments.includes('..')
|
||||
}
|
||||
|
||||
function assertCleanRootIsNotFilesystemRoot(rootAbs: string): void {
|
||||
const n = path.normalize(rootAbs)
|
||||
if (n === '/' || n === '\\') {
|
||||
throw new Error('blago clean: корень копии совпадает с корнем файловой системы — операция запрещена.')
|
||||
}
|
||||
if (process.platform === 'win32' && /^[a-z]:[\\/]?$/i.test(n)) {
|
||||
throw new Error('blago clean: корень копии — корень диска Windows — операция запрещена.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* По индексу собираем имена верхних каталогов (первый сегмент пути) и удаляем каждый целиком через fs.rm recursive.
|
||||
* Конфиг и сессии в .blago/ не затрагиваются.
|
||||
*/
|
||||
export async function runClean(root: string): Promise<void> {
|
||||
let rootAbs = path.resolve(root)
|
||||
try {
|
||||
rootAbs = await fs.realpath(rootAbs)
|
||||
}
|
||||
catch {
|
||||
/* каталога нет */
|
||||
}
|
||||
assertCleanRootIsNotFilesystemRoot(rootAbs)
|
||||
|
||||
const index = await loadIndex(root)
|
||||
const topNames = new Set<string>()
|
||||
const blagoName = '.blago'
|
||||
|
||||
for (const e of index.entries) {
|
||||
const rel = normalizeRelativePath(e.relative_path)
|
||||
if (!isSafeIndexRelativePath(rel)) {
|
||||
continue
|
||||
}
|
||||
const first = rel.split('/').filter(Boolean)[0]
|
||||
if (!first || first === blagoName || first === '.' || first === '..') {
|
||||
continue
|
||||
}
|
||||
topNames.add(first)
|
||||
}
|
||||
|
||||
for (const name of topNames) {
|
||||
const target = path.join(rootAbs, name)
|
||||
if (!isInsideRoot(rootAbs, target)) {
|
||||
continue
|
||||
}
|
||||
const blagoDir = path.join(rootAbs, blagoName)
|
||||
if (target === blagoDir || target.startsWith(`${blagoDir}${path.sep}`)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await fs.rm(target, { recursive: true, force: true })
|
||||
}
|
||||
catch {
|
||||
/* нет или нет прав */
|
||||
}
|
||||
}
|
||||
|
||||
await saveIndex(root, { entries: [] })
|
||||
await saveStaging(root, { paths: [] })
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Расхождение updated_at: индекс vs свежий ответ API → throw.
|
||||
|
||||
export class BlagoConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BlagoConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSameRemoteVersion(localIndexIso: string, serverIso: string, entityLabel: string): void {
|
||||
if (localIndexIso !== serverIso) {
|
||||
throw new BlagoConflictError(
|
||||
`Конфликт для ${entityLabel}: на сервере другая версия (updated_at). Выполните «blago pull», разрешите расхождения и повторите push.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// FR-010: отличие рабочих файлов от эталона в индексе (SHA256 + превью; не патч к серверу).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { info } from '../ui/output.js'
|
||||
|
||||
import { loadIndex } from './index-store.js'
|
||||
|
||||
const PREVIEW_LINES = 24
|
||||
|
||||
export async function runDiff(root: string): Promise<void> {
|
||||
const index = await loadIndex(root)
|
||||
let shown = false
|
||||
for (const e of index.entries) {
|
||||
const abs = path.join(root, e.relative_path)
|
||||
try {
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
const h = sha256Hex(raw)
|
||||
if (h === e.content_etag_local) {
|
||||
continue
|
||||
}
|
||||
shown = true
|
||||
info(`${e.relative_path} [${e.entity_type} ${e.entity_hash}]`)
|
||||
info(` индекс sha256: ${e.content_etag_local}`)
|
||||
info(` сейчас sha256: ${h}`)
|
||||
const lines = raw.split('\n')
|
||||
info(' превью:')
|
||||
for (let i = 0; i < Math.min(PREVIEW_LINES, lines.length); i++) {
|
||||
info(` | ${lines[i]}`)
|
||||
}
|
||||
if (lines.length > PREVIEW_LINES) {
|
||||
info(` … ещё ${lines.length - PREVIEW_LINES} строк`)
|
||||
}
|
||||
info('')
|
||||
}
|
||||
catch {
|
||||
shown = true
|
||||
info(`${e.relative_path} — файл отсутствует на диске [${e.entity_type} ${e.entity_hash}]`)
|
||||
info('')
|
||||
}
|
||||
}
|
||||
if (!shown) {
|
||||
info('Нет расхождений с индексом по содержимому.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// .blagoignore: простые префиксы/имена (не полный gitignore).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
const DEFAULT_IGNORE = ['.blago/', '.git/']
|
||||
|
||||
export async function loadBlagoIgnoreRules(root: string): Promise<string[]> {
|
||||
const rules: string[] = [...DEFAULT_IGNORE]
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, '.blagoignore'), 'utf8')
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
rules.push(t)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
/* нет файла */
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
function matchesRule(relPosix: string, rule: string): boolean {
|
||||
const r = rule.replace(/\\/g, '/')
|
||||
const withSlash = r.endsWith('/') ? r.slice(0, -1) : r
|
||||
if (relPosix === withSlash) {
|
||||
return true
|
||||
}
|
||||
if (relPosix.startsWith(`${withSlash}/`)) {
|
||||
return true
|
||||
}
|
||||
if (!r.includes('/')) {
|
||||
const parts = relPosix.split('/')
|
||||
return parts.includes(withSlash)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isIgnoredRelativePath(relPosix: string, rules: string[]): boolean {
|
||||
for (const rule of rules) {
|
||||
if (matchesRule(relPosix, rule)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// index.json + staging.json: путь и etag содержимого после последнего pull/push.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import { indexPath, stagingPath } from '../config/paths.js'
|
||||
|
||||
export type BlagoEntityType = 'project' | 'issue' | 'story'
|
||||
|
||||
export interface IndexEntry {
|
||||
entity_type: BlagoEntityType
|
||||
entity_hash: string
|
||||
relative_path: string
|
||||
remote_updated_at: string
|
||||
content_etag_local: string
|
||||
}
|
||||
|
||||
export interface IndexFile {
|
||||
entries: IndexEntry[]
|
||||
}
|
||||
|
||||
export interface StagingFile {
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export async function loadIndex(root: string): Promise<IndexFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as IndexFile
|
||||
if (!Array.isArray(parsed.entries)) {
|
||||
return { entries: [] }
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return { entries: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveIndex(root: string, index: IndexFile): Promise<void> {
|
||||
await fs.writeFile(indexPath(root), `${JSON.stringify(index, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function findByHash(index: IndexFile, type: BlagoEntityType, hash: string): IndexEntry | undefined {
|
||||
return index.entries.find(e => e.entity_type === type && e.entity_hash === hash)
|
||||
}
|
||||
|
||||
export function findByRelativePath(index: IndexFile, relativePath: string): IndexEntry | undefined {
|
||||
const n = normalizeRelativePath(relativePath)
|
||||
return index.entries.find(e => normalizeRelativePath(e.relative_path) === n)
|
||||
}
|
||||
|
||||
export function upsertEntry(index: IndexFile, entry: IndexEntry): void {
|
||||
const i = index.entries.findIndex(e => e.entity_type === entry.entity_type && e.entity_hash === entry.entity_hash)
|
||||
if (i >= 0) {
|
||||
index.entries[i] = entry
|
||||
}
|
||||
else {
|
||||
index.entries.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadStaging(root: string): Promise<StagingFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(stagingPath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as StagingFile
|
||||
if (!Array.isArray(parsed.paths)) {
|
||||
return { paths: [] }
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return { paths: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveStaging(root: string, staging: StagingFile): Promise<void> {
|
||||
await fs.writeFile(stagingPath(root), `${JSON.stringify(staging, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/** Добавить пути в staging без проверки «грязности» (после create). */
|
||||
export async function appendPathsToStaging(root: string, paths: string[]): Promise<void> {
|
||||
const staging = await loadStaging(root)
|
||||
const set = new Set(staging.paths.map(p => normalizeRelativePath(p)))
|
||||
for (const p of paths) {
|
||||
set.add(normalizeRelativePath(p))
|
||||
}
|
||||
await saveStaging(root, { paths: [...set].sort() })
|
||||
}
|
||||
|
||||
/** Заменить один путь в staging (после переименования файла при push). */
|
||||
export async function replaceStagingPath(root: string, fromRel: string, toRel: string): Promise<void> {
|
||||
const from = normalizeRelativePath(fromRel)
|
||||
const to = normalizeRelativePath(toRel)
|
||||
const staging = await loadStaging(root)
|
||||
const next = staging.paths.map(p => (normalizeRelativePath(p) === from ? to : p))
|
||||
await saveStaging(root, { paths: [...new Set(next)].sort() })
|
||||
}
|
||||
|
||||
export function normalizeRelativePath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/^\/+/, '')
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// slug и пути project.md / component.md / issues / requirements (как GitHub-синк).
|
||||
|
||||
import { capitalIdPathPrefix, storyRequirementIdPrefix2 } from '../lib/capital-id-path.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
|
||||
const transliterationMap: Record<string, string> = {
|
||||
а: 'a',
|
||||
б: 'b',
|
||||
в: 'v',
|
||||
г: 'g',
|
||||
д: 'd',
|
||||
е: 'e',
|
||||
ё: 'e',
|
||||
ж: 'zh',
|
||||
з: 'z',
|
||||
и: 'i',
|
||||
й: 'y',
|
||||
к: 'k',
|
||||
л: 'l',
|
||||
м: 'm',
|
||||
н: 'n',
|
||||
о: 'o',
|
||||
п: 'p',
|
||||
р: 'r',
|
||||
с: 's',
|
||||
т: 't',
|
||||
у: 'u',
|
||||
ф: 'f',
|
||||
х: 'kh',
|
||||
ц: 'ts',
|
||||
ч: 'ch',
|
||||
ш: 'sh',
|
||||
щ: 'shch',
|
||||
ъ: '',
|
||||
ы: 'y',
|
||||
ь: '',
|
||||
э: 'e',
|
||||
ю: 'yu',
|
||||
я: 'ya',
|
||||
}
|
||||
|
||||
function hasCyrillic(text: string): boolean {
|
||||
return /[а-яё]/i.test(text)
|
||||
}
|
||||
|
||||
function transliterate(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split('')
|
||||
.map(char => transliterationMap[char] ?? char)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function generateSlug(title: string): string {
|
||||
let processed = title
|
||||
if (hasCyrillic(title)) {
|
||||
processed = transliterate(title)
|
||||
}
|
||||
return processed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export interface ProjectPathModel {
|
||||
readonly project_hash: string
|
||||
readonly title: string
|
||||
readonly parent_hash?: string | null
|
||||
/** ID проекта/компонента в Capital (блокчейн), для префикса путей */
|
||||
readonly capital_id: number
|
||||
}
|
||||
|
||||
export { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
|
||||
export function workspaceBasePath(project: ProjectPathModel, projectByHash: ReadonlyMap<string, ProjectPathModel>): string {
|
||||
const parentRef = effectiveParentHash(project.parent_hash)
|
||||
const slug = generateSlug(project.title || (parentRef ? 'unnamed-component' : 'unnamed-project'))
|
||||
const name = `${capitalIdPathPrefix(project.capital_id)}-${slug || (parentRef ? 'unnamed-component' : 'unnamed-project')}`
|
||||
if (!parentRef) {
|
||||
return name
|
||||
}
|
||||
const parent = projectByHash.get(parentRef)
|
||||
if (!parent) {
|
||||
throw new Error(`Родительский проект ${parentRef} не найден для компонента ${project.project_hash}`)
|
||||
}
|
||||
const parentBase = workspaceBasePath(parent, projectByHash)
|
||||
return `${parentBase}/components/${name}`
|
||||
}
|
||||
|
||||
export function projectFileRelativePath(project: ProjectPathModel, projectByHash: ReadonlyMap<string, ProjectPathModel>): string {
|
||||
const parentRef = effectiveParentHash(project.parent_hash)
|
||||
if (!parentRef) {
|
||||
const slug = generateSlug(project.title || 'unnamed-project')
|
||||
const name = `${capitalIdPathPrefix(project.capital_id)}-${slug || 'unnamed-project'}`
|
||||
return `${name}/project.md`
|
||||
}
|
||||
const base = workspaceBasePath(project, projectByHash)
|
||||
return `${base}/component.md`
|
||||
}
|
||||
|
||||
/** Имя файла: id задачи (PREFIX-N) + slug title — коллизий нет при уникальном id. */
|
||||
export function issueFileRelativePath(issueTitle: string, basePath: string, issueCapitalId: string): string {
|
||||
const slug = generateSlug(issueTitle) || 'issue'
|
||||
const idp = capitalIdPathPrefix(issueCapitalId)
|
||||
return `${basePath}/issues/${idp}-${slug}.md`
|
||||
}
|
||||
|
||||
export function storyFileRelativePath(
|
||||
storyTitle: string,
|
||||
basePath: string,
|
||||
storyRecordId: string,
|
||||
storyHash: string,
|
||||
issue?: { readonly id: string, readonly titleSlug: string },
|
||||
): string {
|
||||
const slug = generateSlug(storyTitle) || 'requirement'
|
||||
const id2 = storyRequirementIdPrefix2(storyRecordId, storyHash)
|
||||
if (issue?.titleSlug) {
|
||||
const ip = capitalIdPathPrefix(issue.id)
|
||||
return `${basePath}/issues/${ip}-${issue.titleSlug}-requirements/${id2}-${slug}.md`
|
||||
}
|
||||
return `${basePath}/requirements/${id2}-${slug}.md`
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Очередь локально созданных сущностей до первого push (FR-016).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import { pendingCreatePath } from '../config/paths.js'
|
||||
|
||||
import type { BlagoEntityType } from './index-store.js'
|
||||
import { normalizeRelativePath } from './index-store.js'
|
||||
|
||||
export type PendingCreateKind = 'issue' | 'story'
|
||||
|
||||
export interface PendingCreateItem {
|
||||
readonly kind: PendingCreateKind
|
||||
/** Для issue — временный hash в frontmatter до ответа API; для story — тот же story_hash, что уйдёт в CreateStory. */
|
||||
readonly entity_hash: string
|
||||
readonly relative_path: string
|
||||
readonly created_at: string
|
||||
}
|
||||
|
||||
export interface PendingCreateFile {
|
||||
items: PendingCreateItem[]
|
||||
}
|
||||
|
||||
export async function loadPendingCreate(root: string): Promise<PendingCreateFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(pendingCreatePath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as PendingCreateFile
|
||||
if (!Array.isArray(parsed.items)) {
|
||||
return { items: [] }
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return { items: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePendingCreate(root: string, data: PendingCreateFile): Promise<void> {
|
||||
await fs.writeFile(pendingCreatePath(root), `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function findPendingItem(
|
||||
data: PendingCreateFile,
|
||||
kind: PendingCreateKind,
|
||||
entityHash: string,
|
||||
): PendingCreateItem | undefined {
|
||||
return data.items.find(
|
||||
i => i.kind === kind && i.entity_hash === entityHash,
|
||||
)
|
||||
}
|
||||
|
||||
export async function appendPendingItem(root: string, item: PendingCreateItem): Promise<void> {
|
||||
const data = await loadPendingCreate(root)
|
||||
if (findPendingItem(data, item.kind, item.entity_hash)) {
|
||||
throw new Error(`Запись pending-create уже есть: ${item.kind} ${item.entity_hash}`)
|
||||
}
|
||||
const rel = normalizeRelativePath(item.relative_path)
|
||||
if (data.items.some(i => normalizeRelativePath(i.relative_path) === rel)) {
|
||||
throw new Error(`Файл уже в pending-create: ${rel}`)
|
||||
}
|
||||
data.items.push(item)
|
||||
await savePendingCreate(root, data)
|
||||
}
|
||||
|
||||
export async function removePendingItem(
|
||||
root: string,
|
||||
kind: PendingCreateKind,
|
||||
entityHash: string,
|
||||
): Promise<void> {
|
||||
const data = await loadPendingCreate(root)
|
||||
const next = data.items.filter(i => !(i.kind === kind && i.entity_hash === entityHash))
|
||||
if (next.length === data.items.length) {
|
||||
throw new Error(`Не найдена запись pending-create: ${kind} ${entityHash}`)
|
||||
}
|
||||
await savePendingCreate(root, { items: next })
|
||||
}
|
||||
|
||||
/** Соответствие типа сущности и kind в pending. */
|
||||
export function pendingKindForEntityType(type: BlagoEntityType): PendingCreateKind | undefined {
|
||||
if (type === 'issue' || type === 'story') {
|
||||
return type
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Карта проектов из индекса + файлов project.md / component.md (для layout после create).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
|
||||
import type { IndexFile } from './index-store.js'
|
||||
import type { ProjectPathModel } from './layout.js'
|
||||
|
||||
export interface ProjectRowLite {
|
||||
readonly title: string
|
||||
readonly parent_hash?: string | null
|
||||
}
|
||||
|
||||
export async function loadProjectMapsFromIndex(
|
||||
root: string,
|
||||
index: IndexFile,
|
||||
): Promise<{
|
||||
projectByHash: Map<string, ProjectPathModel>
|
||||
projectRowByHash: Map<string, ProjectRowLite>
|
||||
}> {
|
||||
const projectByHash = new Map<string, ProjectPathModel>()
|
||||
const projectRowByHash = new Map<string, ProjectRowLite>()
|
||||
|
||||
for (const e of index.entries) {
|
||||
if (e.entity_type !== 'project') {
|
||||
continue
|
||||
}
|
||||
const abs = path.join(root, e.relative_path)
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(abs, 'utf8')
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'project') {
|
||||
continue
|
||||
}
|
||||
const h = String(parsed.data.hash ?? '').trim()
|
||||
if (!h) {
|
||||
continue
|
||||
}
|
||||
const idRaw = parsed.data.id
|
||||
const capital_id
|
||||
= typeof idRaw === 'number' && Number.isFinite(idRaw)
|
||||
? idRaw
|
||||
: Number(idRaw) || 0
|
||||
projectByHash.set(h, {
|
||||
project_hash: h,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
parent_hash: parsed.data.parent_hash as string | null | undefined,
|
||||
capital_id,
|
||||
})
|
||||
projectRowByHash.set(h, {
|
||||
title: String(parsed.data.title ?? ''),
|
||||
parent_hash: parsed.data.parent_hash as string | null | undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return { projectByHash, projectRowByHash }
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// pull: SDK → файлы; индекс по hash; смена slug → перенос (FR-014).
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import type { BlagoEntityType, IndexFile } from './index-store.js'
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Queries } from '@coopenomics/sdk'
|
||||
|
||||
import { type BlagoConfigFile, resolveCoopname } from '../config/index.js'
|
||||
import {
|
||||
issueToFrontmatterAndBody,
|
||||
issueWorkspaceTitlesFromProjects,
|
||||
projectToFrontmatterAndBody,
|
||||
serializeBlagoMarkdown,
|
||||
storyToFrontmatterAndBody,
|
||||
} from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
|
||||
import { warn } from '../ui/output.js'
|
||||
import { findByHash, loadIndex, normalizeRelativePath, saveIndex, upsertEntry } from './index-store.js'
|
||||
import {
|
||||
generateSlug,
|
||||
issueFileRelativePath,
|
||||
projectFileRelativePath,
|
||||
type ProjectPathModel,
|
||||
storyFileRelativePath,
|
||||
workspaceBasePath,
|
||||
} from './layout.js'
|
||||
|
||||
interface CapitalProjectRow {
|
||||
id?: number | null
|
||||
project_hash: string
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
coopname: string
|
||||
parent_hash?: string | null
|
||||
parent_title?: string | null
|
||||
status?: string | null
|
||||
created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
components?: CapitalProjectRow[] | null
|
||||
}
|
||||
|
||||
interface CapitalIssueRow {
|
||||
id?: string | null
|
||||
issue_hash: string
|
||||
title: string
|
||||
description?: string | null
|
||||
project_hash: string
|
||||
cycle_id?: string | null
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
estimate?: number | null
|
||||
created_by?: string | null
|
||||
submaster?: string | null
|
||||
creators?: string[] | null
|
||||
metadata?: unknown
|
||||
sort_order?: number | null
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
interface CapitalStoryRow {
|
||||
_id?: string | null
|
||||
story_hash: string
|
||||
title: string
|
||||
description?: string | null
|
||||
content_format?: string | null
|
||||
status?: string | null
|
||||
created_by?: string | null
|
||||
sort_order?: number | null
|
||||
project_hash?: string | null
|
||||
issue_hash?: string | null
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
function flattenProjects(roots: CapitalProjectRow[]): CapitalProjectRow[] {
|
||||
const out: CapitalProjectRow[] = []
|
||||
const walk = (p: CapitalProjectRow): void => {
|
||||
out.push(p)
|
||||
for (const c of p.components ?? []) {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for (const r of roots) {
|
||||
walk(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toUpdatedIso(v: Date | string | null | undefined): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
|
||||
function asProjectPathModel(p: CapitalProjectRow): ProjectPathModel {
|
||||
const n = p.id
|
||||
const capital_id = typeof n === 'number' && Number.isFinite(n) ? n : 0
|
||||
return {
|
||||
project_hash: p.project_hash,
|
||||
title: p.title ?? '',
|
||||
parent_hash: p.parent_hash,
|
||||
capital_id,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDirForFile(absFile: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(absFile), { recursive: true })
|
||||
}
|
||||
|
||||
async function fileExists(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileIfExists(abs: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(abs, 'utf8')
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Один файл сущности: новый путь с сервера vs индекс; «грязный» локально → не затирать без явного сценария.
|
||||
async function syncEntityFile(params: {
|
||||
root: string
|
||||
index: IndexFile
|
||||
entityType: BlagoEntityType
|
||||
entityHash: string
|
||||
relativePath: string
|
||||
content: string
|
||||
remoteUpdatedAt: string
|
||||
label: string
|
||||
}): Promise<void> {
|
||||
const { root, index, entityType, entityHash, relativePath, content, remoteUpdatedAt, label } = params
|
||||
const rel = normalizeRelativePath(relativePath)
|
||||
const absNew = path.join(root, rel)
|
||||
const prev = findByHash(index, entityType, entityHash)
|
||||
|
||||
if (!prev) {
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
const etag = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: etag,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const absOld = path.join(root, prev.relative_path)
|
||||
|
||||
if (prev.relative_path !== rel) {
|
||||
const oldContent = await readFileIfExists(absOld)
|
||||
const dirty
|
||||
= oldContent !== null && oldContent !== undefined && sha256Hex(oldContent) !== prev.content_etag_local
|
||||
|
||||
if (dirty) {
|
||||
await ensureDirForFile(absNew)
|
||||
if (await fileExists(absOld)) {
|
||||
await fs.rename(absOld, absNew)
|
||||
}
|
||||
else {
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
}
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: sha256Hex((await readFileIfExists(absNew)) ?? ''),
|
||||
})
|
||||
warn(
|
||||
`Переименование на сервере: ${label} перенесён на «${rel}» с сохранением локальных правок; проверьте frontmatter (title / updated_at).`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
if ((await fileExists(absOld)) && path.resolve(absOld) !== path.resolve(absNew)) {
|
||||
await fs.unlink(absOld)
|
||||
}
|
||||
const etagAfterRename = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: etagAfterRename,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const current = await readFileIfExists(absNew)
|
||||
const dirty
|
||||
= current !== null && current !== undefined && sha256Hex(current) !== prev.content_etag_local
|
||||
if (dirty && remoteUpdatedAt !== prev.remote_updated_at) {
|
||||
warn(
|
||||
`Пропуск перезаписи ${label}: есть локальные правки, на сервере новая версия. Смержите вручную или откатите файл, затем снова «blago pull».`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
const etagOnDisk = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: etagOnDisk,
|
||||
})
|
||||
}
|
||||
|
||||
function requireCoopname(cfg: BlagoConfigFile): string {
|
||||
const c = resolveCoopname(cfg)
|
||||
if (!c) {
|
||||
throw new Error(
|
||||
'Укажите coopname в .blago/config.json в environments.<активнаяСреда> (или запасной «coopname» сверху), либо: blago init --coopname <имя>',
|
||||
)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
export async function runPull(ctx: AuthenticatedContext): Promise<void> {
|
||||
const coopname = requireCoopname(ctx.config)
|
||||
const index = await loadIndex(ctx.root)
|
||||
const projectByHash = new Map<string, ProjectPathModel>()
|
||||
|
||||
const allProjects: CapitalProjectRow[] = []
|
||||
const seen = new Set<string>()
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const { [Queries.Capital.GetProjects.name]: pageResult } = await ctx.client.Query(
|
||||
Queries.Capital.GetProjects.query,
|
||||
{
|
||||
variables: {
|
||||
filter: { coopname },
|
||||
options: { limit: 100, page, sortOrder: 'DESC' },
|
||||
},
|
||||
},
|
||||
)
|
||||
const chunk = pageResult.items as CapitalProjectRow[]
|
||||
for (const item of chunk) {
|
||||
for (const p of flattenProjects([item])) {
|
||||
if (!seen.has(p.project_hash)) {
|
||||
seen.add(p.project_hash)
|
||||
allProjects.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page >= pageResult.totalPages) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
page = 1
|
||||
for (;;) {
|
||||
const { [Queries.Capital.GetProjects.name]: pageResult } = await ctx.client.Query(
|
||||
Queries.Capital.GetProjects.query,
|
||||
{
|
||||
variables: {
|
||||
filter: { coopname, is_component: true },
|
||||
options: { limit: 100, page, sortOrder: 'DESC' },
|
||||
},
|
||||
},
|
||||
)
|
||||
const chunk = pageResult.items as CapitalProjectRow[]
|
||||
for (const item of chunk) {
|
||||
for (const p of flattenProjects([item])) {
|
||||
if (!seen.has(p.project_hash)) {
|
||||
seen.add(p.project_hash)
|
||||
allProjects.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page >= pageResult.totalPages) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
for (const p of allProjects) {
|
||||
projectByHash.set(p.project_hash, asProjectPathModel(p))
|
||||
}
|
||||
|
||||
const projectRowByHash = new Map<string, CapitalProjectRow>()
|
||||
for (const p of allProjects) {
|
||||
projectRowByHash.set(p.project_hash, p)
|
||||
}
|
||||
|
||||
for (const p of allProjects) {
|
||||
const { data, body } = projectToFrontmatterAndBody(p)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
const rel = projectFileRelativePath(asProjectPathModel(p), projectByHash)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'project',
|
||||
entityHash: p.project_hash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(p._updated_at),
|
||||
label: `проект ${p.project_hash}`,
|
||||
})
|
||||
}
|
||||
|
||||
const issues: CapitalIssueRow[] = []
|
||||
page = 1
|
||||
for (;;) {
|
||||
const { [Queries.Capital.GetIssues.name]: pageResult } = await ctx.client.Query(
|
||||
Queries.Capital.GetIssues.query,
|
||||
{
|
||||
variables: {
|
||||
filter: { coopname },
|
||||
options: { limit: 200, page, sortOrder: 'DESC' },
|
||||
},
|
||||
},
|
||||
)
|
||||
const chunk = pageResult.items as CapitalIssueRow[]
|
||||
issues.push(...chunk)
|
||||
if (page >= pageResult.totalPages) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
const issueByHash = new Map<string, CapitalIssueRow>()
|
||||
for (const i of issues) {
|
||||
issueByHash.set(i.issue_hash, i)
|
||||
}
|
||||
|
||||
for (const i of issues) {
|
||||
const proj = projectByHash.get(i.project_hash)
|
||||
if (!proj) {
|
||||
warn(`Задача ${i.issue_hash}: проект ${i.project_hash} не найден в выборке, пропуск`)
|
||||
continue
|
||||
}
|
||||
const basePath = workspaceBasePath(proj, projectByHash)
|
||||
const issueCapitalId = i.id !== undefined && i.id !== null && String(i.id).trim() !== '' ? String(i.id) : i.issue_hash
|
||||
const rel = issueFileRelativePath(i.title, basePath, issueCapitalId)
|
||||
const workspace = issueWorkspaceTitlesFromProjects(i.project_hash, projectRowByHash)
|
||||
const { data, body } = issueToFrontmatterAndBody(i, workspace)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'issue',
|
||||
entityHash: i.issue_hash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(i._updated_at),
|
||||
label: `задача ${i.issue_hash}`,
|
||||
})
|
||||
}
|
||||
|
||||
const stories: CapitalStoryRow[] = []
|
||||
page = 1
|
||||
for (;;) {
|
||||
const { [Queries.Capital.GetStories.name]: pageResult } = await ctx.client.Query(
|
||||
Queries.Capital.GetStories.query,
|
||||
{
|
||||
variables: {
|
||||
filter: {
|
||||
coopname,
|
||||
show_issues_requirements: true,
|
||||
show_components_requirements: true,
|
||||
},
|
||||
options: { limit: 200, page, sortOrder: 'DESC' },
|
||||
},
|
||||
},
|
||||
)
|
||||
const chunk = pageResult.items as CapitalStoryRow[]
|
||||
stories.push(...chunk)
|
||||
if (page >= pageResult.totalPages) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
for (const s of stories) {
|
||||
if (!s.project_hash) {
|
||||
warn(`Требование ${s.story_hash}: нет project_hash, пропуск`)
|
||||
continue
|
||||
}
|
||||
const proj = projectByHash.get(s.project_hash)
|
||||
if (!proj) {
|
||||
warn(`Требование ${s.story_hash}: проект ${s.project_hash} не найден, пропуск`)
|
||||
continue
|
||||
}
|
||||
const basePath = workspaceBasePath(proj, projectByHash)
|
||||
let issueArg: { id: string, titleSlug: string } | undefined
|
||||
if (s.issue_hash) {
|
||||
const iss = issueByHash.get(s.issue_hash)
|
||||
if (iss) {
|
||||
const issueId = iss.id !== undefined && iss.id !== null && String(iss.id).trim() !== '' ? String(iss.id) : iss.issue_hash
|
||||
issueArg = {
|
||||
id: issueId,
|
||||
titleSlug: generateSlug(iss.title) || 'issue',
|
||||
}
|
||||
}
|
||||
}
|
||||
const storyRecordId
|
||||
= s._id !== undefined && s._id !== null && String(s._id).trim() !== '' ? String(s._id) : s.story_hash
|
||||
const rel = storyFileRelativePath(s.title, basePath, storyRecordId, s.story_hash, issueArg)
|
||||
const { data, body } = storyToFrontmatterAndBody(s)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'story',
|
||||
entityHash: s.story_hash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(s._updated_at),
|
||||
label: `требование ${s.story_hash}`,
|
||||
})
|
||||
}
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// Первая отправка локально созданных issue / story (ветка Create в push).
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations } from '@coopenomics/sdk'
|
||||
|
||||
import { resolveCoopname } from '../config/index.js'
|
||||
import {
|
||||
issueToFrontmatterAndBody,
|
||||
issueWorkspaceTitlesFromProjects,
|
||||
parseBlagoMarkdown,
|
||||
type ParsedBlagoFile,
|
||||
serializeBlagoMarkdown,
|
||||
storyToFrontmatterAndBody,
|
||||
} from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
|
||||
import { findPendingItem, loadPendingCreate, removePendingItem, type PendingCreateItem } from './pending-create.js'
|
||||
import { loadProjectMapsFromIndex } from './project-index-map.js'
|
||||
import {
|
||||
findByHash,
|
||||
normalizeRelativePath,
|
||||
replaceStagingPath,
|
||||
upsertEntry,
|
||||
type IndexFile,
|
||||
} from './index-store.js'
|
||||
import { generateSlug, issueFileRelativePath, storyFileRelativePath, workspaceBasePath } from './layout.js'
|
||||
|
||||
function toIso(v: unknown): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function requireCoopname(cfg: AuthenticatedContext['config']): string {
|
||||
const c = resolveCoopname(cfg)
|
||||
if (!c) {
|
||||
throw new Error(
|
||||
'Укажите coopname в .blago/config.json (активная среда или общий fallback)',
|
||||
)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
async function ensureDirForFile(absFile: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(absFile), { recursive: true })
|
||||
}
|
||||
|
||||
/** issue_hash из индекса → id и slug для пути требования под задачей. */
|
||||
async function issueLinkForStoryPath(
|
||||
root: string,
|
||||
index: IndexFile,
|
||||
issueHash: string,
|
||||
): Promise<{ id: string, titleSlug: string } | undefined> {
|
||||
const entry = findByHash(index, 'issue', issueHash)
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
const raw = await fs.readFile(path.join(root, entry.relative_path), 'utf8')
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'issue') {
|
||||
return undefined
|
||||
}
|
||||
const idRaw = parsed.data.id
|
||||
const id = idRaw !== undefined && idRaw !== null && String(idRaw).trim() !== ''
|
||||
? String(idRaw).trim()
|
||||
: String(parsed.data.hash ?? '')
|
||||
const titleSlug = generateSlug(String(parsed.data.title ?? '')) || 'issue'
|
||||
return { id, titleSlug }
|
||||
}
|
||||
|
||||
export async function pushCreateIssue(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
rel: string,
|
||||
parsed: ParsedBlagoFile,
|
||||
pendingItem: PendingCreateItem,
|
||||
): Promise<void> {
|
||||
const coopname = requireCoopname(ctx.config)
|
||||
const { projectByHash, projectRowByHash } = await loadProjectMapsFromIndex(ctx.root, index)
|
||||
const projectHash = String(parsed.data.project_hash ?? '')
|
||||
const proj = projectByHash.get(projectHash)
|
||||
if (!proj) {
|
||||
throw new Error(`Задача: проект «${projectHash}» не найден в индексе (нужен «blago pull»).`)
|
||||
}
|
||||
|
||||
const creators = Array.isArray(parsed.data.creators)
|
||||
? (parsed.data.creators as unknown[]).map(x => String(x))
|
||||
: []
|
||||
const labels = Array.isArray(parsed.data.labels)
|
||||
? (parsed.data.labels as unknown[]).map(x => String(x))
|
||||
: []
|
||||
|
||||
const issueInput: Mutations.Capital.CreateIssue.IInput['data'] = {
|
||||
coopname,
|
||||
project_hash: projectHash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
status: parsed.data.status as Mutations.Capital.CreateIssue.IInput['data']['status'],
|
||||
priority: parsed.data.priority as Mutations.Capital.CreateIssue.IInput['data']['priority'],
|
||||
estimate: Number(parsed.data.estimate ?? 0),
|
||||
sort_order: Number(parsed.data.sort_order ?? 0),
|
||||
creators,
|
||||
labels,
|
||||
}
|
||||
if (parsed.data.cycle_id) {
|
||||
issueInput.cycle_id = String(parsed.data.cycle_id)
|
||||
}
|
||||
if (parsed.data.submaster) {
|
||||
issueInput.submaster = String(parsed.data.submaster)
|
||||
}
|
||||
|
||||
const mutationResult = await ctx.client.Mutation(Mutations.Capital.CreateIssue.mutation, {
|
||||
variables: { data: issueInput },
|
||||
})
|
||||
const created = mutationResult[Mutations.Capital.CreateIssue.name]
|
||||
if (created == null) {
|
||||
throw new Error('Создание задачи: пустой ответ API')
|
||||
}
|
||||
|
||||
const workspace = issueWorkspaceTitlesFromProjects(created.project_hash, projectRowByHash)
|
||||
const { data, body } = issueToFrontmatterAndBody(
|
||||
{
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
description: created.description,
|
||||
issue_hash: created.issue_hash,
|
||||
project_hash: created.project_hash,
|
||||
cycle_id: created.cycle_id,
|
||||
status: created.status,
|
||||
priority: created.priority,
|
||||
estimate: created.estimate,
|
||||
submaster: created.submaster,
|
||||
creators: created.creators,
|
||||
metadata: created.metadata,
|
||||
sort_order: created.sort_order,
|
||||
_created_at: created._created_at,
|
||||
_updated_at: created._updated_at,
|
||||
},
|
||||
workspace,
|
||||
)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
const basePath = workspaceBasePath(projectByHash.get(created.project_hash)!, projectByHash)
|
||||
const issueCapitalId
|
||||
= created.id !== undefined && created.id !== null && String(created.id).trim() !== ''
|
||||
? String(created.id).trim()
|
||||
: created.issue_hash
|
||||
const newRel = normalizeRelativePath(
|
||||
issueFileRelativePath(created.title, basePath, issueCapitalId),
|
||||
)
|
||||
const oldRel = normalizeRelativePath(rel)
|
||||
const absNew = path.join(ctx.root, newRel)
|
||||
const absOld = path.join(ctx.root, oldRel)
|
||||
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
if (oldRel !== newRel && (await fileExists(absOld))) {
|
||||
await fs.unlink(absOld)
|
||||
}
|
||||
if (oldRel !== newRel) {
|
||||
await replaceStagingPath(ctx.root, oldRel, newRel)
|
||||
}
|
||||
|
||||
const etag = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: 'issue',
|
||||
entity_hash: created.issue_hash,
|
||||
relative_path: newRel,
|
||||
remote_updated_at: toIso(created._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
|
||||
await removePendingItem(ctx.root, 'issue', pendingItem.entity_hash)
|
||||
}
|
||||
|
||||
async function fileExists(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushCreateStory(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
rel: string,
|
||||
parsed: ParsedBlagoFile,
|
||||
pendingItem: PendingCreateItem,
|
||||
): Promise<void> {
|
||||
const coopname = requireCoopname(ctx.config)
|
||||
const { projectByHash } = await loadProjectMapsFromIndex(ctx.root, index)
|
||||
const storyHash = String(parsed.data.hash ?? '')
|
||||
const projectHash = parsed.data.project_hash ? String(parsed.data.project_hash) : ''
|
||||
const issueHash = parsed.data.issue_hash ? String(parsed.data.issue_hash) : ''
|
||||
if (!projectHash && !issueHash) {
|
||||
throw new Error('Требование: укажите project_hash и/или issue_hash во frontmatter')
|
||||
}
|
||||
const proj = projectHash ? projectByHash.get(projectHash) : undefined
|
||||
if (projectHash && !proj) {
|
||||
throw new Error(`Требование: проект «${projectHash}» не найден в индексе`)
|
||||
}
|
||||
|
||||
const storyInput: Mutations.Capital.CreateStory.IInput['data'] = {
|
||||
coopname,
|
||||
story_hash: storyHash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
content_format: parsed.data.content_format as Mutations.Capital.CreateStory.IInput['data']['content_format'],
|
||||
status: parsed.data.status as Mutations.Capital.CreateStory.IInput['data']['status'],
|
||||
sort_order: Number(parsed.data.sort_order ?? 0),
|
||||
}
|
||||
if (projectHash) {
|
||||
storyInput.project_hash = projectHash
|
||||
}
|
||||
if (issueHash) {
|
||||
storyInput.issue_hash = issueHash
|
||||
}
|
||||
|
||||
const mutationResult = await ctx.client.Mutation(Mutations.Capital.CreateStory.mutation, {
|
||||
variables: { data: storyInput },
|
||||
})
|
||||
const created = mutationResult[Mutations.Capital.CreateStory.name]
|
||||
if (created == null) {
|
||||
throw new Error('Создание требования: пустой ответ API')
|
||||
}
|
||||
|
||||
const { data, body } = storyToFrontmatterAndBody({
|
||||
_id: created._id,
|
||||
title: created.title,
|
||||
description: created.description,
|
||||
story_hash: created.story_hash,
|
||||
content_format: created.content_format,
|
||||
status: created.status,
|
||||
created_by: created.created_by,
|
||||
sort_order: created.sort_order,
|
||||
project_hash: created.project_hash,
|
||||
issue_hash: created.issue_hash,
|
||||
_created_at: created._created_at,
|
||||
_updated_at: created._updated_at,
|
||||
})
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
|
||||
const projRow = created.project_hash ? projectByHash.get(String(created.project_hash)) : undefined
|
||||
if (!projRow) {
|
||||
throw new Error('Требование после create: не удалось разрешить project_hash для пути файла')
|
||||
}
|
||||
const basePath = workspaceBasePath(projRow, projectByHash)
|
||||
let issueArg: { id: string, titleSlug: string } | undefined
|
||||
if (created.issue_hash) {
|
||||
issueArg = await issueLinkForStoryPath(ctx.root, index, String(created.issue_hash))
|
||||
}
|
||||
const storyRecordId
|
||||
= created._id !== undefined && created._id !== null && String(created._id).trim() !== ''
|
||||
? String(created._id).trim()
|
||||
: created.story_hash
|
||||
const newRel = normalizeRelativePath(
|
||||
storyFileRelativePath(created.title, basePath, storyRecordId, created.story_hash, issueArg),
|
||||
)
|
||||
const oldRel = normalizeRelativePath(rel)
|
||||
const absNew = path.join(ctx.root, newRel)
|
||||
const absOld = path.join(ctx.root, oldRel)
|
||||
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
if (oldRel !== newRel && (await fileExists(absOld))) {
|
||||
await fs.unlink(absOld)
|
||||
}
|
||||
if (oldRel !== newRel) {
|
||||
await replaceStagingPath(ctx.root, oldRel, newRel)
|
||||
}
|
||||
|
||||
const etag = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: 'story',
|
||||
entity_hash: created.story_hash,
|
||||
relative_path: newRel,
|
||||
remote_updated_at: toIso(created._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
|
||||
await removePendingItem(ctx.root, 'story', pendingItem.entity_hash)
|
||||
}
|
||||
|
||||
export async function findPendingForParsed(
|
||||
root: string,
|
||||
kind: 'issue' | 'story',
|
||||
entityHash: string,
|
||||
): Promise<PendingCreateItem | undefined> {
|
||||
const data = await loadPendingCreate(root)
|
||||
return findPendingItem(data, kind, entityHash)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// push: только staging; remote_updated_at из индекса должен совпасть с сервером.
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations, Queries } from '@coopenomics/sdk'
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
import { validateParsedForPush } from '../validate/index.js'
|
||||
import { assertSameRemoteVersion } from './conflicts.js'
|
||||
import {
|
||||
findByHash,
|
||||
loadIndex,
|
||||
loadStaging,
|
||||
normalizeRelativePath,
|
||||
saveIndex,
|
||||
saveStaging,
|
||||
upsertEntry,
|
||||
} from './index-store.js'
|
||||
import { pendingKindForEntityType } from './pending-create.js'
|
||||
import {
|
||||
findPendingForParsed,
|
||||
pushCreateIssue,
|
||||
pushCreateStory,
|
||||
} from './push-create.js'
|
||||
|
||||
// Ответы GraphQL/Zeus часто дают _updated_at как unknown — сужаем безопасно.
|
||||
function toIso(v: unknown): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
const staging = await loadStaging(ctx.root)
|
||||
if (staging.paths.length === 0) {
|
||||
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь>')
|
||||
}
|
||||
const index = await loadIndex(ctx.root)
|
||||
|
||||
const normalizedList = [...new Set(staging.paths.map(p => normalizeRelativePath(p)))]
|
||||
const remaining = new Set(normalizedList)
|
||||
|
||||
for (const rel of normalizedList) {
|
||||
const n = rel
|
||||
const abs = path.join(ctx.root, n)
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
const { type, hash } = validateParsedForPush(parsed)
|
||||
const entry = findByHash(index, type, hash)
|
||||
const pKind = pendingKindForEntityType(type)
|
||||
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
|
||||
|
||||
if (entry && pending) {
|
||||
throw new Error(
|
||||
`Файл «${n}»: сущность ${type} уже в индексе, но есть запись pending-create. Удалите pending-create.json вручную или выполните pull.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!entry && pending) {
|
||||
if (normalizeRelativePath(pending.relative_path) !== n) {
|
||||
throw new Error(
|
||||
`Файл «${n}» не совпадает с путём в pending-create («${pending.relative_path}»).`,
|
||||
)
|
||||
}
|
||||
if (type === 'issue') {
|
||||
await pushCreateIssue(ctx, index, n, parsed, pending)
|
||||
}
|
||||
else if (type === 'story') {
|
||||
await pushCreateStory(ctx, index, n, parsed, pending)
|
||||
}
|
||||
remaining.delete(n)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Файл «${n}»: сущность ${type} ${hash} не в индексе. Выполните «blago pull» или «blago create» и снова add.`,
|
||||
)
|
||||
}
|
||||
if (normalizeRelativePath(entry.relative_path) !== n) {
|
||||
throw new Error(
|
||||
`Файл «${n}» не совпадает с каноническим путём в индексе «${entry.relative_path}». Выполните «blago pull» для выравнивания путей.`,
|
||||
)
|
||||
}
|
||||
|
||||
const etag = sha256Hex(raw)
|
||||
|
||||
if (type === 'project') {
|
||||
const coopname = String(parsed.data.coopname ?? '')
|
||||
const rawParent = parsed.data.parent_hash
|
||||
const parentHash = effectiveParentHash(
|
||||
rawParent === undefined || rawParent === null ? undefined : String(rawParent),
|
||||
)
|
||||
const projectQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetProject.query,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
hash,
|
||||
parent_hash: parentHash,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const remote = projectQuery[Queries.Capital.GetProject.name]
|
||||
if (remote == null) {
|
||||
throw new Error(`Проект «${hash}» не найден на сервере.`)
|
||||
}
|
||||
assertSameRemoteVersion(entry.remote_updated_at, toIso(remote._updated_at), `проект ${hash}`)
|
||||
const projectData: Mutations.Capital.EditProject.IInput['data'] = {
|
||||
coopname,
|
||||
project_hash: hash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
data: remote.data ?? '',
|
||||
meta: remote.meta ?? '',
|
||||
invite: remote.invite ?? '',
|
||||
}
|
||||
await ctx.client.Mutation(
|
||||
Mutations.Capital.EditProject.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: projectData,
|
||||
},
|
||||
},
|
||||
)
|
||||
const projectAfterQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetProject.query,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
hash,
|
||||
parent_hash: parentHash,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const after = projectAfterQuery[Queries.Capital.GetProject.name]
|
||||
if (after == null) {
|
||||
throw new Error(`Проект «${hash}» не найден на сервере после сохранения.`)
|
||||
}
|
||||
upsertEntry(index, {
|
||||
entity_type: 'project',
|
||||
entity_hash: hash,
|
||||
relative_path: entry.relative_path,
|
||||
remote_updated_at: toIso(after._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
}
|
||||
else if (type === 'issue') {
|
||||
const issueQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetIssue.query,
|
||||
{
|
||||
variables: {
|
||||
data: { issue_hash: hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const remote = issueQuery[Queries.Capital.GetIssue.name]
|
||||
if (remote == null) {
|
||||
throw new Error(`Задача «${hash}» не найдена на сервере.`)
|
||||
}
|
||||
assertSameRemoteVersion(entry.remote_updated_at, toIso(remote._updated_at), `задача ${hash}`)
|
||||
const creators = Array.isArray(parsed.data.creators)
|
||||
? (parsed.data.creators as unknown[]).map(x => String(x))
|
||||
: []
|
||||
const labels = Array.isArray(parsed.data.labels)
|
||||
? (parsed.data.labels as unknown[]).map(x => String(x))
|
||||
: []
|
||||
/** Только поля GraphQL UpdateIssueInput (schema.gql); лишние ключи ломают capitalUpdateIssue. */
|
||||
const issueData: Mutations.Capital.UpdateIssue.IInput['data'] = {
|
||||
issue_hash: hash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
status: parsed.data.status as Mutations.Capital.UpdateIssue.IInput['data']['status'],
|
||||
priority: parsed.data.priority as Mutations.Capital.UpdateIssue.IInput['data']['priority'],
|
||||
estimate: Number(parsed.data.estimate ?? 0),
|
||||
sort_order: Number(parsed.data.sort_order ?? 0),
|
||||
creators,
|
||||
labels,
|
||||
}
|
||||
if (parsed.data.cycle_id) {
|
||||
issueData.cycle_id = String(parsed.data.cycle_id)
|
||||
}
|
||||
if (parsed.data.submaster) {
|
||||
issueData.submaster = String(parsed.data.submaster)
|
||||
}
|
||||
const issueMutation = await ctx.client.Mutation(
|
||||
Mutations.Capital.UpdateIssue.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: issueData,
|
||||
},
|
||||
},
|
||||
)
|
||||
const updated = issueMutation[Mutations.Capital.UpdateIssue.name]
|
||||
if (updated == null) {
|
||||
throw new Error(`Не удалось обновить задачу «${hash}» (пустой ответ мутации).`)
|
||||
}
|
||||
upsertEntry(index, {
|
||||
entity_type: 'issue',
|
||||
entity_hash: hash,
|
||||
relative_path: entry.relative_path,
|
||||
remote_updated_at: toIso(updated._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
}
|
||||
else if (type === 'story') {
|
||||
const storyQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetStory.query,
|
||||
{
|
||||
variables: {
|
||||
data: { story_hash: hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const remote = storyQuery[Queries.Capital.GetStory.name]
|
||||
if (remote == null) {
|
||||
throw new Error(`Требование «${hash}» не найдено на сервере.`)
|
||||
}
|
||||
assertSameRemoteVersion(entry.remote_updated_at, toIso(remote._updated_at), `требование ${hash}`)
|
||||
const storyData: Mutations.Capital.UpdateStory.IInput['data'] = {
|
||||
story_hash: hash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
content_format: parsed.data.content_format as Mutations.Capital.UpdateStory.IInput['data']['content_format'],
|
||||
status: parsed.data.status as Mutations.Capital.UpdateStory.IInput['data']['status'],
|
||||
sort_order: Number(parsed.data.sort_order ?? 0),
|
||||
}
|
||||
if (parsed.data.project_hash) {
|
||||
storyData.project_hash = String(parsed.data.project_hash)
|
||||
}
|
||||
if (parsed.data.issue_hash) {
|
||||
storyData.issue_hash = String(parsed.data.issue_hash)
|
||||
}
|
||||
const storyMutation = await ctx.client.Mutation(
|
||||
Mutations.Capital.UpdateStory.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: storyData,
|
||||
},
|
||||
},
|
||||
)
|
||||
const updated = storyMutation[Mutations.Capital.UpdateStory.name]
|
||||
if (updated == null) {
|
||||
throw new Error(`Не удалось обновить требование «${hash}» (пустой ответ мутации).`)
|
||||
}
|
||||
upsertEntry(index, {
|
||||
entity_type: 'story',
|
||||
entity_hash: hash,
|
||||
relative_path: entry.relative_path,
|
||||
remote_updated_at: toIso(updated._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
}
|
||||
|
||||
remaining.delete(n)
|
||||
}
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
await saveStaging(ctx.root, { paths: [...remaining] })
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// remove: убрать пути из staging.json (аналог git restore --staged для .md).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { loadStaging, normalizeRelativePath, saveStaging } from './index-store.js'
|
||||
|
||||
async function collectMarkdownFiles(absDir: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
const entries = await fs.readdir(absDir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
const abs = path.join(absDir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
const nested = await collectMarkdownFiles(abs)
|
||||
out.push(...nested)
|
||||
}
|
||||
else if (e.isFile() && e.name.endsWith('.md')) {
|
||||
out.push(abs)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface RunRemoveResult {
|
||||
readonly removedCount: number
|
||||
readonly remainingPaths: string[]
|
||||
readonly notStagedCount: number
|
||||
}
|
||||
|
||||
/** Полностью очистить staging. */
|
||||
export async function runClearStaging(root: string): Promise<void> {
|
||||
await saveStaging(root, { paths: [] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Убрать из staging указанные файлы или все .md под каталогом (пути относительно root).
|
||||
* Пути, которых нет в staging, пропускаются (счётчик notStaged).
|
||||
*/
|
||||
export async function runRemove(root: string, targets: string[]): Promise<RunRemoveResult> {
|
||||
if (targets.length === 0) {
|
||||
throw new Error('Укажите файлы или каталоги: blago remove <путь> … либо blago remove --all')
|
||||
}
|
||||
const staging = await loadStaging(root)
|
||||
const stagedSet = new Set(staging.paths.map(p => normalizeRelativePath(p)))
|
||||
|
||||
const toRemove = new Set<string>()
|
||||
for (const t of targets) {
|
||||
const abs = path.resolve(root, t)
|
||||
let st: Awaited<ReturnType<typeof fs.stat>>
|
||||
try {
|
||||
st = await fs.stat(abs)
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
const files: string[] = []
|
||||
if (st.isDirectory()) {
|
||||
files.push(...(await collectMarkdownFiles(abs)))
|
||||
}
|
||||
else if (st.isFile() && path.extname(abs).toLowerCase() === '.md') {
|
||||
files.push(abs)
|
||||
}
|
||||
for (const file of files) {
|
||||
toRemove.add(normalizeRelativePath(path.relative(root, file)))
|
||||
}
|
||||
}
|
||||
|
||||
let removedCount = 0
|
||||
let notStagedCount = 0
|
||||
for (const rel of toRemove) {
|
||||
if (stagedSet.has(rel)) {
|
||||
stagedSet.delete(rel)
|
||||
removedCount += 1
|
||||
}
|
||||
else {
|
||||
notStagedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
const remainingPaths = [...stagedSet].sort()
|
||||
await saveStaging(root, { paths: remainingPaths })
|
||||
return { removedCount, remainingPaths, notStagedCount }
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// restore: один файл с сервера по пути из индекса (как git checkout -- file из удалённого).
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import type { IndexEntry, IndexFile } from './index-store.js'
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Queries } from '@coopenomics/sdk'
|
||||
|
||||
import {
|
||||
issueToFrontmatterAndBody,
|
||||
issueWorkspaceTitlesFromProjects,
|
||||
parseBlagoMarkdown,
|
||||
projectToFrontmatterAndBody,
|
||||
serializeBlagoMarkdown,
|
||||
storyToFrontmatterAndBody,
|
||||
} from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
import {
|
||||
findByRelativePath,
|
||||
loadIndex,
|
||||
loadStaging,
|
||||
normalizeRelativePath,
|
||||
saveIndex,
|
||||
saveStaging,
|
||||
upsertEntry,
|
||||
} from './index-store.js'
|
||||
import {
|
||||
generateSlug,
|
||||
issueFileRelativePath,
|
||||
type ProjectPathModel,
|
||||
storyFileRelativePath,
|
||||
workspaceBasePath,
|
||||
} from './layout.js'
|
||||
|
||||
interface CapitalProjectRow {
|
||||
id?: number | null
|
||||
project_hash: string
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
coopname: string
|
||||
parent_hash?: string | null
|
||||
parent_title?: string | null
|
||||
status?: string | null
|
||||
created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
interface CapitalIssueRow {
|
||||
id?: string | null
|
||||
issue_hash: string
|
||||
title: string
|
||||
description?: string | null
|
||||
project_hash: string
|
||||
cycle_id?: string | null
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
estimate?: number | null
|
||||
submaster?: string | null
|
||||
creators?: string[] | null
|
||||
metadata?: unknown
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
interface CapitalStoryRow {
|
||||
_id?: string | null
|
||||
story_hash: string
|
||||
title: string
|
||||
description?: string | null
|
||||
content_format?: string | null
|
||||
status?: string | null
|
||||
project_hash?: string | null
|
||||
issue_hash?: string | null
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
function toUpdatedIso(v: Date | string | null | undefined): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
|
||||
function asProjectPathModel(p: CapitalProjectRow): ProjectPathModel {
|
||||
const n = p.id
|
||||
const capital_id = typeof n === 'number' && Number.isFinite(n) ? n : 0
|
||||
return {
|
||||
project_hash: p.project_hash,
|
||||
title: p.title ?? '',
|
||||
parent_hash: p.parent_hash,
|
||||
capital_id,
|
||||
}
|
||||
}
|
||||
|
||||
/** Цепочка предков от листа к корню (для путей issue/story). */
|
||||
async function loadProjectChainFromLeaf(
|
||||
ctx: AuthenticatedContext,
|
||||
leafHash: string,
|
||||
firstFetchParent: string | undefined,
|
||||
): Promise<Map<string, CapitalProjectRow>> {
|
||||
const map = new Map<string, CapitalProjectRow>()
|
||||
let hash: string | undefined = leafHash
|
||||
let parentArg: string | undefined = firstFetchParent
|
||||
for (let i = 0; i < 64 && hash; i++) {
|
||||
const parentRef = effectiveParentHash(parentArg)
|
||||
const queryResult = await ctx.client.Query(
|
||||
Queries.Capital.GetProject.query,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
hash,
|
||||
parent_hash: parentRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const row = queryResult[Queries.Capital.GetProject.name] as CapitalProjectRow | null | undefined
|
||||
if (!row) {
|
||||
throw new Error(`Проект «${hash}» не найден на сервере.`)
|
||||
}
|
||||
map.set(row.project_hash, row)
|
||||
const up = effectiveParentHash(row.parent_hash)
|
||||
if (!up) {
|
||||
break
|
||||
}
|
||||
hash = up
|
||||
parentArg = undefined
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function mapsFromProjectRows(rows: Map<string, CapitalProjectRow>): {
|
||||
projectByHash: Map<string, ProjectPathModel>
|
||||
projectRowByHash: Map<string, CapitalProjectRow>
|
||||
} {
|
||||
const projectByHash = new Map<string, ProjectPathModel>()
|
||||
const projectRowByHash = new Map<string, CapitalProjectRow>()
|
||||
for (const [h, r] of rows) {
|
||||
projectByHash.set(h, asProjectPathModel(r))
|
||||
projectRowByHash.set(h, r)
|
||||
}
|
||||
return { projectByHash, projectRowByHash }
|
||||
}
|
||||
|
||||
async function writeRestoredFile(params: {
|
||||
root: string
|
||||
index: IndexFile
|
||||
entry: IndexEntry
|
||||
content: string
|
||||
remoteUpdatedAt: string
|
||||
}): Promise<void> {
|
||||
const { root, index, entry, content, remoteUpdatedAt } = params
|
||||
const rel = normalizeRelativePath(entry.relative_path)
|
||||
const abs = path.join(root, rel)
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
await fs.writeFile(abs, content, 'utf8')
|
||||
const etag = sha256Hex(await fs.readFile(abs, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: entry.entity_type,
|
||||
entity_hash: entry.entity_hash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: etag,
|
||||
})
|
||||
}
|
||||
|
||||
async function unstagedPath(root: string, rel: string): Promise<void> {
|
||||
const staging = await loadStaging(root)
|
||||
const n = normalizeRelativePath(rel)
|
||||
const next = staging.paths.filter(p => normalizeRelativePath(p) !== n)
|
||||
if (next.length !== staging.paths.length) {
|
||||
await saveStaging(root, { paths: next.sort() })
|
||||
}
|
||||
}
|
||||
|
||||
async function projectParentHintFromDisk(absFile: string, entityHash: string): Promise<string | undefined> {
|
||||
try {
|
||||
const raw = await fs.readFile(absFile, 'utf8')
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'project') {
|
||||
return undefined
|
||||
}
|
||||
const h = String(parsed.data.hash ?? '')
|
||||
if (h !== entityHash) {
|
||||
return undefined
|
||||
}
|
||||
return effectiveParentHash(
|
||||
parsed.data.parent_hash === undefined || parsed.data.parent_hash === null
|
||||
? undefined
|
||||
: String(parsed.data.parent_hash),
|
||||
)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRestore(ctx: AuthenticatedContext, userPath: string): Promise<void> {
|
||||
const rel = normalizeRelativePath(userPath)
|
||||
if (!rel || !rel.endsWith('.md')) {
|
||||
throw new Error('Укажите относительный путь к .md файлу сущности (как в индексе).')
|
||||
}
|
||||
|
||||
const index = await loadIndex(ctx.root)
|
||||
const entry = findByRelativePath(index, rel)
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Путь «${rel}» не найден в индексе (.blago/index.json). Выполните «blago pull» или проверьте путь.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.entity_type === 'project') {
|
||||
const abs = path.join(ctx.root, rel)
|
||||
const hint = await projectParentHintFromDisk(abs, entry.entity_hash)
|
||||
const chain = await loadProjectChainFromLeaf(ctx, entry.entity_hash, hint)
|
||||
const row = chain.get(entry.entity_hash)
|
||||
if (!row) {
|
||||
throw new Error(`Не удалось загрузить проект «${entry.entity_hash}» с сервера.`)
|
||||
}
|
||||
const { data, body } = projectToFrontmatterAndBody(row)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await writeRestoredFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entry,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(row._updated_at),
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await unstagedPath(ctx.root, rel)
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.entity_type === 'issue') {
|
||||
const issueQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetIssue.query,
|
||||
{
|
||||
variables: {
|
||||
data: { issue_hash: entry.entity_hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const issueRow = issueQuery[Queries.Capital.GetIssue.name] as CapitalIssueRow | null | undefined
|
||||
if (!issueRow) {
|
||||
throw new Error(`Задача «${entry.entity_hash}» не найдена на сервере.`)
|
||||
}
|
||||
const chain = await loadProjectChainFromLeaf(ctx, issueRow.project_hash, undefined)
|
||||
const { projectByHash, projectRowByHash } = mapsFromProjectRows(chain)
|
||||
const proj = projectByHash.get(issueRow.project_hash)
|
||||
if (!proj) {
|
||||
throw new Error(`Проект задачи «${issueRow.project_hash}» не найден в цепочке на сервере.`)
|
||||
}
|
||||
const basePath = workspaceBasePath(proj, projectByHash)
|
||||
const issueCapitalId
|
||||
= issueRow.id !== undefined && issueRow.id !== null && String(issueRow.id).trim() !== ''
|
||||
? String(issueRow.id)
|
||||
: issueRow.issue_hash
|
||||
const canonicalRel = issueFileRelativePath(issueRow.title, basePath, issueCapitalId)
|
||||
if (normalizeRelativePath(canonicalRel) !== rel) {
|
||||
throw new Error(
|
||||
`Канонический путь задачи на сервере «${canonicalRel}» не совпадает с «${rel}». Выполните «blago pull» для выравнивания путей.`,
|
||||
)
|
||||
}
|
||||
const workspace = issueWorkspaceTitlesFromProjects(issueRow.project_hash, projectRowByHash)
|
||||
const { data, body } = issueToFrontmatterAndBody(issueRow, workspace)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await writeRestoredFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entry,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(issueRow._updated_at),
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await unstagedPath(ctx.root, rel)
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.entity_type === 'story') {
|
||||
const storyQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetStory.query,
|
||||
{
|
||||
variables: {
|
||||
data: { story_hash: entry.entity_hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const storyRow = storyQuery[Queries.Capital.GetStory.name] as CapitalStoryRow | null | undefined
|
||||
if (!storyRow || !storyRow.project_hash) {
|
||||
throw new Error(`Требование «${entry.entity_hash}» не найдено на сервере или без project_hash.`)
|
||||
}
|
||||
const chain = await loadProjectChainFromLeaf(ctx, storyRow.project_hash, undefined)
|
||||
const { projectByHash } = mapsFromProjectRows(chain)
|
||||
const proj = projectByHash.get(storyRow.project_hash)
|
||||
if (!proj) {
|
||||
throw new Error(`Проект требования «${storyRow.project_hash}» не найден в цепочке на сервере.`)
|
||||
}
|
||||
const basePath = workspaceBasePath(proj, projectByHash)
|
||||
let issueArg: { id: string, titleSlug: string } | undefined
|
||||
if (storyRow.issue_hash) {
|
||||
const issueQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetIssue.query,
|
||||
{
|
||||
variables: {
|
||||
data: { issue_hash: storyRow.issue_hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const iss = issueQuery[Queries.Capital.GetIssue.name] as CapitalIssueRow | null | undefined
|
||||
if (iss) {
|
||||
const issueId = iss.id !== undefined && iss.id !== null && String(iss.id).trim() !== '' ? String(iss.id) : iss.issue_hash
|
||||
issueArg = {
|
||||
id: issueId,
|
||||
titleSlug: generateSlug(iss.title) || 'issue',
|
||||
}
|
||||
}
|
||||
}
|
||||
const storyRecordId
|
||||
= storyRow._id !== undefined && storyRow._id !== null && String(storyRow._id).trim() !== ''
|
||||
? String(storyRow._id)
|
||||
: storyRow.story_hash
|
||||
const canonicalRel = storyFileRelativePath(
|
||||
storyRow.title,
|
||||
basePath,
|
||||
storyRecordId,
|
||||
storyRow.story_hash,
|
||||
issueArg,
|
||||
)
|
||||
if (normalizeRelativePath(canonicalRel) !== rel) {
|
||||
throw new Error(
|
||||
`Канонический путь требования на сервере «${canonicalRel}» не совпадает с «${rel}». Выполните «blago pull» для выравнивания путей.`,
|
||||
)
|
||||
}
|
||||
const { data, body } = storyToFrontmatterAndBody(storyRow)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await writeRestoredFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entry,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(storyRow._updated_at),
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await unstagedPath(ctx.root, rel)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Неизвестный тип сущности в индексе: ${entry.entity_type}`)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// staging + список путей, у которых sha256 ≠ content_etag_local в индексе.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { info } from '../ui/output.js'
|
||||
|
||||
import { loadIndex, loadStaging, normalizeRelativePath } from './index-store.js'
|
||||
|
||||
export async function runStatus(root: string): Promise<void> {
|
||||
const staging = await loadStaging(root)
|
||||
const index = await loadIndex(root)
|
||||
|
||||
info('В индексе для отправки (staging):')
|
||||
if (staging.paths.length === 0) {
|
||||
info(' (пусто)')
|
||||
}
|
||||
else {
|
||||
for (const p of staging.paths) {
|
||||
info(` + ${normalizeRelativePath(p)}`)
|
||||
}
|
||||
}
|
||||
|
||||
info('Изменённые относительно последней синхронизации (по содержимому):')
|
||||
let any = false
|
||||
for (const e of index.entries) {
|
||||
const abs = path.join(root, e.relative_path)
|
||||
try {
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
const h = sha256Hex(raw)
|
||||
if (h !== e.content_etag_local) {
|
||||
info(` M ${e.relative_path} [${e.entity_type} ${e.entity_hash}]`)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
info(` ? ${e.relative_path} (файл отсутствует)`)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
if (!any) {
|
||||
info(' (нет расхождений)')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './output.js'
|
||||
@@ -0,0 +1,40 @@
|
||||
import pc from 'picocolors'
|
||||
|
||||
/** SDK при GraphQL-ошибках бросает массив объектов `{ message }`, не `Error` — String() даёт «[object Object]». */
|
||||
export function formatThrownValue(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
return e.message
|
||||
}
|
||||
if (Array.isArray(e)) {
|
||||
return e.map(item => formatThrownValue(item)).filter(s => s.length > 0).join('\n')
|
||||
}
|
||||
if (typeof e === 'object' && e !== null) {
|
||||
const o = e as Record<string, unknown>
|
||||
if (typeof o.message === 'string' && o.message.length > 0) {
|
||||
return o.message
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(e)
|
||||
}
|
||||
catch {
|
||||
return String(e)
|
||||
}
|
||||
}
|
||||
return String(e)
|
||||
}
|
||||
|
||||
export function info(message: string): void {
|
||||
console.log(pc.cyan(message))
|
||||
}
|
||||
|
||||
export function success(message: string): void {
|
||||
console.log(pc.green(message))
|
||||
}
|
||||
|
||||
export function warn(message: string): void {
|
||||
console.warn(pc.yellow(message))
|
||||
}
|
||||
|
||||
export function error(message: string): void {
|
||||
console.error(pc.red(message))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Минимальные проверки полей перед push.
|
||||
|
||||
import type { EntityFrontmatterType, ParsedBlagoFile } from '../format/index.js'
|
||||
|
||||
export class BlagoValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BlagoValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
function reqString(data: Record<string, unknown>, key: string, entity: string): string {
|
||||
const v = data[key]
|
||||
if (v === undefined || v === null || String(v).trim() === '') {
|
||||
throw new BlagoValidationError(`${entity}: отсутствует или пустое поле frontmatter «${key}»`)
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function optStringArray(data: Record<string, unknown>, key: string, entity: string): string[] {
|
||||
const v = data[key]
|
||||
if (v === undefined || v === null) {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(v)) {
|
||||
throw new BlagoValidationError(`${entity}: поле «${key}» должно быть массивом строк`)
|
||||
}
|
||||
return v.map(x => String(x))
|
||||
}
|
||||
|
||||
export function validateParsedForPush(parsed: ParsedBlagoFile): {
|
||||
type: EntityFrontmatterType
|
||||
hash: string
|
||||
} {
|
||||
const { type, data } = parsed
|
||||
if (type === 'project') {
|
||||
reqString(data, 'title', 'project')
|
||||
const hash = reqString(data, 'hash', 'project')
|
||||
reqString(data, 'coopname', 'project')
|
||||
return { type, hash }
|
||||
}
|
||||
if (type === 'issue') {
|
||||
reqString(data, 'title', 'issue')
|
||||
const hash = reqString(data, 'hash', 'issue')
|
||||
reqString(data, 'project_hash', 'issue')
|
||||
reqString(data, 'status', 'issue')
|
||||
reqString(data, 'priority', 'issue')
|
||||
if (data.estimate === undefined || data.estimate === null || Number.isNaN(Number(data.estimate))) {
|
||||
throw new BlagoValidationError('issue: поле «estimate» должно быть числом')
|
||||
}
|
||||
optStringArray(data, 'creators', 'issue')
|
||||
optStringArray(data, 'labels', 'issue')
|
||||
if (data.created_by !== undefined && data.created_by !== null && typeof data.created_by !== 'string') {
|
||||
throw new BlagoValidationError('issue: поле created_by должно быть строкой')
|
||||
}
|
||||
return { type, hash }
|
||||
}
|
||||
if (type === 'story') {
|
||||
reqString(data, 'title', 'story')
|
||||
const hash = reqString(data, 'hash', 'story')
|
||||
reqString(data, 'content_format', 'story')
|
||||
reqString(data, 'status', 'story')
|
||||
if (data.created_by !== undefined && data.created_by !== null && typeof data.created_by !== 'string') {
|
||||
throw new BlagoValidationError('story: поле created_by должно быть строкой')
|
||||
}
|
||||
if (!data.project_hash && !data.issue_hash) {
|
||||
throw new BlagoValidationError('story: нужен project_hash и/или issue_hash')
|
||||
}
|
||||
return { type, hash }
|
||||
}
|
||||
throw new BlagoValidationError(`Неизвестный тип сущности: ${type}`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "build.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
EOSIO_PUB_KEY=EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV
|
||||
EOSIO_PRV_KEY=5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3
|
||||
DATADIR=blockchain-data
|
||||
BPACCOUNT=eosio
|
||||
PASSWORD=PW5JGe4WsTPGrjnMzkGd4wfVdzoHCEySgbyq2WBWGAxSfevbXqAG4
|
||||
SERVER_SECRET=SECRET
|
||||
SERVER_URL=http://127.0.0.1:2998
|
||||
MONGO_URI=mongodb://127.0.0.1:27017/cooperative-x
|
||||
SIMPLE_EXPLORER_API=http://localhost:4000
|
||||
SKIP_BLOCK_FETCH=TRUE
|
||||
POSTGRES_HOST=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USERNAME=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DATABASE=voskhod
|
||||
@@ -0,0 +1,14 @@
|
||||
.cache
|
||||
.DS_Store
|
||||
.idea
|
||||
*.log
|
||||
*.tgz
|
||||
coverage
|
||||
dist
|
||||
lib-cov
|
||||
logs
|
||||
node_modules
|
||||
temp
|
||||
.env
|
||||
blockchain-data/*
|
||||
wallet-data/*
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"includePath": [
|
||||
"${workspaceFolder}/**",
|
||||
"${workspaceFolder}/lib/**",
|
||||
"${workspaceFolder}/lib/eosiolib/contracts/**",
|
||||
"${workspaceFolder}/lib/eosiolib/core/**"
|
||||
],
|
||||
"defines": [
|
||||
"_DEBUG",
|
||||
"UNICODE",
|
||||
"_UNICODE",
|
||||
"int128_t=__int128_t",
|
||||
"uint128_t=__uint128_t"
|
||||
],
|
||||
"cStandard": "c17",
|
||||
"cppStandard": "c++20"
|
||||
}
|
||||
],
|
||||
"version": 4
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
// Enable the ESlint flat config support
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
// "source.fixAll": "explicit",
|
||||
// "source.organizeImports": "never"
|
||||
},
|
||||
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{
|
||||
"rule": "style/*",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-indent",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-spacing",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-spaces",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-order",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-dangle",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-newline",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*quotes",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*semi",
|
||||
"severity": "off"
|
||||
}
|
||||
],
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml"
|
||||
],
|
||||
// "[cpp]": {
|
||||
// "editor.defaultFormatter": "ms-vscode.cpptools",
|
||||
// },
|
||||
// "C_Cpp.vcFormat.indent.withinParentheses": "indent",
|
||||
"C_Cpp.errorSquiggles": "disabled"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# @coopenomics/boot
|
||||
|
||||
## Назначение
|
||||
|
||||
CLI-утилита для инициализации блокчейна EOSIO и развёртывания кооператива. Выполняет полный цикл: запуск ноды → создание аккаунтов → активация фич → деплой контрактов → выпуск токенов → загрузка шаблонов → создание кооператива и совета.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts — CLI точка входа (commander): boot, boot:clean, boot:extra,
|
||||
│ start, stop, clear, cleos, deploy, create-coop, unlock
|
||||
├── init/
|
||||
│ ├── booter.ts — Главные сценарии: boot(), bootClean(), bootExtra()
|
||||
│ ├── infra.ts — startInfra() — базовая инфраструктура блокчейна
|
||||
│ │ installInitialData() — начальные данные (пользователи, совет)
|
||||
│ │ installExtraData() — расширенные данные
|
||||
│ └── cooperative.ts — CooperativeClass — создание программ ЦПП, регистрация
|
||||
│ └── participant.ts — Логика регистрации пайщика
|
||||
├── blockchain/
|
||||
│ └── index.ts — Класс Blockchain — обёртка над eosjs для работы с EOSIO
|
||||
│ (создание аккаунтов, деплой контрактов, транзакции, токены)
|
||||
├── configs/
|
||||
│ ├── index.ts — Главная конфигурация (аккаунты, ключи, токен, эмиссия)
|
||||
│ ├── networks.ts — Настройки сети (протокол, хост, порт)
|
||||
│ ├── contracts.ts — Список контрактов и путей к их WASM/ABI файлам
|
||||
│ └── protocol_features/ — JSON-файлы фич для активации на блокчейне
|
||||
├── docker/ — Управление Docker-контейнером ноды блокчейна
|
||||
│ ├── container.ts, run.ts, stop.ts, find.ts, exec.ts, deploy.ts
|
||||
│ ├── health.ts — Проверка готовности ноды
|
||||
│ └── purge.ts — Очистка данных блокчейна и БД
|
||||
├── *-init.ts — Инициализация реляционной БД (пользователи, vault, статус, extensions)
|
||||
├── tests/ — Интеграционные тесты
|
||||
│ ├── capital.test.ts — Основной тест (полный цикл ЦПП)
|
||||
│ ├── wallet.test.ts — Тесты кошелька
|
||||
│ ├── registrator.test.ts — Тесты регистрации
|
||||
│ └── capital/ — Вспомогательные функции для тестов капитала
|
||||
└── utils/ — Утилиты (sleep, randomHash, randomUsername, и т.д.)
|
||||
```
|
||||
|
||||
## Порядок загрузки (boot flow)
|
||||
|
||||
1. **startInfra()** — основа:
|
||||
- Инициализация `Blockchain` с ключами
|
||||
- Создание системных аккаунтов (eosio.token, registrator, soviet, и т.д.)
|
||||
- Активация фич протокола (PREACTIVATE_FEATURE → остальные)
|
||||
- Деплой смарт-контрактов (eosio.boot → eosio.system → все остальные)
|
||||
- Создание и выпуск токенов
|
||||
- Загрузка шаблонов документов из `@coopenomics/factory` Registry
|
||||
- Настройка ресурсов (powerup)
|
||||
|
||||
2. **installInitialData()** — данные кооператива:
|
||||
- Сохранение организации, платёжных методов, физлиц в MongoDB
|
||||
- Сохранение переменных кооператива (vars)
|
||||
- Регистрация пользователей в блокчейне
|
||||
- Инициализация реляционной БД (пользователи, vault)
|
||||
- Создание совета (board)
|
||||
- Создание программ ЦПП (Благорост, маркетплейс)
|
||||
|
||||
3. **bootClean()** — минимальная загрузка (только инфра + программы, без данных)
|
||||
|
||||
4. **bootExtra()** — расширенная загрузка (5 членов совета, дополнительные пайщики)
|
||||
|
||||
## Режимы контрактов
|
||||
|
||||
- **test** — контракты собранные с флагом `test` (позволяют boot с 1 членом совета)
|
||||
- **production** — стандартные контракты (требуют кворум)
|
||||
|
||||
Пути к WASM/ABI файлам указаны в `configs/contracts.ts`, относительно `../contracts/build/contracts/`.
|
||||
|
||||
## Тесты
|
||||
|
||||
- Фреймворк: **Vitest**
|
||||
- Основной тест: `src/tests/capital.test.ts` — полный цикл ЦПП (создание программ, проектов, инвестирование, голосование, конвертация)
|
||||
- Требует: полностью загруженный блокчейн (`pnpm run reboot`), MongoDB, реляционную БД
|
||||
|
||||
### Запуск
|
||||
|
||||
```
|
||||
pnpm --filter @coopenomics/boot test
|
||||
```
|
||||
|
||||
### Важно: Duplicate transaction
|
||||
|
||||
EOSIO отклоняет транзакции с одинаковым хешем (TAPOS block + action data). При повторных вызовах одинаковых действий нужен `await sleep(500)` между ними.
|
||||
|
||||
## Скрипты package.json
|
||||
|
||||
| Скрипт | Описание |
|
||||
|--------|----------|
|
||||
| `boot` | Полная загрузка: инфра + данные + кооператив |
|
||||
| `boot:clean` | Чистая загрузка: только инфра + программы |
|
||||
| `boot:extra` | Расширенная загрузка: инфра + 5 членов совета |
|
||||
| `create-coop` | Создание кооператива после boot:clean |
|
||||
| `test` | Vitest (capital.test.ts, timeout 240s) |
|
||||
| `test:all` | Все тесты |
|
||||
| `clear` | Очистка блокчейна и перезапуск ноды |
|
||||
|
||||
## Зависимости от других компонентов
|
||||
|
||||
- `cooptypes` (workspace) — типы контрактов
|
||||
- `@coopenomics/factory` (workspace) — реестр шаблонов, сохранение данных в MongoDB
|
||||
@@ -0,0 +1,80 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.2.10](https://github.com/coopenomics/mono/compare/v2.2.9...v2.2.10) (2025-03-27)
|
||||
|
||||
**Note:** Version bump only for package @coopenomics/boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.9](https://github.com/coopenomics/mono/compare/v2.2.8...v2.2.9) (2025-03-12)
|
||||
|
||||
**Note:** Version bump only for package @coopenomics/boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.1.8](https://github.com/coopenomics/contracts/compare/boot@0.1.7...boot@0.1.8) (2024-12-24)
|
||||
|
||||
**Note:** Version bump only for package boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.1.7](https://github.com/coopenomics/contracts/compare/boot@0.1.7-alpha.0...boot@0.1.7) (2024-10-16)
|
||||
|
||||
**Note:** Version bump only for package boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.1.6](https://github.com/coopenomics/contracts/compare/boot@0.1.5...boot@0.1.6) (2024-10-13)
|
||||
|
||||
**Note:** Version bump only for package boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.1.5](https://github.com/coopenomics/contracts/compare/boot@0.1.5-alpha.0...boot@0.1.5) (2024-09-26)
|
||||
|
||||
**Note:** Version bump only for package boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.1.4](https://github.com/coopenomics/contracts/compare/boot@0.1.4-alpha.0...boot@0.1.4) (2024-08-13)
|
||||
|
||||
**Note:** Version bump only for package boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.1.3](https://github.com/coopenomics/contracts/compare/boot@0.1.3-alpha.0...boot@0.1.3) (2024-08-13)
|
||||
|
||||
**Note:** Version bump only for package boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.1.2](https://github.com/coopenomics/contracts/compare/boot@0.1.2-alpha.0...boot@0.1.2) (2024-08-10)
|
||||
|
||||
**Note:** Version bump only for package boot
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.1.1](https://github.com/coopenomics/contracts/compare/boot@0.1.1-alpha.1...boot@0.1.1) (2024-08-09)
|
||||
|
||||
**Note:** Version bump only for package boot
|
||||
@@ -0,0 +1,100 @@
|
||||
# 🚀 @coopenomics/boot
|
||||
|
||||
CLI-утилита для инициализации блокчейн-инфраструктуры кооператива. Создаёт системные аккаунты, устанавливает смарт-контракты, настраивает токены и разворачивает кооперативную среду. Включает интеграционные тесты для основных подсистем платформы.
|
||||
|
||||
## Основные возможности
|
||||
|
||||
- Полный цикл развёртывания блокчейна EOSIO — от запуска ноды до создания кооператива
|
||||
- Установка системных и прикладных смарт-контрактов
|
||||
- Инициализация токенов и создание тестовых данных
|
||||
- Управление Docker-контейнерами блокчейн-ноды
|
||||
- Интеграционные тесты: паевые взносы (capital), кошелёк (wallet), регистрация участников (registrator)
|
||||
- Поддержка нескольких режимов загрузки: стандартный, чистый и расширенный
|
||||
|
||||
## Установка
|
||||
|
||||
Компонент является частью монорепозитория. Установка зависимостей из корня проекта:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Или только для этого компонента:
|
||||
|
||||
```bash
|
||||
pnpm install --filter @coopenomics/boot
|
||||
```
|
||||
|
||||
## Скрипты
|
||||
|
||||
| Скрипт | Команда | Описание |
|
||||
|--------|---------|----------|
|
||||
| `boot` | `pnpm run boot` | Запуск блокчейна и установка контрактов |
|
||||
| `boot:clean` | `pnpm run boot:clean` | Чистый запуск с полной переустановкой |
|
||||
| `boot:extra` | `pnpm run boot:extra` | Расширенная инициализация с дополнительными данными |
|
||||
| `deploy` | `pnpm run deploy` | Развёртывание контрактов на запущенной ноде |
|
||||
| `cli` | `pnpm run cli` | Интерактивный командный интерфейс (Commander.js) |
|
||||
| `clear` | `pnpm run clear` | Очистка данных блокчейна |
|
||||
| `start` | `pnpm run start` | Перезапуск ноды |
|
||||
| `stop` | `pnpm run stop` | Остановка ноды |
|
||||
| `test` | `pnpm run test` | Интеграционные тесты (capital) |
|
||||
| `test:all` | `pnpm run test:all` | Все интеграционные тесты |
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Скопируйте `.env-example` в `.env` и настройте переменные окружения:
|
||||
|
||||
- Endpoint блокчейн-ноды (API)
|
||||
- Строка подключения к MongoDB
|
||||
- Строка подключения к реляционной БД
|
||||
- Приватные ключи для развёртывания контрактов
|
||||
|
||||
Подробное описание переменных — в файле `.env-example`.
|
||||
|
||||
## Тестирование
|
||||
|
||||
Перед запуском тестов необходима работающая инфраструктура (см. `docker-compose.yaml` в корне проекта):
|
||||
|
||||
```bash
|
||||
pnpm --filter @coopenomics/boot run test
|
||||
pnpm --filter @coopenomics/boot run test:all
|
||||
```
|
||||
|
||||
Тесты используют **Vitest** с таймаутом 240 секунд — это связано с ожиданием подтверждения блокчейн-транзакций.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Точка входа CLI (Commander.js)
|
||||
├── init/ # Логика инициализации
|
||||
│ ├── booter.ts # Загрузчик блокчейна
|
||||
│ ├── cooperative.ts # Настройка кооператива
|
||||
│ ├── infra.ts # Инфраструктурная инициализация
|
||||
│ └── participant.ts # Создание участников
|
||||
├── blockchain/ # Взаимодействие с EOSIO (eosjs)
|
||||
├── docker/ # Управление Docker-контейнерами
|
||||
├── configs/ # Конфигурация ноды и протокол-фичи
|
||||
├── tests/ # Интеграционные тесты
|
||||
│ ├── capital.test.ts # Тесты паевых взносов
|
||||
│ ├── wallet.test.ts # Тесты кошелька
|
||||
│ └── registrator.test.ts # Тесты регистрации
|
||||
└── utils/ # Вспомогательные утилиты
|
||||
scripts/
|
||||
├── reboot.sh # Полный перезапуск
|
||||
├── clean_reboot.sh # Чистый перезапуск
|
||||
└── extra_reboot.sh # Расширенный перезапуск
|
||||
```
|
||||
|
||||
## Ключевые зависимости
|
||||
|
||||
- **eosjs** — взаимодействие с блокчейном EOSIO
|
||||
- **commander** — CLI-фреймворк
|
||||
- **dockerode** — управление Docker-контейнерами из Node.js
|
||||
- **mongoose** — работа с MongoDB
|
||||
- **pg** — работа с реляционной БД
|
||||
- **vitest** — тестирование
|
||||
|
||||
## Лицензия
|
||||
|
||||
[BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.ru)
|
||||
@@ -0,0 +1,19 @@
|
||||
// @ts-check
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
ignores: [
|
||||
// eslint ignore globs here
|
||||
],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'unused-imports/no-unused-vars': 'off',
|
||||
'no-console': 'off',
|
||||
'unicorn/prefer-number-properties': 'off',
|
||||
// overrides
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.4.6-4",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "",
|
||||
"keywords": [],
|
||||
"scripts": {
|
||||
"enter": "./scripts/enter.sh",
|
||||
"cli": "esno src/index.ts",
|
||||
"deploy": "esno src/index.ts deploy",
|
||||
"cleos": "esno src/index.ts cleos",
|
||||
"unlock": "esno src/index.ts unlock",
|
||||
"boot": "esno src/index.ts boot",
|
||||
"boot:clean": "esno src/index.ts boot:clean",
|
||||
"boot:extra": "esno src/index.ts boot:extra",
|
||||
"reboot:clean": "cd components/boot/scripts && bash clean_reboot.sh",
|
||||
"reboot:extra": "cd components/boot/scripts && bash extra_reboot.sh",
|
||||
"create-coop": "esno src/index.ts create-coop",
|
||||
"clear": "./scripts/clear.sh",
|
||||
"start": "./scripts/restart.sh",
|
||||
"stop": "./scripts/stop.sh",
|
||||
"test": "vitest run src/tests/capital.test.ts --testTimeout=240000",
|
||||
"test:all": "vitest run --testTimeout=240000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@types/pg": "^8.16.0",
|
||||
"axios": "^1.13.5",
|
||||
"chai": "^5.1.2",
|
||||
"commander": "^12.1.0",
|
||||
"cooptypes": "workspace:*",
|
||||
"dockerode": "^4.0.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"eosjs": "^22.1.0",
|
||||
"eosjs-api": "^7.0.4",
|
||||
"eosjs-ecc": "^4.0.7",
|
||||
"execa": "^9.5.2",
|
||||
"mocha": "^10.7.3",
|
||||
"mongoose": "^9.2.2",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^2.18.0",
|
||||
"@antfu/ni": "^0.21.12",
|
||||
"@antfu/utils": "^0.7.8",
|
||||
"@types/dockerode": "^3.3.31",
|
||||
"@types/node": "^20.12.12",
|
||||
"bumpp": "^9.4.1",
|
||||
"eslint": "^9.2.0",
|
||||
"esno": "^4.7.0",
|
||||
"pnpm": "^9.1.1",
|
||||
"rimraf": "^5.0.7",
|
||||
"simple-git-hooks": "^2.11.1",
|
||||
"typescript": "^5.4.5",
|
||||
"unbuild": "^2.0.0",
|
||||
"vite": "^5.2.11",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Определение ассоциативных массивов
|
||||
typeset -A contract_params_test contract_params_prod
|
||||
contract_params_test=(
|
||||
[ano]="ano"
|
||||
[gateway]="gateway"
|
||||
[draft]="draft"
|
||||
[marketplace]="marketplace"
|
||||
[soviet]="soviet"
|
||||
[registrator]="registrator"
|
||||
[system]="eosio"
|
||||
[fund]="fund"
|
||||
)
|
||||
contract_params_prod=(
|
||||
[ano]="ano"
|
||||
[gateway]="gateway"
|
||||
[draft]="draft"
|
||||
[marketplace]="marketplace"
|
||||
[soviet]="soviet"
|
||||
[registrator]="registrator"
|
||||
[system]="eosio"
|
||||
[fund]="fund"
|
||||
[template]="template"
|
||||
)
|
||||
|
||||
# Выбор массива параметров в зависимости от входящего аргумента
|
||||
if [ "$2" = "prod" ]; then
|
||||
contract_params=("${(@kv)contract_params_prod}")
|
||||
elif [ "$2" = "test" ]; then
|
||||
contract_params=("${(@kv)contract_params_test}")
|
||||
else
|
||||
contract_params=("${(@kv)contract_params_test}")
|
||||
fi
|
||||
|
||||
# Форматирование строки для переменной окружения
|
||||
contract_params_str=""
|
||||
for key value in "${(@kv)contract_params}"; do
|
||||
contract_params_str+="${key}=${value},"
|
||||
done
|
||||
|
||||
# Проверяем, задан ли аргумент для контрактов
|
||||
if [ -n "$1" ]; then
|
||||
contracts=$1
|
||||
else
|
||||
contracts="*"
|
||||
fi
|
||||
|
||||
# Запуск контейнера Docker
|
||||
docker run --rm --name cdt_v4.1.0 \
|
||||
--volume $(pwd):/project \
|
||||
-w /project \
|
||||
--env CONTRACT_PARAMS="$contract_params_str" \
|
||||
dicoop/cdt_v4.1.0 /bin/bash -c "
|
||||
|
||||
cd ../cpp
|
||||
|
||||
# Перебираем все папки в директории
|
||||
for folder in $contracts ; do
|
||||
# Убираем слэш из названия папки, чтобы получить имя контракта
|
||||
contract=\${folder%/}
|
||||
contract=\${contract##*/}
|
||||
|
||||
echo \"Сборка контракта \$contract в \$folder...\"
|
||||
|
||||
# Переходим в директорию контракта
|
||||
cd \$folder
|
||||
|
||||
# Извлекаем параметр для -contract из переменной окружения
|
||||
contract_param=\$(echo \$CONTRACT_PARAMS | grep -o \"\$contract=[^,]*\" | cut -d'=' -f2)
|
||||
|
||||
# Компилируем с использованием параметра -contract
|
||||
cdt-cpp -abigen -I include -R include -contract \$contract_param -o \$contract.wasm \$contract.cpp
|
||||
|
||||
# Возвращаемся назад
|
||||
cd ..
|
||||
|
||||
echo \"Сборка контракта \$contract завершена!\"
|
||||
done
|
||||
"
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
cd ../system/build && cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON -Dcdt_DIR="/cdt/build/lib/cmake/cdt" -Dleap_DIR="/blockchain/build/lib/cmake/leap" .. && make -j 2
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker exec mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
echo "MongoDB готов!"
|
||||
|
||||
# Ждем готовности PostgreSQL
|
||||
echo "Ждем готовности PostgreSQL..."
|
||||
until docker exec postgres pg_isready -U postgres -d voskhod > /dev/null 2>&1; do
|
||||
echo "PostgreSQL еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:clean
|
||||
|
||||
# Запускаем parser
|
||||
echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose restart coopback || true
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker exec mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
echo "MongoDB готов!"
|
||||
|
||||
# Ждем готовности PostgreSQL
|
||||
echo "Ждем готовности PostgreSQL..."
|
||||
until docker exec postgres pg_isready -U postgres -d voskhod > /dev/null 2>&1; do
|
||||
echo "PostgreSQL еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Передача аргументов cleos
|
||||
docker exec -it node /usr/local/bin/cleos "$@"
|
||||
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Импорт параметров сетей
|
||||
source ./networks.sh
|
||||
|
||||
# Устанавливаем значения по умолчанию
|
||||
network_param=$local
|
||||
account_name_param=""
|
||||
network=""
|
||||
|
||||
# Получаем первый аргумент, который должен быть именем контракта
|
||||
contract_name=$1
|
||||
|
||||
rm /Users/darksun/testnet/wallet/keosd.sock
|
||||
./kill.sh
|
||||
|
||||
# Проверяем наличие параметра -n и устанавливаем соответствующую сеть
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n)
|
||||
network=$2
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
-t)
|
||||
account_name_param=$2
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Устанавливаем значение сети из networks.sh
|
||||
case $network in
|
||||
prod)
|
||||
network_param=$prod
|
||||
;;
|
||||
test)
|
||||
network_param=$test
|
||||
;;
|
||||
local)
|
||||
network_param=$local
|
||||
;;
|
||||
esac
|
||||
|
||||
# Разблокировка кошелька
|
||||
./unlock.sh
|
||||
|
||||
# Функция для установки контракта
|
||||
deploy_contract() {
|
||||
local contract=$1
|
||||
local dir=$2
|
||||
local account_name=$3
|
||||
echo "Устанавливаем контракт $contract на аккаунт $account_name..."
|
||||
if [ -z "$account_name_param" ]; then
|
||||
echo ./cleos.sh $network_param set contract $account_name $dir -p $account_name
|
||||
./cleos.sh $network_param set contract $account_name $dir -p $account_name
|
||||
else
|
||||
echo ./cleos.sh $network_param set contract $account_name_param $dir -p $account_name_param
|
||||
./cleos.sh $network_param set contract $account_name_param $dir -p $account_name_param
|
||||
fi
|
||||
}
|
||||
|
||||
# Если контракт указан, деплоим только его
|
||||
if [ ! -z "$contract_name" ]; then
|
||||
ls ./
|
||||
echo "Проверка наличия папки для контракта $contract_name..."
|
||||
if [ -d "./$contract_name" ]; then
|
||||
echo "Папка контракта найдена. Содержимое папки:"
|
||||
deploy_contract $contract_name "/contracts/cpp/$contract_name" $contract_name
|
||||
elif [[ "$contract_name" == "eosio.msig" || "$contract_name" == "eosio.token" || "$contract_name" == "eosio.wrap" ]]; then
|
||||
echo "Папка системного контракта найдена. Содержимое папки:"
|
||||
ls "/contracts/cpp/system/build/contracts/$contract_name"
|
||||
deploy_contract $contract_name "/contracts/cpp/system/build/contracts/$contract_name" $contract_name
|
||||
elif [ "$contract_name" == "eosio.system" ]; then
|
||||
echo "Папка системного контракта найдена. Содержимое папки:"
|
||||
ls "/contracts/cpp/system/build/contracts/$contract_name"
|
||||
deploy_contract $contract_name "/contracts/cpp/system/build/contracts/$contract_name" "eosio"
|
||||
elif [ "$contract_name" == "eosio.boot" ]; then
|
||||
echo "Папка системного контракта найдена. Содержимое папки:"
|
||||
ls "/contracts/cpp/system/build/contracts/$contract_name"
|
||||
deploy_contract $contract_name "/contracts/cpp/system/build/contracts/$contract_name" "eosio"
|
||||
else
|
||||
echo "Контракт $contract_name не найден"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Перебираем все папки с контрактами на верхнем уровне, исключая 'system'
|
||||
for dir in $(find /contracts/cpp/* -maxdepth 0 -type d ! -name 'system'); do
|
||||
contract=$(basename $dir)
|
||||
echo "Найдена папка $dir для контракта $contract. Содержимое папки:"
|
||||
ls $dir
|
||||
deploy_contract $contract $dir $contract
|
||||
done
|
||||
|
||||
# Исключение для контрактов в папке system
|
||||
special_system_contracts=("eosio.msig" "eosio.token" "eosio.wrap")
|
||||
for contract in "${special_system_contracts[@]}"; do
|
||||
echo "Найдена папка системного контракта для $contract. Содержимое папки:"
|
||||
ls "/contracts/cpp/system/build/contracts/$contract"
|
||||
deploy_contract $contract "/contracts/cpp/system/build/contracts/$contract" $contract
|
||||
done
|
||||
|
||||
deploy_contract "eosio.system" "/contracts/cpp/system/build/contracts/eosio.system" "eosio"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
docker run --rm -it --name cdt \
|
||||
--volume $(pwd)/:/project \
|
||||
-w /project \
|
||||
dicoop/blockchain_v5.1.1:dev /bin/bash
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user