mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
589
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e36c02ade | ||
|
|
b411c5bef8 | ||
|
|
ae6e25d1b9 | ||
|
|
839f9eb56c | ||
|
|
490b10e07a | ||
|
|
74aa1323d8 | ||
|
|
2fc5a67dce | ||
|
|
8a170eb2ac | ||
|
|
208ecb826e | ||
|
|
ec1ce79be9 | ||
|
|
faaaf399cb | ||
|
|
d10da0f5fc | ||
|
|
b4cbba1e9a | ||
|
|
ad4595a257 | ||
|
|
d2163cf6a0 | ||
|
|
fe1391903a | ||
|
|
069f537bde | ||
|
|
235a15b9cc | ||
|
|
beec2f523a | ||
|
|
690b79d2b7 | ||
|
|
d3e9803069 | ||
|
|
ce6bfe282f | ||
|
|
60dbb461e7 | ||
|
|
1ca0c658b9 | ||
|
|
b2705b8788 | ||
|
|
cfeb4b6a4a | ||
|
|
bd609c3c54 | ||
|
|
dab4dd4a4e | ||
|
|
5814b89821 | ||
|
|
c3337bdedb | ||
|
|
c11079260d | ||
|
|
7ee09ac790 | ||
|
|
b07c25aa7e | ||
|
|
6eb7e1a81f | ||
|
|
6626b1ce7d | ||
|
|
b2a426bb30 | ||
|
|
d89a6e33f3 | ||
|
|
0684f4719e | ||
|
|
2b272d230c | ||
|
|
7b2f7064b9 | ||
|
|
4c0c89c5ff | ||
|
|
d1b1f58f00 | ||
|
|
793f23a390 | ||
|
|
2efb53aab1 | ||
|
|
80d6bc8da6 | ||
|
|
1299ea80b8 | ||
|
|
bca681b5ea | ||
|
|
e2817eba39 | ||
|
|
9fbff1e8e5 | ||
|
|
0c4fac37b0 | ||
|
|
92d2662078 | ||
|
|
ea1cb67502 | ||
|
|
2c52c265cf | ||
|
|
5df5af7ca8 | ||
|
|
1d1d9bb1c4 | ||
|
|
a6f2c2829e | ||
|
|
2a967e84c5 | ||
|
|
0065d07fe3 | ||
|
|
16f28ec5c7 | ||
|
|
0273558edc | ||
|
|
97bab72eb9 | ||
|
|
70c823ae9f | ||
|
|
229bbf8eb8 | ||
|
|
767f9e7fe3 | ||
|
|
a76b742294 | ||
|
|
9d04681a42 | ||
|
|
775dd8126f | ||
|
|
0f4671afd0 | ||
|
|
daa39fd7ec | ||
|
|
a28e2bf9f6 | ||
|
|
c355fc6888 | ||
|
|
d32d857eb3 | ||
|
|
cf4e85c6ee | ||
|
|
fc73433ffb | ||
|
|
8239a96509 | ||
|
|
49e8d6ecc5 | ||
|
|
558999faa8 | ||
|
|
c9e8aeee5c | ||
|
|
1da7c1da67 | ||
|
|
fb71ee7282 | ||
|
|
96362a2835 | ||
|
|
3c692c4321 | ||
|
|
5b63fd27fe | ||
|
|
5b769adcc5 | ||
|
|
073ddf1421 | ||
|
|
fbdd647168 | ||
|
|
18aed41889 | ||
|
|
f4aa894fe7 | ||
|
|
a7024c8f72 | ||
|
|
820bafa11f | ||
|
|
82a9d0ee9e | ||
|
|
5a3a0a189c | ||
|
|
fd05ea0e9c | ||
|
|
cf6c0ae873 | ||
|
|
f3daaf7b15 | ||
|
|
992fc0fed7 | ||
|
|
68d47ec507 | ||
|
|
b38211469a | ||
|
|
9bd12d70e9 | ||
|
|
c0be963f9c | ||
|
|
74e8d89081 | ||
|
|
8468f8505a | ||
|
|
8be251447a | ||
|
|
008e1d7805 | ||
|
|
310071df1b | ||
|
|
87aa7b3b4c | ||
|
|
187138bec5 | ||
|
|
2776f3c37a | ||
|
|
2b981a5734 | ||
|
|
89c51f0f74 | ||
|
|
e6b642fc19 | ||
|
|
512f62c22b | ||
|
|
db84455c34 | ||
|
|
7c0f77499c | ||
|
|
d30e06b268 | ||
|
|
4ac3d2c868 | ||
|
|
8363b221bb | ||
|
|
9f67d7ab76 | ||
|
|
c2325a8b6e | ||
|
|
06a6a7edf3 | ||
|
|
1a10b38743 | ||
|
|
9c97eeb597 | ||
|
|
5b0f629b6e | ||
|
|
a80b035a0a | ||
|
|
d4b6df4f63 | ||
|
|
7122cbccf8 | ||
|
|
8c20af6474 | ||
|
|
8c03ac15ab | ||
|
|
1163ccffe4 | ||
|
|
b1e79cf86d | ||
|
|
2aee5379b5 | ||
|
|
68ce13ead4 | ||
|
|
5dcaca4d0b | ||
|
|
bb36af08fe | ||
|
|
897c18bd08 | ||
|
|
b69111dda3 | ||
|
|
a025d01294 | ||
|
|
9d061a0f53 | ||
|
|
511b8b7d97 | ||
|
|
30083b96c1 | ||
|
|
d6dce12acc | ||
|
|
5606d5d553 | ||
|
|
1072e5f1de | ||
|
|
c419f3e9b2 | ||
|
|
637ec0f901 | ||
|
|
7ff2092547 | ||
|
|
1272c0e8e4 | ||
|
|
0d6beeba47 | ||
|
|
7987a5d925 | ||
|
|
6671e3493e | ||
|
|
4730faea0c | ||
|
|
dc129b2fad | ||
|
|
9b17fa2b93 | ||
|
|
abfc5ff8d5 | ||
|
|
9aeb23e5da | ||
|
|
d9bce1e39e | ||
|
|
c997fd8ea7 | ||
|
|
75ac8bb491 | ||
|
|
e09e1c5f80 | ||
|
|
71f78c14a3 | ||
|
|
7c0d7faa24 | ||
|
|
0f1fbb8fe5 | ||
|
|
1915250529 | ||
|
|
4d75f8d402 | ||
|
|
cc8f590bf4 | ||
|
|
384a185a2e | ||
|
|
285a0018c9 | ||
|
|
930223019e | ||
|
|
4c252a0763 | ||
|
|
bd6bf480da | ||
|
|
2f4e0c4418 | ||
|
|
4caf585048 | ||
|
|
a25f8c8611 | ||
|
|
6cf3ab7007 | ||
|
|
687c6c15f5 | ||
|
|
59cd193b65 | ||
|
|
7bd408b45e | ||
|
|
00ce7cdca6 | ||
|
|
1c5210c210 | ||
|
|
b5a0d43c96 | ||
|
|
c17c582818 | ||
|
|
d3c618a7ad | ||
|
|
021ee1725b | ||
|
|
ffc79ea0e7 | ||
|
|
0cfa048d42 | ||
|
|
5e633ac2f1 | ||
|
|
5e6b06f009 | ||
|
|
0be4fd77e5 | ||
|
|
03f2616f30 | ||
|
|
244d637fac | ||
|
|
71db8e57f5 | ||
|
|
16092ec967 | ||
|
|
42dc83df80 | ||
|
|
3c9229087b | ||
|
|
15a8215a4f | ||
|
|
499d219933 | ||
|
|
1e4b586fa3 | ||
|
|
e3c18db7b8 | ||
|
|
aa59cd1992 | ||
|
|
c0138c3fe0 | ||
|
|
a186bde2a9 | ||
|
|
e9450db83b | ||
|
|
bdf5e3011f | ||
|
|
48c07f9ab3 | ||
|
|
f6240d0911 | ||
|
|
6e74db1f9e | ||
|
|
bfbd41d64b | ||
|
|
d9e1829fcf | ||
|
|
dfb6037797 | ||
|
|
02e50211f4 | ||
|
|
3a689b379a | ||
|
|
e4c2ccd58f | ||
|
|
417ee87422 | ||
|
|
d7eb647c09 | ||
|
|
08651b5917 | ||
|
|
74bd19c063 | ||
|
|
dfe52df7bb | ||
|
|
40a6142e3e | ||
|
|
b415d16408 | ||
|
|
09c667252c | ||
|
|
9ff090c30b | ||
|
|
9fe1819ea5 | ||
|
|
cc92e851ad | ||
|
|
40f1cf6332 | ||
|
|
870e1f2125 | ||
|
|
70305b7937 | ||
|
|
3512042cf3 | ||
|
|
4a9c241eee | ||
|
|
3883c1932c | ||
|
|
feb0e46aab | ||
|
|
5e0ab3423f | ||
|
|
e4a38e803c | ||
|
|
95cd830c01 | ||
|
|
ddc03eaf30 | ||
|
|
b5d8cde93f | ||
|
|
4e8a833d62 | ||
|
|
3add08b100 | ||
|
|
f2b28968a0 | ||
|
|
599cf3ed22 | ||
|
|
ff28357d7a | ||
|
|
8b4c8ad337 | ||
|
|
07b030d045 | ||
|
|
7f799c8490 | ||
|
|
91a5108b85 | ||
|
|
90c8188952 | ||
|
|
198ea76674 | ||
|
|
5758cd94b6 | ||
|
|
0e8fe54ae3 | ||
|
|
5e1455c3d6 | ||
|
|
eab1a42e07 | ||
|
|
fdfd77df9a | ||
|
|
58f2ad9810 | ||
|
|
3cc99a1ea6 | ||
|
|
15781bb038 | ||
|
|
9ec12ac7d7 | ||
|
|
2559c0478c | ||
|
|
07fcdb0d4e | ||
|
|
40407e52d6 | ||
|
|
2898fdf5d3 | ||
|
|
bb4c82ea5a | ||
|
|
1f2730a64f | ||
|
|
43238feada | ||
|
|
573274875c | ||
|
|
2e27c4f499 | ||
|
|
8029ad3b2d | ||
|
|
eca5cdcd9e | ||
|
|
523293d4d9 | ||
|
|
5d20f8fc84 | ||
|
|
e27ad5fdf9 | ||
|
|
5b1f7d796d | ||
|
|
0874c26208 | ||
|
|
1e7b8f08cf | ||
|
|
7c2793752d | ||
|
|
5cde390c24 | ||
|
|
46ebb13fda | ||
|
|
4775a5fd06 | ||
|
|
7e89e7fb6f | ||
|
|
95ea073b77 | ||
|
|
e135d37878 | ||
|
|
2dedb44372 | ||
|
|
97a6c32838 | ||
|
|
a56fcbd785 | ||
|
|
e16603bbd0 | ||
|
|
dfe132699e | ||
|
|
f793ec33b3 | ||
|
|
ce8bddad8f | ||
|
|
3fe0c85055 | ||
|
|
9da48d5387 | ||
|
|
2bd9bc6138 | ||
|
|
a274fe74e1 | ||
|
|
ea5c35198f | ||
|
|
0f6f3bbb8a | ||
|
|
448eb9223d | ||
|
|
cc0be69019 | ||
|
|
9c627f4c16 | ||
|
|
118154a43a | ||
|
|
b29f4db52c | ||
|
|
5efa257a36 | ||
|
|
09ec6d6ec5 | ||
|
|
8cacdc5112 | ||
|
|
d4394f1190 | ||
|
|
6feb9c2762 | ||
|
|
79c6bd1ac4 | ||
|
|
4e94092730 | ||
|
|
82629a57c6 | ||
|
|
92f607a864 | ||
|
|
245483cb35 | ||
|
|
f04fc68513 | ||
|
|
49eb594e5b | ||
|
|
ed84a476c6 | ||
|
|
eb77ca566c | ||
|
|
262357acef | ||
|
|
9e727f9a13 | ||
|
|
d35e8caa2b | ||
|
|
b9c073e26f | ||
|
|
b160fecac4 | ||
|
|
dab727f90f | ||
|
|
0e36df8ae3 | ||
|
|
f7d3516c24 | ||
|
|
e6665d0803 | ||
|
|
6fd4654c21 | ||
|
|
b5c99fad2a | ||
|
|
f588f7f8d4 | ||
|
|
4fe83cbcbc | ||
|
|
069a050f39 | ||
|
|
577615803b | ||
|
|
680ade5975 | ||
|
|
6914645c19 | ||
|
|
1d0b6b9a62 | ||
|
|
0482f4ae08 | ||
|
|
16ce748ffa | ||
|
|
2c199028ea | ||
|
|
22b16a7ac2 | ||
|
|
f2ccde0dd6 | ||
|
|
dd80d49989 | ||
|
|
e3d5f434a4 | ||
|
|
11b8a0ff97 | ||
|
|
e54b292fce | ||
|
|
41222343e3 | ||
|
|
4676f1c820 | ||
|
|
e49b82842d | ||
|
|
e0a39e5630 | ||
|
|
21b050b220 | ||
|
|
9bed1223fd | ||
|
|
4735f0725d | ||
|
|
1f27617a27 | ||
|
|
ee460d677d | ||
|
|
f2ca465ec6 | ||
|
|
40e6961711 | ||
|
|
1bb51988ac | ||
|
|
2c30fa4a94 | ||
|
|
a22186ca01 | ||
|
|
746b11ff65 | ||
|
|
de51741835 | ||
|
|
a633c42904 | ||
|
|
7522424517 | ||
|
|
57f15d7ba4 | ||
|
|
1af672aa1d | ||
|
|
41f8f493aa | ||
|
|
8be95c140a | ||
|
|
add0088704 | ||
|
|
037f06808e | ||
|
|
01456818a8 | ||
|
|
5c3182fb56 | ||
|
|
f515b2db80 | ||
|
|
eb2c2b5aec | ||
|
|
6f181f357b | ||
|
|
1bcc152368 | ||
|
|
26df9d2caa | ||
|
|
45adb1e88c | ||
|
|
6f5b8ce68d | ||
|
|
16a249d5bf | ||
|
|
a52e4320ec | ||
|
|
e8cabb7196 | ||
|
|
7e82ad89ff | ||
|
|
2219b58000 | ||
|
|
3667b4733a | ||
|
|
490e9c0e5f | ||
|
|
7e87201c24 | ||
|
|
e9653c13eb | ||
|
|
255cc4c325 | ||
|
|
fc05b229f1 | ||
|
|
da4599f219 | ||
|
|
a7620d3cdc | ||
|
|
2e48b728dd | ||
|
|
130dca4261 | ||
|
|
2afd7aab38 | ||
|
|
622d372776 | ||
|
|
790c2834f2 | ||
|
|
71dfb8ca24 | ||
|
|
81e419dad4 | ||
|
|
93271730eb | ||
|
|
5d0985cafc | ||
|
|
6ade75e4cd | ||
|
|
86eefab9f5 | ||
|
|
7701010fee | ||
|
|
b3dc262862 | ||
|
|
f0fb52ab74 | ||
|
|
ed4fe3230d | ||
|
|
8217acef3f | ||
|
|
019737286f | ||
|
|
d90ac998aa | ||
|
|
781b98b1c1 | ||
|
|
4986c9af99 | ||
|
|
4868ed2d63 | ||
|
|
2510d47023 | ||
|
|
0d28a3ac3c | ||
|
|
f4ee2005e3 | ||
|
|
4b85367d4e | ||
|
|
433cc3c712 | ||
|
|
ab5162e581 | ||
|
|
3b8058de81 | ||
|
|
f4916594b8 | ||
|
|
4c01f6a789 | ||
|
|
076fb933dc | ||
|
|
bcabad567e | ||
|
|
414ef0bc8b | ||
|
|
9d807fc6d6 | ||
|
|
c43f752eaf | ||
|
|
6e8290cc08 | ||
|
|
bbd3cc7fdb | ||
|
|
90c742ace6 | ||
|
|
0d9573b949 | ||
|
|
b1f63c9cac | ||
|
|
76099d2a42 | ||
|
|
60eb2389a3 | ||
|
|
86a648e759 | ||
|
|
66c066616b | ||
|
|
30f552847a | ||
|
|
b75e2da480 | ||
|
|
eb09413d8b | ||
|
|
05ae607a6a | ||
|
|
f6eb55163f | ||
|
|
b0595a6502 | ||
|
|
2399fb9d15 | ||
|
|
41b9eb1005 | ||
|
|
2f70db4b7e | ||
|
|
25d98d2ab3 | ||
|
|
f56605600f | ||
|
|
0617552956 | ||
|
|
ff784cc772 | ||
|
|
5b5b2c28f7 | ||
|
|
101886e522 | ||
|
|
276c36b6e9 | ||
|
|
45f0b94971 | ||
|
|
5d9a6cdff6 | ||
|
|
f2833eec33 | ||
|
|
4badc2e9c1 | ||
|
|
05b6bf8c04 | ||
|
|
3f6203f125 | ||
|
|
ef0baaa30f | ||
|
|
aef5d57776 | ||
|
|
d76902cebe | ||
|
|
0fa96521c5 | ||
|
|
61232e3537 | ||
|
|
014b18caf8 | ||
|
|
8a51e9cf4d | ||
|
|
fb3fe09466 | ||
|
|
a106851cdc | ||
|
|
687a384fe4 | ||
|
|
b2daf26e6c | ||
|
|
d0604f892b | ||
|
|
d05a529c52 | ||
|
|
cdfa9860bb | ||
|
|
9877946d68 | ||
|
|
3e7b65264e | ||
|
|
54557a79c5 | ||
|
|
412c291193 | ||
|
|
94692dc418 | ||
|
|
6733aed3fb | ||
|
|
2235294c59 | ||
|
|
5bea930d05 | ||
|
|
385affbcaf | ||
|
|
c5c6759868 | ||
|
|
02c407ef9f | ||
|
|
dd5e3b876e | ||
|
|
acf711fbb0 | ||
|
|
aeb0c3f5f7 | ||
|
|
0a5d0c374b | ||
|
|
e9974c0f21 | ||
|
|
1228baa9a3 | ||
|
|
098aceaf10 | ||
|
|
fd43a0d238 | ||
|
|
dc3205148e | ||
|
|
372c1bffe1 | ||
|
|
b4338a209c | ||
|
|
4cbc30f627 | ||
|
|
44abeace88 | ||
|
|
1c7a32113a | ||
|
|
68a3c11d41 | ||
|
|
c0fd21a665 | ||
|
|
12b52a854e | ||
|
|
ccd5232e70 | ||
|
|
567a27f0c4 | ||
|
|
b019c37d3d | ||
|
|
dec3ad9430 | ||
|
|
9729743a29 | ||
|
|
a4e4621eaa | ||
|
|
4337555ea1 | ||
|
|
9018397b09 | ||
|
|
1c9aae5909 | ||
|
|
6489fa4048 | ||
|
|
22850b7cd0 | ||
|
|
f65c8fb9a0 | ||
|
|
3232da71e2 | ||
|
|
c9fc4f1c8f | ||
|
|
0c56c56f74 | ||
|
|
2928b9b7c5 | ||
|
|
1fc6971278 | ||
|
|
2679851d66 | ||
|
|
ab86cd2b1c | ||
|
|
02fce63e11 | ||
|
|
8981a018b0 | ||
|
|
bbf5df6d01 | ||
|
|
6f68cf74a7 | ||
|
|
07f501a67a | ||
|
|
4caf06aafc | ||
|
|
4901d76ebe | ||
|
|
6b0dd4e9cf | ||
|
|
a2c38ad4e8 | ||
|
|
438ccbdc7f | ||
|
|
d6c5430e0c | ||
|
|
d48b731ea4 | ||
|
|
bccb6527a7 | ||
|
|
00cf0f59ec | ||
|
|
3a823bee01 | ||
|
|
98650b9bfb | ||
|
|
a781af5b6a | ||
|
|
f202a0975a | ||
|
|
ea4280d184 | ||
|
|
c519ee1389 | ||
|
|
c61b9dbe60 | ||
|
|
77f4aa6296 | ||
|
|
86a9084d56 | ||
|
|
43def44136 | ||
|
|
8c1be7210e | ||
|
|
24e290746d | ||
|
|
d52931a326 | ||
|
|
73cc019d2f | ||
|
|
41151d47f0 | ||
|
|
3fbd9a995d | ||
|
|
af299bc08d | ||
|
|
93fa12200f | ||
|
|
35f769ad25 | ||
|
|
732cee4837 | ||
|
|
681e78967f | ||
|
|
d2bd1382ba | ||
|
|
c0ec9a3890 | ||
|
|
4221b96431 | ||
|
|
3ddbe2ddb3 | ||
|
|
7ceb786143 | ||
|
|
174d9d5b07 | ||
|
|
a1118d08dd | ||
|
|
379d2575a0 | ||
|
|
e30dcc6fff | ||
|
|
281a5ed97e | ||
|
|
6004f861fe | ||
|
|
174c798d4b | ||
|
|
14b8b5ac61 | ||
|
|
89dec572bb | ||
|
|
f662880c8d | ||
|
|
90cd2770b3 | ||
|
|
69240e7917 | ||
|
|
e1d9c1e414 | ||
|
|
376eaeba52 | ||
|
|
80983662aa | ||
|
|
9840f22688 | ||
|
|
b9e2acaaa5 | ||
|
|
7ac8cd3781 | ||
|
|
7e2f10e842 | ||
|
|
256407b9fd | ||
|
|
7979dfaa16 | ||
|
|
10a87d47da | ||
|
|
59709280da | ||
|
|
81b656f0ab | ||
|
|
f966c5b965 | ||
|
|
56a26848e6 | ||
|
|
28f21bfb70 | ||
|
|
7b4724845f | ||
|
|
2053423864 | ||
|
|
e7519975ec | ||
|
|
7b7389526b | ||
|
|
a464b61d39 | ||
|
|
88ca4563a8 | ||
|
|
449bd68fab | ||
|
|
59256e77d1 | ||
|
|
f11d91bd6b | ||
|
|
e0278d0331 | ||
|
|
2baa2e50ac |
No files matched your search
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
|
||||
"runtime": {
|
||||
"version": "Lua5.2",
|
||||
"requirePattern": [ "?", "?.lua" ]
|
||||
},
|
||||
"workspace": {
|
||||
"$comment-moduleMap": "Rewrites each file's own module path rather than the require string",
|
||||
"$comment-library": "The factorio typedefs are provided by a second config, generated with fmtk luals-addon in ci",
|
||||
"$comment-templates": "The create templates contain __plugin_name__ placeholders and are not valid lua",
|
||||
"ignoreGlobs": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"database/**",
|
||||
"docs/**",
|
||||
"external_plugins/**",
|
||||
"factorio/**",
|
||||
"instances/**",
|
||||
"logs/**",
|
||||
"mods/**",
|
||||
"modules/**",
|
||||
"temp/**",
|
||||
"packages/create/templates/**"
|
||||
],
|
||||
"moduleMap": [
|
||||
{ "pattern": "^packages\\.host\\.modules\\.(.+)$", "replace": "modules.$1" },
|
||||
{ "pattern": "^plugins\\.([^.]+)\\.module\\.(.+)$", "replace": "modules.$1.$2" },
|
||||
{ "pattern": "^packages\\.host\\.lua\\.export\\.(.+)$", "replace": "$1" },
|
||||
{ "pattern": "^packages\\.host\\.lua\\.clusterio_lib\\.(.+)$", "replace": "$1" }
|
||||
]
|
||||
},
|
||||
"doc": {
|
||||
"privateName": [ "__*" ]
|
||||
},
|
||||
"$comment-disable": "Same set as ExpCluster, these are editor level hints rather than ci checks",
|
||||
"$comment-unnecessary-if": "Disabled by the config fmtk generates for itself, the api union types make it unreliable",
|
||||
"diagnostics": {
|
||||
"globals": [ "__DebugAdapter", "__Profiler" ],
|
||||
"disable": [
|
||||
"assign-type-mismatch",
|
||||
"param-type-mismatch",
|
||||
"preferred-local-alias",
|
||||
"unnecessary-assert",
|
||||
"unnecessary-if",
|
||||
"unused"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
dist/
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Install dependencies
|
||||
description: Set up pnpm and Node.js, restore caches and run pnpm install
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version passed to actions/setup-node
|
||||
required: true
|
||||
ignore-scripts:
|
||||
description: Skip the prepare scripts that build the workspace packages
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
# The lockfile is not committed, so the usual lockfile hash key does not
|
||||
# apply. Instead a new store snapshot is saved whenever a package.json
|
||||
# changes or a week passes, and every run restores the most recent one.
|
||||
- id: store
|
||||
shell: bash
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
|
||||
restore-keys: pnpm-store-${{ runner.os }}-
|
||||
|
||||
# The prepare scripts run tsc --build and webpack, both incremental when
|
||||
# their previous output is present. Every run saves its output and the
|
||||
# next run starts from the most recent one.
|
||||
- if: inputs.ignore-scripts != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
packages/*/dist
|
||||
plugins/*/dist
|
||||
packages/*/node_modules/.cache
|
||||
plugins/*/node_modules/.cache
|
||||
key: build-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: build-${{ runner.os }}-
|
||||
|
||||
- shell: bash
|
||||
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
|
||||
# Drop packages the restored store had but this install did not use, so
|
||||
# the snapshot does not grow with every version bump.
|
||||
- shell: bash
|
||||
run: pnpm store prune
|
||||
@@ -1,10 +1,15 @@
|
||||
Describe why and what has been changed by this pullrequest, feel free to also ask for input on decisions you are unsure about etc.
|
||||
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferrentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
|
||||
<!--
|
||||
Describe why and what has been changed by this pull request, feel free to also ask for input on decisions you are unsure about etc.
|
||||
|
||||
## Changelog
|
||||
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
|
||||
|
||||
If there are no relevant changelog entries replace the code block below with "None." followed by an optional reason for why it is not needed.
|
||||
-->
|
||||
|
||||
### Changelog
|
||||
```
|
||||
### Features
|
||||
- Added doodad to web interface. [#999](https://github.com/clusterio/clusterio/issues/999)
|
||||
- Added doodad to web interface. #999
|
||||
|
||||
### Changes
|
||||
- ...
|
||||
@@ -12,6 +17,6 @@ The changelog lines below will be copied directly from merged pull requests to t
|
||||
### Fixes
|
||||
- ...
|
||||
|
||||
### Breaking changes
|
||||
### Breaking Changes
|
||||
- ...
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Lua Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
EMMYLUA_CHECK_VERSION: "0.25.1"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Generate Factorio typedefs
|
||||
run: |
|
||||
npm install --prefix "$RUNNER_TEMP/fmtk" factoriomod-debug
|
||||
"$RUNNER_TEMP/fmtk/node_modules/.bin/fmtk" luals-addon "$RUNNER_TEMP"
|
||||
- name: Install emmylua_check
|
||||
run: |
|
||||
curl -fsSL "https://github.com/EmmyLuaLs/emmylua-analyzer-rust/releases/download/${EMMYLUA_CHECK_VERSION}/emmylua_check-linux-x64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP"
|
||||
- name: Run Lint Report
|
||||
shell: bash
|
||||
run: |
|
||||
# The typedefs are generated per run, so their path is passed via a second config
|
||||
jq -n --arg lib "$RUNNER_TEMP/factorio/library" \
|
||||
'{ workspace: { library: [ $lib ] } }' \
|
||||
> "$RUNNER_TEMP/ci.emmyrc.json"
|
||||
|
||||
# Exits non zero on error severity, so the gate below is used instead
|
||||
"$RUNNER_TEMP/emmylua_check" . --config ".emmyrc.json,$RUNNER_TEMP/ci.emmyrc.json" \
|
||||
--output-format json --output check.json || true
|
||||
|
||||
# Credit to https://github.com/Krealle/luals-check-action/blob/main/action.yml
|
||||
# Although some minor fixes were needed
|
||||
|
||||
# Format and print the messages (would be nice to format as a table, but that's too complex for jq)
|
||||
jq -r --arg root "$PWD/" \
|
||||
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
|
||||
{
|
||||
file: $file,
|
||||
code: .code,
|
||||
line: .range.start.line,
|
||||
message: (.message | split("\n")
|
||||
| map(if startswith("- ") then .[2:] else . end)
|
||||
| join("\n")),
|
||||
severity: (if .severity <= 1 then "ERR" else "WARN" end)
|
||||
} |
|
||||
"**[\(.severity)] \(.code):** \(.file)#L\(.line + 1)<br><code>\(.message)</code>"' \
|
||||
check.json > "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo ""
|
||||
|
||||
# Github Annotations (limited to 10)
|
||||
jq -r --arg root "$PWD/" \
|
||||
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
|
||||
{
|
||||
file: $file,
|
||||
title: .code,
|
||||
line: .range.start.line,
|
||||
endLine: .range.end.line,
|
||||
col: .range.start.character,
|
||||
endColumn: .range.end.character,
|
||||
message: (.message | gsub("\n"; "%0A"; "m")),
|
||||
level: (if .severity <= 1 then "error" else "warning" end)
|
||||
} |
|
||||
"::\(.level) file=\(.file),line=\(.line + 1),endLine=\(.endLine + 1),col=\(.col + 1),endColumn=\(.endColumn + 1),title=\(.title)::\(.message) (\(.title))"' \
|
||||
check.json
|
||||
|
||||
# An entry is emitted for every file checked, including clean ones, so
|
||||
# the gate counts diagnostics and asserts the workspace was actually read
|
||||
if [[ $(jq 'length' check.json) -lt 20 ]] ; then
|
||||
echo "::error::Only $(jq 'length' check.json) files were checked, expected the whole workspace"
|
||||
exit 1
|
||||
elif [[ $(jq '[.[].diagnostics[]] | length' check.json) -gt 0 ]] ; then
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All checks succeeded!" | tee "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -7,70 +7,111 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.x
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
|
||||
node-version: [20.x, 22.x]
|
||||
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
factorio-version: [1.1.110, 2.0.47]
|
||||
# Supported node versions are active lts and maintenance see https://nodejs.org/en/about/releases/
|
||||
node-version: [24.x]
|
||||
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
factorio-version: [1.1.110, 2.0.77, 2.1.17]
|
||||
|
||||
# Coverage is performed on the older of the supported node versions
|
||||
include:
|
||||
- node-version: 22.x
|
||||
factorio-version: 2.1.17
|
||||
coverage: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare and install
|
||||
uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Prepare and install
|
||||
run: pnpm i --no-frozen-lockfile
|
||||
- name: Use Factorio ${{ matrix.factorio-version }}
|
||||
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- name: Run tests
|
||||
if: ${{ matrix.node-version != '20.x' || matrix.factorio-version != '1.1.110' }}
|
||||
if: ${{ !matrix.coverage }}
|
||||
run: pnpm test
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
|
||||
if: ${{ matrix.coverage }}
|
||||
run: pnpm run ci-cover
|
||||
- name : Upload coverage to Codecov
|
||||
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
|
||||
uses: codecov/codecov-action@v4
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ matrix.coverage }}
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Upload test logs for debugging
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
|
||||
path: temp/test/logs
|
||||
|
||||
version-compat:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# npm version spec for the published side of the compatibility test.
|
||||
# If each release is compatible with the one before it then testing
|
||||
# against the latest release transitively covers older versions too.
|
||||
PUBLISHED_VERSION: latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- controller: local
|
||||
host: published
|
||||
- controller: published
|
||||
host: local
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare and install
|
||||
uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
|
||||
run: >
|
||||
node scripts/version-compat-test.js
|
||||
--controller ${{ matrix.controller }}
|
||||
--host ${{ matrix.host }}
|
||||
--published-version $PUBLISHED_VERSION
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
ignore-scripts: true
|
||||
- run: pnpm run lint
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
ignore-scripts: true
|
||||
- run: pnpm run build-mod
|
||||
# Upload build to artifacts
|
||||
- name: Upload build to artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: clusterio_lib
|
||||
path: ./dist
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
npm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
|
||||
factorio:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.x
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
|
||||
- run: node ./publish.js factorio ./dist
|
||||
env:
|
||||
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: ./dist/*
|
||||
+5
-10
@@ -10,9 +10,8 @@ pids
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directories used by tools like nyc and codecov
|
||||
# Coverage directories used by tools like c8 and codecov
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
@@ -29,20 +28,16 @@ dist/
|
||||
|
||||
node_modules
|
||||
|
||||
Factorio*
|
||||
/factorio
|
||||
!/test/file/factorio
|
||||
!/test/lib/factorio
|
||||
!/packages/lib/src/factorio
|
||||
/factorio*
|
||||
/instances
|
||||
/mods
|
||||
/database
|
||||
/plugin-list.json
|
||||
/config-controller.json
|
||||
/config-control.json
|
||||
/config-host.json
|
||||
/config-*.json
|
||||
/config-*.json.lock
|
||||
pnpm-lock.yaml
|
||||
mod_cache.json
|
||||
.luarc.json
|
||||
/static/
|
||||
saves
|
||||
/temp/
|
||||
|
||||
+261
@@ -31,6 +31,267 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0-alpha.27
|
||||
|
||||
### Features
|
||||
|
||||
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
|
||||
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
|
||||
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
|
||||
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
|
||||
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
|
||||
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
|
||||
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
|
||||
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
|
||||
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Allow player online autosave slots to be disabled while autosaves are enabled. [#966](<https://github.com/clusterio/clusterio/issues/966>)
|
||||
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
|
||||
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Global chat now filters server-specific GPS, blueprint, train, and train-stop rich-text tags before relaying messages between instances. [#593](<https://github.com/clusterio/clusterio/issues/593>)
|
||||
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
|
||||
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
|
||||
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
|
||||
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@bbassie](<https://github.com/bbassie>)
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## Version 2.0.0-alpha.26
|
||||
|
||||
### Features
|
||||
|
||||
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
|
||||
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
|
||||
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
|
||||
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
|
||||
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
|
||||
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
|
||||
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
|
||||
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## Version 2.0.0-alpha.25
|
||||
|
||||
### Features
|
||||
|
||||
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
|
||||
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
|
||||
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
|
||||
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
|
||||
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
|
||||
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
[@Daniel](<https://github.com/Daniel>)
|
||||
[@Hornwitser](<https://github.com/Hornwitser>)
|
||||
|
||||
## Version 2.0.0-alpha.24
|
||||
|
||||
### Features
|
||||
|
||||
- Added `FactorioIcon` component for showing icons from the game in the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Added `useDefaultModPack` hook to the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Added types for the exported metadata for mod packs. [#526](https://github.com/clusterio/clusterio/issues/526)
|
||||
- Added config value validation beyond simple type checks. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added config staging to allow setting many values at once. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added a warning when attempting to set player autosave slots lower than autosave slots. [#855](https://github.com/clusterio/clusterio/issues/855)
|
||||
- Added `controller.authSecret` as the type of config field `controller.auth_secret` was inconsistent. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added support for the `node:` prefix in import statements in web UI code. [#881](https://github.com/clusterio/clusterio/pull/881)
|
||||
- Added config validation to load plugin to error when save patching or script commands are disabled [#884](https://github.com/clusterio/clusterio/issues/884)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed host printing a deprecation warning on startup. [#855](https://github.com/clusterio/clusterio/issues/855)
|
||||
- Fixed exported prototype metadata missing localised_name. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Fixed headless download option in installer failing with `TypeError: callback is not a function`. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
- Fixed controller crashing if a host attempted to download a mod that had been deleted from the controller's filesystem while the controller was running. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
|
||||
### Changes
|
||||
|
||||
- Moved `/icon-reference` to `/mods/mod-packs/:id/icon-reference` and linked it from the mod pack page. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Made the `useAccount`, `useModPacks`, `useMods`, `useRoles`, `useSaves`, `useSystems`, `useUsers` and related hooks in the web UI available to plugins. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Exported prototypes are now based on the `defines.prototypes` mapping from the game. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Removed usage of fs-extra. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `useExportManifest` from the web UI. Use `.exportManifest` on a mod pack from one of the `useModPack` hooks instead. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Replaced the 8 `useTypeMetadata` hooks in the web UI such as `useItemMetadata` with `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- `.item-name` css classes are no longer created in the web UI, and will not render icons from the game. Use the `FactorioIcon` component instead and pass it a prototype from `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Renamed `useLocale` hook in web UI to `useExportLocale` which now requires the mod pack to load the exported locale from as an argument. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Fluids are no longer merged with items in the exported prototype metadata for mod packs. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
|
||||
### Meta
|
||||
|
||||
- Changelog entries are now parsed with a script from pull request descriptions. [#871](https://github.com/clusterio/clusterio/pull/871)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
## Version 2.0.0-alpha.23
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added automatic headless download and update on linux. [#330](https://github.com/clusterio/clusterio/issues/330)
|
||||
- Added recipe notification sync to inventory sync. [#506](https://github.com/clusterio/clusterio/issues/506)
|
||||
|
||||
### Features
|
||||
|
||||
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
|
||||
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
|
||||
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
|
||||
- Extended export-data to produce icons for recipes, signals, technologies, planets, qualities and entities. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
|
||||
|
||||
### Changes
|
||||
|
||||
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
|
||||
- Instance versions can now be set using Major.Minor without specifying a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- Versions for factorio and mod packs can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
|
||||
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
|
||||
- Installations where multiple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
|
||||
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Removed debug log statement from equipment grid serialization. [#807](https://github.com/clusterio/clusterio/pull/807)
|
||||
- Fix that the documentation had a dead link to details of the message passing protocol [#812](https://github.com/clusterio/clusterio/issues/812)
|
||||
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
|
||||
- Fix headless download on linux install. [#816](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fix option `--no-download-headless` being ignored. [#816](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
|
||||
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#806](https://github.com/clusterio/clusterio/issues/806)
|
||||
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
|
||||
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
|
||||
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
|
||||
- Always create a whitelist file so it can be watched for changes. [#865](https://github.com/clusterio/clusterio/issues/865)
|
||||
- Added missing await statements which could cause an instance to start with an outdated admin/ban/whitelist. [#869](https://github.com/clusterio/clusterio/issues/869)
|
||||
- Fixed metrics breaking when labels contain comma or equal signs [#861](https://github.com/clusterio/clusterio/issues/861)
|
||||
- Fixed Host page show repeated errors when viewing an offline local host [#848](https://github.com/clusterio/clusterio/issues/848)
|
||||
- Fixed bad mod paths in icon specifications crashing the export. [#841](https://github.com/clusterio/clusterio/issues/841)
|
||||
- Fixed prototypes with no icon data causing errors during export. [#841](https://github.com/clusterio/clusterio/issues/841)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Custom permission methods should use `IUser` rather than `IControllerUser` [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `controller.userManager` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `controller.instances` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `User/Instance/HostInfo` renamed to `User/Instance/HostRecord` [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- Removed `host.tls_ca` and `control.tls_ca` config options, see [section on self signed certificates](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate) for the replacement for this option [#857](https://github.com/clusterio/clusterio/issues/857)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Daniel](https://github.com/Danielv123)
|
||||
[@Double](https://github.com/DoubleThoughtTheProgrammer)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
[@Ryan Walker](https://github.com/solarcloud7)
|
||||
|
||||
## Version 2.0.0-alpha.22
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
|
||||
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
|
||||
|
||||
### Features
|
||||
|
||||
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
|
||||
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
|
||||
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
|
||||
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
|
||||
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
|
||||
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
|
||||
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
|
||||
### Changes
|
||||
|
||||
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
|
||||
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
|
||||
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
|
||||
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
|
||||
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
|
||||
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
|
||||
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
|
||||
- Fixed npm update button failing to apply update and restart.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Danielv123](https://github.com/Danielv123)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
## Version 2.0.0-alpha.21
|
||||
|
||||
### Major Features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:20 as subspace_storage_builder
|
||||
FROM node:24 AS subspace_storage_builder
|
||||
RUN apt update && apt install -y git
|
||||
WORKDIR /
|
||||
RUN git clone https://github.com/clusterio/subspace_storage.git
|
||||
@@ -6,7 +6,7 @@ WORKDIR /subspace_storage
|
||||
RUN npm install \
|
||||
&& node build
|
||||
|
||||
FROM node:20 as clusterio_builder
|
||||
FROM node:24 AS clusterio_builder
|
||||
RUN apt update \
|
||||
&& apt install -y wget \
|
||||
&& mkdir /clusterio
|
||||
|
||||
@@ -86,6 +86,8 @@ There's also plugins developed and maintained by the community:
|
||||
|
||||
Want to make your own plugin?
|
||||
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
|
||||
If you have a plugin written for an earlier 2.0 alpha, see [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
|
||||
Note that from 2.0 onwards minor versions of Clusterio may contain breaking changes for plugins, while breaking changes for users running a cluster are held back for major versions.
|
||||
|
||||
|
||||
## Installation
|
||||
@@ -167,16 +169,12 @@ Your instances (save files etc) will be stored there.
|
||||
### Installing Plugins
|
||||
|
||||
For well-known plugins you can select them during installation and no further steps are necessary to make them work.
|
||||
Installing plugins that are not offered by the installer consists of two steps.
|
||||
First install the package the plugin is provided by via npm, for example
|
||||
Plugins that are not offered by the installer can be installed by the name of the npm package that provides them, for example
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
|
||||
|
||||
Then tell clusterio that this plugin exists by adding it as a plugin.
|
||||
|
||||
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
|
||||
This installs the package with npm and adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
|
||||
The command must be run from the directory clusterio was installed in and will refuse to run anywhere else.
|
||||
If you have hosts or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
|
||||
The clusteriocontroller, clusteriohost and clusterioctl commands has the plugin sub-command so you do not need to install clusteriocontroller to add plugins, instead use the clusterio command you have available.
|
||||
|
||||
@@ -232,7 +230,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
|
||||
|
||||
## Running Clusterio
|
||||
|
||||
After completing the installation start up the controller and at least one host separately.
|
||||
After completing the installation and creating your admin user, start up the controller and at least one host separately.
|
||||
The installer provides the `run-controller` and `run-host` scripts to make this simple.
|
||||
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
|
||||
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
const yargs = require("yargs");
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import yargs from "yargs";
|
||||
|
||||
const npmPackage = require("./package.json");
|
||||
import npmPackage from "./package.json" with { type: "json" };
|
||||
const workspaces = npmPackage.workspaces
|
||||
.map(p => p.slice(0, -2))
|
||||
.filter(p => !p.includes("external_plugins"));
|
||||
@@ -24,13 +23,15 @@ let DRY = false;
|
||||
* @param {string} filePath The path to remove.
|
||||
*/
|
||||
async function tryRemove(filePath) {
|
||||
if (await fs.exists(filePath)) {
|
||||
// eslint-disable-next-line no-console
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
console.log(filePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DRY) {
|
||||
await fs.remove(filePath);
|
||||
await fs.rm(filePath, { force: true, recursive: true, maxRetries: 10 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +48,9 @@ async function removeBuildArtifacts() {
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +74,7 @@ async function removeTestArtifacts() {
|
||||
async function removeExecutionArtifacts() {
|
||||
const artifacts = [
|
||||
"database", "instances", "logs", "mods",
|
||||
"config-control.json", "config-controller.json", "config-host.json",
|
||||
"config-ctl.json", "config-controller.json", "config-host.json",
|
||||
"FactorioAdminToken.txt", "plugin-list.json",
|
||||
];
|
||||
|
||||
@@ -81,10 +85,10 @@ async function removeExecutionArtifacts() {
|
||||
* Main function for this script
|
||||
*/
|
||||
async function main() {
|
||||
await yargs
|
||||
await yargs(process.argv.slice(2))
|
||||
.scriptName("clean")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("dry", {
|
||||
.option("dry-run", {
|
||||
alias: "dry",
|
||||
nargs: 0,
|
||||
describe: "Will not delete any files",
|
||||
default: false,
|
||||
@@ -101,16 +105,16 @@ async function main() {
|
||||
removeBuildArtifacts();
|
||||
removeTestArtifacts();
|
||||
})
|
||||
.command("fast", "Remove all build artifacts", () => {}, argv => {
|
||||
.command("$0", "Remove all build artifacts", () => {}, argv => {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.demandCommand(1, "A command must be specified")
|
||||
.strict()
|
||||
.parse();
|
||||
}
|
||||
|
||||
// Run main if started from command line
|
||||
if (module === require.main) {
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
+41
-2
@@ -3,10 +3,10 @@
|
||||
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
||||
|
||||
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
|
||||
Plugins can add their own config fields for the controller and instances configs.
|
||||
Plugins can add their own config fields for the controller, host and instance configs.
|
||||
The fields are defined early in the startup, and fields for disabled plugins will still be created.
|
||||
|
||||
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
|
||||
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields`, `hostConfigFields` and `instanceConfigFields` properties of the `plugin` export.
|
||||
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
|
||||
|
||||
|
||||
@@ -46,6 +46,45 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
|
||||
Value this config field should take in a newly initialized config.
|
||||
This can also be an async function returning the value to use.
|
||||
|
||||
**validator** (optional):
|
||||
Function called with the value being set and the config it belongs to.
|
||||
It should throw an error if the value is not acceptable, and the message of the error is passed on to whoever tried to set it.
|
||||
Common validators such as `greaterThanZero`, `greaterThanEqualZero`, `integer` and `filePath` are available on `Config.validators`, along with `optional` to skip null and `all` to combine several.
|
||||
The config is read only while the validator runs.
|
||||
|
||||
**dependsOn** (optional):
|
||||
Names of other fields the validator reads the value of.
|
||||
When one of those fields is changed the validator of this field is run again against the new value, and the change is rejected if it fails.
|
||||
|
||||
**restartRequired** (optional):
|
||||
True if a restart of the controller, host or instance the config belongs to is needed for changes to take effect.
|
||||
Changing such a field sets the `restartRequired` flag on the config, which the web interface uses to show that a restart is pending.
|
||||
Defaults to false.
|
||||
|
||||
**restartRequiredProps** (optional):
|
||||
For object fields, names of properties that have the opposite of `restartRequired`.
|
||||
Only used by the web interface to show which properties need a restart, `factorio.settings` uses it to mark the settings that are applied live.
|
||||
|
||||
**credential** (optional):
|
||||
Locations allowed to read the value of this field.
|
||||
Other locations can still change the value if allowed by `access`, which is what you want for tokens and passwords.
|
||||
|
||||
**readonly** (optional):
|
||||
Locations allowed to change the value of this field.
|
||||
Other locations can still read the value if allowed by `access`.
|
||||
|
||||
**hidden** (optional):
|
||||
True if the web interface should not show this field.
|
||||
Used for internal values that are managed by Clusterio.
|
||||
|
||||
**autoComplete** (optional):
|
||||
Value for the autocomplete attribute of the input in the web interface, e.g. `section-factorio new-password`.
|
||||
Only used for string and number fields.
|
||||
|
||||
**inputComponent** (optional):
|
||||
Name of the input component the web interface should render for this field instead of the default for its type.
|
||||
Plugins can add their own components through `inputComponents` on their web plugin class.
|
||||
|
||||
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
|
||||
|
||||
|
||||
|
||||
+365
-33
@@ -1,5 +1,15 @@
|
||||
# Configuration
|
||||
|
||||
Config values are changed with the `config set` commands of clusterioctl, clusteriocontroller and clusteriohost, or from the settings pages in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for the commands.
|
||||
|
||||
All values are checked against the type of the field when set.
|
||||
Some fields have additional rules on top of that, such as a number having to be greater than 0, and these are listed under the field.
|
||||
Setting a value that fails a check is rejected with an error and the old value is kept.
|
||||
|
||||
A few fields are internal to Clusterio.
|
||||
These are managed by Clusterio itself, hidden in the web interface and should not be modified by hand.
|
||||
|
||||
|
||||
## Controller Configuration
|
||||
|
||||
@@ -20,11 +30,30 @@ This is also used by plugins to store its data.
|
||||
Defaults to "database".
|
||||
|
||||
|
||||
### controller.mods_directory
|
||||
|
||||
Directory where mods shared with the cluster are stored.
|
||||
Mods uploaded through the web interface or downloaded from the mod portal end up here, and hosts fetch the mods they need for their instances from here.
|
||||
Needs to be writeable by the controller.
|
||||
|
||||
Defaults to "mods".
|
||||
|
||||
|
||||
### controller.autosave_interval
|
||||
|
||||
Interval in seconds the controller saves the data it holds in memory, such as users, instances, mod packs and plugin data, to controller.database_directory.
|
||||
The data is also saved on shutdown.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### controller.http_port
|
||||
|
||||
Port to host HTTP server on.
|
||||
If set to null no HTTP server will be exposed.
|
||||
At least one of this and controller.https_port needs to be set to a port.
|
||||
Must be a whole number greater than 0.
|
||||
|
||||
Defaults to 8080.
|
||||
|
||||
@@ -35,6 +64,7 @@ Port to host HTTPS server on.
|
||||
Uses controller.tls_certificate and controller.tls_private_key as the certificate and private key for the HTTPS server.
|
||||
If set to null no HTTPS server will be exposed.
|
||||
At least one of this and controller.http_port needs to be set to a port.
|
||||
Must be a whole number greater than 0.
|
||||
|
||||
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
|
||||
|
||||
@@ -51,27 +81,56 @@ interfaces.
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.trusted_proxies
|
||||
|
||||
Comma separated list of IP addresses and/or CIDR blocks of reverse proxies in front of the controller to trust the `X-Forwarded-For` header from.
|
||||
When a connection comes from one of these addresses the last address in the `X-Forwarded-For` header is used as the remote address of the connection instead of the address of the proxy.
|
||||
Entries that are not valid IP addresses are logged as errors and ignored.
|
||||
|
||||
Defaults to null meaning the header is never trusted.
|
||||
|
||||
|
||||
### controller.public_url
|
||||
|
||||
Public address the controller is accessible on.
|
||||
Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
|
||||
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
|
||||
This should be a full URL ending with a /.
|
||||
|
||||
Defaults to null meaning assume localhost.
|
||||
|
||||
|
||||
### controller.static_url
|
||||
|
||||
Used to override where static assets such as the web bundle is loaded from.
|
||||
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
|
||||
Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
|
||||
|
||||
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
|
||||
|
||||
Defaults to null meaning use the internally hosted assets.
|
||||
|
||||
|
||||
### controller.grafana_url
|
||||
|
||||
URL of a Grafana dashboard to link to from the overview page of the web interface, including the protocol.
|
||||
If null no link is shown.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.tls_certificate
|
||||
|
||||
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
|
||||
Both this and controller.tls_private_key have to be set for the controller to start with controller.https_port configured.
|
||||
|
||||
Defaults to "database/certificates/cert.crt".
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.tls_private_key
|
||||
|
||||
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
|
||||
|
||||
Defaults to "database/certificates/cert.key".
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.auth_secret
|
||||
@@ -79,6 +138,7 @@ Defaults to "database/certificates/cert.key".
|
||||
Base64 encoded authentication secret used to verify tokens issued to hosts, users, and sessions.
|
||||
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
|
||||
Keep this secret if leaked an attacker could easily compromise the cluster.
|
||||
The value can only be read and changed from the controller itself, it is not available through clusterioctl or the web interface.
|
||||
|
||||
Defaults to null which means generate a secure secret on startup.
|
||||
|
||||
@@ -88,6 +148,7 @@ Defaults to null which means generate a secure secret on startup.
|
||||
Interval in seconds heartbeats are sent out at for WebSocket connections.
|
||||
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
|
||||
A lower value means less time between connections going stale and them being closed.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
@@ -99,25 +160,94 @@ A higher timeout gives more time for a client to recconect and resume an active
|
||||
In the case of the controller or client crashing the data loss is unavoidable.
|
||||
|
||||
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `controller.heartbeat_interval`.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### controller.max_send_buffer_size
|
||||
|
||||
Size in MiB of messages sent to a connection that it has not yet acknowledged before the controller ends its session.
|
||||
The controller keeps these so they can be resent if the connection drops and resumes.
|
||||
A client that stops acknowledging messages, or falls far behind, gets disconnected and starts a new session when it reconnects.
|
||||
Changes apply to new sessions.
|
||||
Set to 0 for no limit.
|
||||
|
||||
Defaults to 64.
|
||||
|
||||
|
||||
### controller.metrics_timeout
|
||||
|
||||
Timeout in seconds before a call to gather metrics from a host times out.
|
||||
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 8.
|
||||
|
||||
|
||||
### controller.stream_timeout
|
||||
### controller.max_subscription_filters
|
||||
|
||||
Maximum number of filters one connection can hold for a single subscribed event.
|
||||
Subscription requests that would go over this limit are rejected.
|
||||
Set to 0 for no limit.
|
||||
Lowering the limit does not remove filters connections already hold.
|
||||
|
||||
Defaults to 10000.
|
||||
|
||||
|
||||
### controller.system_metrics_interval
|
||||
|
||||
Interval in seconds the controller gathers CPU, memory and disk usage from itself and every connected host for display in the web interface.
|
||||
Whether the controller needs a restart to apply an update is also checked on this interval.
|
||||
If 0 system metrics are not gathered.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 10.
|
||||
|
||||
|
||||
### controller.proxy_stream_timeout
|
||||
|
||||
Timeout in seconds for proxy streams through the controller that has been created but is not yet sending data to be automatically closed.
|
||||
Streams that have started sending data before this timeout is not affected.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### controller.factorio_username
|
||||
|
||||
Username of the Factorio.com account to authenticate with the Factorio API with.
|
||||
Together with controller.factorio_token this is used by the controller to download mods from the mod portal, and if controller.share_factorio_credentials_with_hosts is enabled, as the credentials in the server settings of instances.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.factorio_token
|
||||
|
||||
Token of the Factorio.com account to authenticate with the Factorio API with, see controller.factorio_username.
|
||||
This is the token shown on your profile page on factorio.com, not your password.
|
||||
The value is treated as a credential, it can be set from anywhere but only read back from the controller itself.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.share_factorio_credentials_with_hosts
|
||||
|
||||
If enabled hosts get controller.factorio_username and controller.factorio_token from the controller when they start an instance and put them in the server settings of that instance.
|
||||
Credentials set on the host with host.factorio_username and host.factorio_token, or on the instance through `username` and `token` in factorio.settings, take precedence over the ones from the controller.
|
||||
Disable this if you don't want the controller's credentials to be sent to the hosts.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### controller.default_mod_pack_id
|
||||
|
||||
ID of the mod pack to use for instances that don't have factorio.mod_pack_id set.
|
||||
Instances without a mod pack of their own fail to start if this is null.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.default_role_id
|
||||
|
||||
Role to automatically grant to new users.
|
||||
@@ -126,6 +256,55 @@ If null no role is granted.
|
||||
Defaults to 1 which correspond to the default Player role.
|
||||
|
||||
|
||||
### controller.mod_portal_cache_duration_minutes
|
||||
|
||||
Time in minutes the controller caches the list of mods fetched from the Factorio mod portal when browsing it from the web interface.
|
||||
Set to 0 to disable the cache and fetch the list on every request.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 30.
|
||||
|
||||
|
||||
### controller.mod_portal_page_size
|
||||
|
||||
Number of results to request per page when fetching the list of mods from the Factorio mod portal.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 1000.
|
||||
|
||||
|
||||
### controller.allow_remote_updates
|
||||
|
||||
Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
|
||||
For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### controller.allow_plugin_updates
|
||||
|
||||
Whether remote requests to update plugins installed on the controller with npm are allowed.
|
||||
Like controller.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### controller.allow_plugin_install
|
||||
|
||||
Whether remote requests to install new plugins on the controller with npm are allowed.
|
||||
Like controller.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### controller.version
|
||||
|
||||
Version of the controller package that last loaded this config.
|
||||
Set automatically on startup and used to detect when the controller has been updated or downgraded.
|
||||
Internal, hidden in the web interface and should not be changed.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the plugin on the controller.
|
||||
@@ -147,6 +326,7 @@ Defaults to "New Host".
|
||||
### host.id
|
||||
|
||||
Immutable numeric id of the host which uniquely identifies this host in the cluster.
|
||||
Hidden in the web interface.
|
||||
|
||||
Defaults to a random 31 bit number.
|
||||
|
||||
@@ -159,6 +339,14 @@ This can either point to a Factorio server directory, or to a directory containi
|
||||
Defaults to "factorio".
|
||||
|
||||
|
||||
### host.mods_directory
|
||||
|
||||
Directory the host stores mods downloaded from the controller in.
|
||||
When an instance starts the mods in its mod pack are downloaded here if missing and then linked into the mods directory of the instance.
|
||||
|
||||
Defaults to "mods".
|
||||
|
||||
|
||||
### host.instances_directory
|
||||
|
||||
Directory to store instances in.
|
||||
@@ -178,18 +366,11 @@ Defaults to "http://localhost:8080/".
|
||||
|
||||
Access token used for authenticating with the controller.
|
||||
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
|
||||
The value can only be read and changed from the host itself.
|
||||
|
||||
Defaults to "enter token here".
|
||||
|
||||
|
||||
### host.tls_ca
|
||||
|
||||
Path to certificate in PEM format to use for validating a TLS connection to the controller.
|
||||
If you have a self signed certificate on the controller you will need to copy the certificate to your hosts and set it as the certificate authority with this option.
|
||||
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### host.public_address
|
||||
|
||||
External address instances hosted on this host can be accessed on.
|
||||
@@ -198,14 +379,82 @@ This is used by plugins like Server Select to give the correct address to switch
|
||||
Defaults to "localhost".
|
||||
|
||||
|
||||
### host.factorio_port_range
|
||||
|
||||
UDP ports the host hands out to instances that don't have factorio.game_port set.
|
||||
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34100-34199" or "34100, 34200-34210".
|
||||
A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
|
||||
Starting an instance fails if all ports in the range are taken.
|
||||
|
||||
Defaults to "34100-34199".
|
||||
|
||||
|
||||
### host.factorio_username
|
||||
|
||||
Username of the Factorio.com account to put in the server settings of instances on this host.
|
||||
If set this takes precedence over the credentials shared by the controller, but not over `username` set in factorio.settings of an instance.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### host.factorio_token
|
||||
|
||||
Token of the Factorio.com account to go with host.factorio_username.
|
||||
The value is treated as a credential, it can be set from anywhere but only read back from the host itself.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### host.max_reconnect_delay
|
||||
|
||||
Maximum delay in seconds to wait after connection to the controller is dropped before attempting to reconnect to it.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### host.allow_remote_updates
|
||||
|
||||
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
|
||||
For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### host.allow_plugin_updates
|
||||
|
||||
Whether remote requests to update plugins installed on the host with npm are allowed.
|
||||
Like host.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### host.allow_plugin_install
|
||||
|
||||
Whether remote requests to install new plugins on the host with npm are allowed.
|
||||
Like host.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### host.version
|
||||
|
||||
Version of the host package that last loaded this config.
|
||||
Set automatically on startup and used to detect when the host has been updated or downgraded.
|
||||
Internal, hidden in the web interface and should not be changed.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the plugin on the host.
|
||||
Only plugins with a host or instance part have this field in the host config.
|
||||
Plugins not loaded on the host will not be loaded on the instances it hosts.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Instance Configuration
|
||||
|
||||
### instance.name
|
||||
@@ -218,6 +467,7 @@ Defaults to "New Instance".
|
||||
### instance.id
|
||||
|
||||
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
|
||||
Hidden in the web interface.
|
||||
|
||||
Defaluts to a random 31 bit integer.
|
||||
|
||||
@@ -227,6 +477,7 @@ Defaluts to a random 31 bit integer.
|
||||
Host this instance is assigned to.
|
||||
To change this you need to use the separate assign instance interface.
|
||||
After being assigned to a host the directory for the instance is created on the host.
|
||||
Internal and hidden in the web interface.
|
||||
|
||||
Defaults to null meaning not assigned.
|
||||
|
||||
@@ -239,26 +490,63 @@ Does not affect start or stop of the instance while the host is running.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.exclude_from_start_all
|
||||
|
||||
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
||||
This allows you to prevent certain instances from being started when using the bulk start operation.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.version
|
||||
|
||||
Version of Factorio to use for this instance.
|
||||
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the host.
|
||||
Can be a specific version like "2.0" or "2.0.15", a release channel like "stable" or "experimental", or the special string "latest" meaning the latest version of Factorio found on the host.
|
||||
A release channel is resolved to a version through the latest releases data the controller keeps, and the instance fails to start if that is unavailable.
|
||||
Values that don't match one of these forms are rejected.
|
||||
|
||||
Defaults to "latest".
|
||||
|
||||
|
||||
### factorio.executable_path
|
||||
|
||||
Path relative to the Factorio installation directory of the executable to run.
|
||||
If null the path is auto detected, which is `bin/x64/factorio` on Linux and Windows and `MacOS/factorio` on macOS.
|
||||
Only needed in special setups.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.shutdown_timeout
|
||||
|
||||
Time in seconds to wait for the Factorio server to exit after asking it to stop before the process is killed.
|
||||
Set to 0 to wait forever.
|
||||
Changing this while the instance is stopping restarts the timer with the new value.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 300.
|
||||
|
||||
|
||||
### factorio.game_port
|
||||
|
||||
UDP port to run the Factorio server on.
|
||||
If null a random port in the dynamyc range assigned each time the instance starts.
|
||||
If null a port from host.factorio_port_range on the host is assigned to the instance.
|
||||
Negative values are rejected.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.host_assigned_game_port
|
||||
|
||||
Port from host.factorio_port_range the host has assigned to this instance while factorio.game_port is null.
|
||||
Internal, set by the host and only accessible from the host.
|
||||
|
||||
|
||||
### factorio.rcon_port
|
||||
|
||||
TCP port to start the RCON interface on the Factorio server on.
|
||||
If null a random port in the dynamic range is assigned each time the instance starts.
|
||||
Negative values are rejected.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
@@ -267,16 +555,18 @@ Defaults to null.
|
||||
|
||||
Password to use for the RCON interafec.
|
||||
If null a random secure password is generated and used each time the instance starts.
|
||||
The value is treated as a credential and can only be read back by the controller and the host.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### player_online_autosave_slots
|
||||
### factorio.player_online_autosave_slots
|
||||
|
||||
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
|
||||
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
|
||||
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
|
||||
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
|
||||
Setting this to a value lower than `autosave_slots` in factorio.settings is rejected, unless it is 0.
|
||||
The same check is done when factorio.settings is changed.
|
||||
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
|
||||
|
||||
If 0 no autosave renaming takes place.
|
||||
@@ -284,12 +574,34 @@ If 0 no autosave renaming takes place.
|
||||
Defaults to 5.
|
||||
|
||||
|
||||
### factorio.mod_pack_id
|
||||
|
||||
ID of the mod pack to use on this instance.
|
||||
The mods in the pack are linked into the mods directory of the instance every time it starts, and any other files in that directory are removed.
|
||||
If null the mod pack set in controller.default_mod_pack_id is used.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.enable_save_patching
|
||||
|
||||
Whether to use save patching or not.
|
||||
When enabled lua code is patched into the game save before starting it.
|
||||
Most plugins require the use of save patching to function.
|
||||
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
|
||||
Plugins that need save patching can't be loaded on the instance while this is disabled.
|
||||
Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.enable_script_commands
|
||||
|
||||
Allow Clusterio and plugins to send commands that disable achievements to the server over RCON, that is `/c`, `/sc`, `/mc`, `/cheat`, `/editor` and their long forms.
|
||||
Clusterio integrations and most plugins need this, and when enabled achievements are disabled on the server at startup.
|
||||
When disabled attempts to send such a command fail with an error.
|
||||
This does not stop players from using these commands themselves.
|
||||
Like factorio.enable_save_patching, plugins that need script commands can't be loaded on the instance while this is disabled.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
@@ -301,6 +613,13 @@ Turn on the whitelist on the server.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.enable_authserver_bans
|
||||
|
||||
Pass the `--use-authserver-bans` flag to Factorio, which makes the server check connecting players against the multiplayer bans on Factorio.com and report bans and unbans done on the server back to it.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.settings
|
||||
|
||||
Object with the settings to put into `server-settings.json` for the Factorio server.
|
||||
@@ -308,7 +627,7 @@ The settings in `data/server-settings.example.json` of the Factorio installation
|
||||
|
||||
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
|
||||
|
||||
Defaults to {"tags":["clusterio"],"auto_pause":false}.
|
||||
Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
|
||||
|
||||
|
||||
### factorio.verbose_logging
|
||||
@@ -319,6 +638,14 @@ This prints more messages to the log.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.console_logging
|
||||
|
||||
Pass the `--console-log` flag to Factorio to make it write the console output, such as chat, commands and player joins, to `console.log` in the instance directory.
|
||||
Useful for 3rd party tools that read the console log of the server.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.strip_paths
|
||||
|
||||
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
|
||||
@@ -341,25 +668,35 @@ Defaults to true.
|
||||
### factorio.sync_adminlist
|
||||
|
||||
Synchronize in-game admin list with admin status of the users in cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
|
||||
With "bidirectional" players promoted or demoted in-game with `/promote` and `/demote` are also updated in the cluster, and from there on the other instances.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to true.
|
||||
Defaults to "bidirectional".
|
||||
|
||||
|
||||
### factorio.sync_whitelist
|
||||
|
||||
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
|
||||
With "bidirectional" changes made in-game with `/whitelist` are picked up by watching `server-whitelist.json` for changes and updated in the cluster as well.
|
||||
This requires factorio.enable_whitelist to be on, as Factorio doesn't write the file otherwise.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to true.
|
||||
Defaults to "bidirectional".
|
||||
|
||||
|
||||
### factorio.sync_banlist
|
||||
|
||||
Synchronize in-game banlist with the banned status of users in the cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
|
||||
With "bidirectional" players banned or unbanned in-game with `/ban` and `/unban` are also updated in the cluster, and from there on the other instances.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to true.
|
||||
Defaults to "bidirectional".
|
||||
|
||||
|
||||
### factorio.max_concurrent_commands
|
||||
@@ -376,6 +713,7 @@ This means a high number of concurrent commands can amplify resends due to packe
|
||||
|
||||
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
|
||||
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
@@ -383,21 +721,22 @@ Defaults to 5.
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the given plugin on the instance.
|
||||
Note that plugins need to be loaded on the controller in order for them to be loaded on instances.
|
||||
Note that plugins need to be loaded on the controller and the host in order for them to be loaded on instances.
|
||||
Loading a plugin that requires save patching or script commands is rejected while factorio.enable_save_patching or factorio.enable_script_commands respectively is disabled on the instance.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Control Configuration
|
||||
## Ctl Configuration
|
||||
|
||||
### control.controller_url
|
||||
### ctl.controller_url
|
||||
|
||||
URL to connect to the controller to.
|
||||
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### control.controller_token
|
||||
### ctl.controller_token
|
||||
|
||||
Access token used for authenticating with the controller.
|
||||
You can generate an access token with `clusteriocontroller bootstrap generate-user-token <username>`, or use the `clusteriocontroller bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
|
||||
@@ -405,17 +744,10 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### control.tls_ca
|
||||
|
||||
Path to certificate in PEM format to use for validating a TLS connection to the controller.
|
||||
If you have a self signed certificate on the controller you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
|
||||
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### control.max_reconnect_delay
|
||||
### ctl.max_reconnect_delay
|
||||
|
||||
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 60.
|
||||
+10
-3
@@ -77,7 +77,7 @@ The documentation uses the binaries provided by the packages, but for the develo
|
||||
|
||||
By default node does not enable source maps, to get stack traces pointing to the `.ts` files you will need to pass `--enable-source-maps` to `node` or set the environment variable `NODE_OPTIONS=--enable-source-maps`.
|
||||
|
||||
Note that to use `clusterioctl` you will have to create a control config first:
|
||||
Note that to use `clusterioctl` you will have to create a ctl config first:
|
||||
|
||||
node packages/controller bootstrap create-ctl-config [admin-user]
|
||||
|
||||
@@ -125,6 +125,11 @@ Please do add tests for any code you add and run both:
|
||||
|
||||
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
|
||||
|
||||
The full test suite can take a while to run.
|
||||
Use `pnpm run fast-test` to skip tests marked as slow, or `pnpm run no-external-test` to skip tests that depend on external APIs such as the Factorio mod portal or GitHub.
|
||||
These correspond to setting the `FAST_TEST` and `NO_EXTERNAL_TEST` environment variables and can be combined.
|
||||
When writing tests that take a lot of time call `slowTest(this)` at the start of the test, and for tests that call out to external APIs call `externalTest(this)`, both are exported from `test/integration/index.js`.
|
||||
|
||||
|
||||
### Submitting a Pull Request
|
||||
|
||||
@@ -137,6 +142,8 @@ You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the
|
||||
After your feature branch have been merged you should consider it final.
|
||||
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
|
||||
|
||||
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
|
||||
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
|
||||
|
||||
### Editing your Feature Branch
|
||||
|
||||
@@ -222,8 +229,8 @@ Files containing and exporting a single class should be named the same as the cl
|
||||
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
|
||||
E.g.:
|
||||
|
||||
const libLink = require("./link");
|
||||
const libLuaTools = require("./lua_tools");
|
||||
import libLink from "./link";
|
||||
import libLuaTools from "./lua_tools";
|
||||
|
||||
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
|
||||
|
||||
|
||||
+7
-7
@@ -361,16 +361,16 @@ While we have considered alternatives since, transitioning to another solution w
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
🧾 Time Immemorial
|
||||
🧾 7th October 2026
|
||||
|
||||
> We follow symver versioning for breaking changes.
|
||||
> In our alpha stage, we allow every alpha to contain breaking changes as long as their are documented and provide migration actions.
|
||||
> Once we enter beta, breaking changes will be more restricted.
|
||||
> From 2.0.0 onwards, major versions contain breaking changes for end users, while minor versions may contain breaking changes for the plugin API.
|
||||
> All breaking changes are documented in the changelog along with migration actions.
|
||||
|
||||
We aim to be highly transparent with breaking changes and maintain compatibility with as many existing installations as possible.
|
||||
However, as we are currently in the alpha stage, things are unstable, and breaking changes occur frequently.
|
||||
As such, we have decided to document all breaking changes and provide migration steps where the changes are non-trivial.
|
||||
Once we enter the beta stage, we will implement stricter restrictions on breaking changes.
|
||||
During the alpha stage every alpha was allowed to contain breaking changes, as long as they were documented and provided migration actions.
|
||||
From 2.0 onwards changes which require action from people running a cluster, such as changes to configs, stored data or the command line, are held back for the next major version.
|
||||
Changes to the plugin API may still be made in minor versions.
|
||||
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
|
||||
|
||||
|
||||
### Distribution
|
||||
|
||||
@@ -120,7 +120,7 @@ There's also the `add_libraries` function exported by event_handler, which accep
|
||||
## Communicating with Clusterio
|
||||
|
||||
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
|
||||
See [socket.md](socket.md) for the implementation details of it.
|
||||
See [protocol.md](devs/protocol.md) for the implementation details of it.
|
||||
|
||||
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ See the following example code for a hypothetical `Data` class with a single pro
|
||||
* A Data class example
|
||||
* @alias module:Data
|
||||
*/
|
||||
class Data {
|
||||
export class Data {
|
||||
/** @type {string} */
|
||||
member = "foo";
|
||||
|
||||
@@ -41,8 +41,6 @@ class Data {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Data;
|
||||
```
|
||||
|
||||
Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object.
|
||||
@@ -53,14 +51,13 @@ For example a class holding a data property with an instance of the Data class f
|
||||
|
||||
```js
|
||||
// Composed.js
|
||||
"use strict";
|
||||
const Data = require("./Data");
|
||||
import Data from "./Data";
|
||||
|
||||
/**
|
||||
* Composed data class example
|
||||
* @alias module:Data
|
||||
*/
|
||||
class Composed {
|
||||
export class Composed {
|
||||
/** @type {module:Data|undefined} */
|
||||
data;
|
||||
|
||||
@@ -83,8 +80,6 @@ class Composed {
|
||||
|
||||
// toJSON is optional and not needed for this particular class
|
||||
}
|
||||
|
||||
module.exports = Composed;
|
||||
```
|
||||
|
||||
It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data.
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
# Migrating Plugins to 2.0
|
||||
|
||||
This guide covers the changes needed to bring a plugin written for 2.0.0-alpha.27 up to 2.0.0.
|
||||
If your plugin targets an older alpha, go through the Breaking Changes sections in the [changelog](/CHANGELOG.md) for the versions in between first.
|
||||
Plugins still on alpha 13 should start with the [alpha 14 guide](plugin-migration-to-alpha-14.md).
|
||||
|
||||
|
||||
## Versioning from 2.0
|
||||
|
||||
From 2.0.0 onwards a major version (3.0.0) contains breaking changes for users running a cluster, while a minor version (2.1.0) may contain breaking changes for the plugin API.
|
||||
This means a plugin written for 2.0 is not guaranteed to work with 2.1 without changes.
|
||||
Check the Breaking Changes section of the changelog when a new minor version is released and publish an update to your plugin if needed.
|
||||
See [Breaking Changes](/docs/decisions.md#breaking-changes) in the decision log.
|
||||
|
||||
|
||||
## Convert to ES modules
|
||||
|
||||
Clusterio's packages are now ES modules and plugins should be too.
|
||||
Add `"type": "module"` to the plugin's `package.json` and replace `require()` and `module.exports` with `import` and `export`.
|
||||
|
||||
Relative imports must include the full filename with the `.js` extension.
|
||||
This also applies in TypeScript, where you import `./messages.js` even though the source file is `messages.ts`.
|
||||
Importing a directory no longer resolves to its `index.js`.
|
||||
|
||||
**Alpha 27**
|
||||
```js
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const messages = require("./messages");
|
||||
|
||||
const plugin = {
|
||||
name: "foo_frobber",
|
||||
controllerEntrypoint: "controller",
|
||||
messages: [messages.Frobnicate],
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
```
|
||||
|
||||
**2.0**
|
||||
```js
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
export const plugin = {
|
||||
name: "foo_frobber",
|
||||
controllerEntrypoint: "controller.js",
|
||||
messages: [messages.Frobnicate],
|
||||
};
|
||||
```
|
||||
|
||||
The entrypoint paths in the plugin declaration must include the `.js` extension as well, for TypeScript plugins this means `"./dist/node/controller.js"` instead of `"./dist/node/controller"`.
|
||||
|
||||
`__dirname` and `__filename` do not exist in ES modules, use `import.meta.dirname` and `import.meta.filename` instead.
|
||||
JSON files are imported with `import packageJson from "./package.json" with { type: "json" };`.
|
||||
|
||||
A `webpack.config.js` written with `require()` stops working once the package is an ES module.
|
||||
Rename it to `webpack.config.cjs`, webpack-cli finds it under that name without further changes.
|
||||
If your `prepare` script passes `--config webpack.config.js` update it to the new name.
|
||||
|
||||
TypeScript plugins using `"module": "node16"` in their tsconfig, as the plugin templates do, emit ES modules once `"type": "module"` is set and need no further config changes.
|
||||
|
||||
Node.js can still import a CommonJS plugin, but it only finds the `plugin` export if it can detect it from the source, which works for `module.exports = { plugin };` and fails for other forms such as an object literal written in place.
|
||||
Converting the plugin avoids the problem.
|
||||
|
||||
|
||||
## Replace plugin classes with entrypoint functions
|
||||
|
||||
Entrypoints now export a default async function instead of a class deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin`.
|
||||
The function is called once when the plugin loads and attaches handlers to the hooks it needs.
|
||||
Class exports still load, but they are deprecated and a warning is logged for each one.
|
||||
See [Defining the plugin entrypoint](/docs/writing-plugins.md#defining-the-plugin-entrypoint) for the details.
|
||||
|
||||
**Alpha 27**
|
||||
```js
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
this.controller.handle(Frobnicate, this.handleFrobnicate.bind(this));
|
||||
}
|
||||
|
||||
async onSaveData() {
|
||||
await this.saveFrobnicationData();
|
||||
}
|
||||
|
||||
async onInstanceStatusChanged(instance, prev) {
|
||||
this.logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
|
||||
}
|
||||
|
||||
async handleFrobnicate(request) { /* ... */ }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
};
|
||||
```
|
||||
|
||||
**2.0 (JS)**
|
||||
```js
|
||||
export default async function(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
|
||||
controller.handle(Frobnicate, async (request) => { /* ... */ });
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
await saveFrobnicationData();
|
||||
});
|
||||
|
||||
controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance, prev) => {
|
||||
logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**2.0 (TS)**
|
||||
```ts
|
||||
import type { ControllerPluginContext } from "@clusterio/controller";
|
||||
|
||||
export default async function(context: ControllerPluginContext) {
|
||||
// Same as above
|
||||
}
|
||||
```
|
||||
|
||||
The code from `init()` goes into the body of the function, and state that lived on `this` becomes local variables.
|
||||
The context types are `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from the respective packages.
|
||||
|
||||
|
||||
### Keeping a class for state
|
||||
|
||||
If the plugin has enough state that a class is still the better fit, give the class a private constructor and an async `static fromContext` that creates it from the context and attaches its hooks.
|
||||
`fromContext` can then be the default export of the entrypoint.
|
||||
Instances are kept in a static `WeakMap` keyed by the controller, host or instance they belong to, which lets other code look them up without holding a reference to them.
|
||||
|
||||
```ts
|
||||
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
export class Frobber {
|
||||
private static instances = new WeakMap<Controller, Frobber>();
|
||||
private frobnications = new Map<string, number>();
|
||||
|
||||
private constructor(
|
||||
private controller: Controller,
|
||||
private logger: lib.Logger,
|
||||
) { }
|
||||
|
||||
static async fromContext(context: ControllerPluginContext) {
|
||||
const frobber = new Frobber(context.controller, context.logger);
|
||||
await frobber.load();
|
||||
context.controller.hooks.save.attach(context.plugin.name, () => frobber.save());
|
||||
Frobber.instances.set(context.controller, frobber);
|
||||
return frobber;
|
||||
}
|
||||
|
||||
static get(controller: Controller) {
|
||||
return Frobber.instances.get(controller);
|
||||
}
|
||||
|
||||
async load() { /* ... */ }
|
||||
async save() { /* ... */ }
|
||||
}
|
||||
|
||||
export default Frobber.fromContext;
|
||||
```
|
||||
|
||||
The loader calls the default export without `this`, so refer to the class by name inside `fromContext` rather than using `this`.
|
||||
Don't extend the deprecated base classes for this, they attach `on*` methods to hooks themselves and log a warning.
|
||||
|
||||
|
||||
### Method to hook names
|
||||
|
||||
Each `on*` method has a hook of the same name without the `on` prefix and with the first letter lowercased, so `onInstanceStatusChanged` becomes `controller.hooks.instanceStatusChanged`.
|
||||
The exceptions are:
|
||||
|
||||
- `onSaveData` on the controller is `controller.hooks.save`.
|
||||
- `addCommands` on ctl plugins is `context.hooks.addCommands`.
|
||||
- The `pages`, `loginForms` and `inputComponents` properties of web plugins are the `pages`, `loginForms` and `inputComponents` hooks on `control.hooks`, with handlers that return the value.
|
||||
- The `componentExtra` property of web plugins is the `control.hooks.extensionComponents` hook.
|
||||
|
||||
Controller hooks are on `controller.hooks`, host hooks on `host.hooks`, instance hooks on `instance.hooks`, and web hooks on `control.hooks`.
|
||||
The ctl hooks are passed directly as `context.hooks`.
|
||||
|
||||
**Alpha 27**
|
||||
```jsx
|
||||
class WebPlugin extends BaseWebPlugin {
|
||||
async init() {
|
||||
this.pages = [
|
||||
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2.0**
|
||||
```jsx
|
||||
export default async function(context) {
|
||||
const { control, plugin } = context;
|
||||
|
||||
control.hooks.pages.attach(plugin.name, () => [
|
||||
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Replacing other class members
|
||||
|
||||
The class properties are on the context object passed to the entrypoint:
|
||||
|
||||
- `this.info` is `context.plugin`.
|
||||
- `this.logger` is `context.logger`.
|
||||
- `this.controller`, `this.host`, `this.instance` and `this.control` are `context.controller`, `context.host`, `context.instance` and `context.control`.
|
||||
- `this.metrics` on controller plugins is `context.metrics`.
|
||||
- `this.package` on web plugins is `context.package`.
|
||||
|
||||
The helper methods of the base classes have the following replacements:
|
||||
|
||||
- `this.sendRcon(message, expectEmpty)` is `instance.sendRcon(message, expectEmpty, plugin.name)`.
|
||||
The plugin name is used to label the RCON metrics.
|
||||
- `this.broadcastEventToHosts(event)` is `controller.sendTo("allHosts", event)`.
|
||||
- `this.sendOrderedRcon(message)` has no replacement.
|
||||
If your plugin needs commands to execute in order, queue them in the plugin and send the next one after the previous one resolves.
|
||||
|
||||
|
||||
### Changes to hook behaviour
|
||||
|
||||
Handlers attached by different plugins to the same hook now run concurrently, where the `on*` methods were called one plugin at a time.
|
||||
Don't rely on another plugin's handler having finished before yours runs.
|
||||
|
||||
A handler that throws or takes more than 15 seconds is logged and does not prevent the other handlers from running.
|
||||
Each hook accepts one handler per name, attach it under `plugin.name`.
|
||||
|
||||
|
||||
## Accessing other plugins
|
||||
|
||||
The `plugins` map on `Controller`, `Host` and `Instance` has been removed.
|
||||
It's replaced with `loadedPlugins`, a set of the plugin info objects for the plugins that loaded.
|
||||
The set holds info objects rather than names, so look the info up in `pluginInfos` by name first to check if a plugin is loaded on the controller or host:
|
||||
|
||||
```js
|
||||
const info = controller.pluginInfos.find(info => info.name === "other_plugin");
|
||||
const loaded = info !== undefined && controller.loadedPlugins.has(info);
|
||||
```
|
||||
|
||||
In the web interface `control.loadedPlugins` is a map from plugin name to plugin info.
|
||||
`control.plugins` is deprecated and only holds plugins still using the class export.
|
||||
|
||||
With no plugin class instances, `controller.plugins.get("other_plugin")` has no direct replacement.
|
||||
If one plugin needs to call into another, have the other plugin export its class and use the static lookup from [Keeping a class for state](#keeping-a-class-for-state):
|
||||
|
||||
```ts
|
||||
import { Frobber } from "other_plugin/dist/node/controller.js";
|
||||
|
||||
const frobber = Frobber.get(controller);
|
||||
if (!frobber) {
|
||||
throw new Error("other_plugin is not loaded");
|
||||
}
|
||||
```
|
||||
|
||||
This only works if both plugins resolve `other_plugin` to the same file on disk, otherwise the lookup goes to a different copy of the class and returns undefined.
|
||||
|
||||
For state shared between components of a web plugin, assign it to a module level variable in the entrypoint.
|
||||
The web plugin template created by `@clusterio/create` shows this for a subscriber.
|
||||
|
||||
|
||||
## Declare permissions on the plugin
|
||||
|
||||
Permissions can now be declared under `permissions` in the plugin declaration instead of with `lib.definePermission()`.
|
||||
`lib.definePermission()` still works, but don't use both for the same permission name.
|
||||
|
||||
For TypeScript plugins `user.checkPermission()`, `account.hasPermission()` and the `permission` of web pages now only accept known permission names.
|
||||
Add your permission names to the `Permissions` interface or these calls will fail to compile, even if you keep using `lib.definePermission()`:
|
||||
|
||||
```ts
|
||||
declare module "@clusterio/lib" {
|
||||
export interface Permissions {
|
||||
"foo_frobber.frobnicate": never;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin template's `tsconfig.browser.json` now includes `index.ts` so the web build also sees the declaration, add it to the `include` list if your plugin has its own tsconfig for the browser.
|
||||
See [Plugin Permissions](/docs/writing-plugins.md#plugin-permissions).
|
||||
|
||||
|
||||
## Renamed ctl classes and config
|
||||
|
||||
The remaining uses of "control" for clusterioctl were renamed to "ctl":
|
||||
|
||||
- `controlConfigFields` in the plugin declaration is `ctlConfigFields`.
|
||||
Fields under the old name are silently ignored.
|
||||
- The `ControlConfigFields` interface is `CtlConfigFields`.
|
||||
- `lib.ControlConfig` is `lib.CtlConfig`.
|
||||
- The `Control` class exported by `@clusterio/ctl` is `Ctl`.
|
||||
|
||||
The term "control" is still used in message `src` and `dst` and in config field locations, where it covers both ctl and the web interface.
|
||||
|
||||
|
||||
## Static assets in the web interface
|
||||
|
||||
`staticRoot` now includes the `static/` prefix.
|
||||
Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part.
|
||||
|
||||
|
||||
## Package dependencies
|
||||
|
||||
npm installs peer dependencies by default, so listing `@clusterio/web_ui` under `peerDependencies` installs it everywhere the plugin is installed even though it's only needed to build the web bundle.
|
||||
Move `@clusterio/web_ui` to `devDependencies` and mark `@clusterio/controller`, `@clusterio/host` and `@clusterio/ctl` as optional in `peerDependenciesMeta`:
|
||||
|
||||
```json
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "^2.0.0",
|
||||
"@clusterio/host": "^2.0.0",
|
||||
"@clusterio/lib": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -14,7 +14,7 @@ All messages sent and received are validated with JSON schemas to catch potentia
|
||||
Messages are sent by calling the `.send()` method of the message instance and passing a `Link` instance to send it over and the data to send.
|
||||
For the controller there's a `Link` instance for each host and control connection stored in the `WsServer` class.
|
||||
For hosts the `Host` instance is a link to the controller, and each `Instance` instance is an in-memory link to the `Host` instance and has a corresponding `InstanceConnection` link stored on the `Host` instance.
|
||||
For ctl and web_ui there's a `Control` instance that acts as the link to the controller.
|
||||
For ctl there's a `Ctl` instance and web_ui there's a `Control` instance that acts as the link to the controller.
|
||||
|
||||
Messages received invokes message handlers that were registered when the link was brought up.
|
||||
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
|
||||
|
||||
@@ -5,6 +5,8 @@ The save patching is based on the built in [event_handler](https://github.com/wu
|
||||
Mods on the other hand work without modifications.
|
||||
|
||||
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
|
||||
To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it.
|
||||
Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario.
|
||||
|
||||
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` s
|
||||
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
|
||||
|
||||
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
|
||||
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-control.json` for managing the cluster as the given user.
|
||||
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-ctl.json` for managing the cluster as the given user.
|
||||
|
||||
|
||||
## Hosts
|
||||
@@ -43,6 +43,14 @@ The controller also supports remote updates, following the same logic. The corre
|
||||
ctl> controller update [--restart]
|
||||
controller> config set controller.allow_remote_updates true/false
|
||||
|
||||
### Mark restart required
|
||||
|
||||
ctl> host mark-restart-required <host>
|
||||
|
||||
Marks the host as requiring a restart, the same way a plugin update does. Use it after installing or changing a plugin by other means than `host plugin install`, such as a plugin checked out into `external_plugins`. The mark shows up in the web interface on the next system metrics poll. The controller has the same command:
|
||||
|
||||
ctl> controller mark-restart-required
|
||||
|
||||
## Instances
|
||||
|
||||
### List Instances
|
||||
@@ -65,6 +73,12 @@ Terminates the running Factorio server without giving it a chance to save or cle
|
||||
|
||||
Note: This may cause loss of data.
|
||||
|
||||
### Save game
|
||||
|
||||
ctl> instance save-game <name>
|
||||
|
||||
Saves the running game of the instance to the save it was started from and waits for the save to finish.
|
||||
|
||||
### Extract players from instance
|
||||
|
||||
ctl> instance extract-players <name>
|
||||
@@ -241,6 +255,13 @@ This command lists all plugins currently known to the remote. Newly installed pl
|
||||
|
||||
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Update All
|
||||
|
||||
ctl> controller update --all [--restart]
|
||||
ctl> host update <host> --all [--restart]
|
||||
|
||||
Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote.
|
||||
|
||||
### Install
|
||||
|
||||
ctl> controller plugin install <name> [--restart]
|
||||
@@ -261,3 +282,29 @@ Remote updates are enabled by default but can be disabled on a per-machine basis
|
||||
controller> config set controller.allow_plugin_install true/false
|
||||
|
||||
Remote installs are disabled by default but can be enabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline.
|
||||
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The controller exports metrics for the whole cluster on the `/metrics` endpoint.
|
||||
Fetching it requires a token for a user with the `core.controller.metrics` permission, sent either as an `Authorization: Bearer <token>` header or as an `X-Access-Token` header.
|
||||
Requests without a valid token get a 401 response and requests for users lacking the permission get a 403 response.
|
||||
|
||||
Create a role with just this permission, a user to hold it, and generate a token for that user.
|
||||
Give the user a name that is not a valid Factorio player name, such as one starting with `#`, so a player joining the cluster can never claim the account and its token:
|
||||
|
||||
ctl> role create Prometheus --permissions core.controller.metrics
|
||||
ctl> user create #prometheus
|
||||
ctl> user set-roles #prometheus Prometheus
|
||||
$ npx clusteriocontroller bootstrap generate-user-token '#prometheus'
|
||||
|
||||
Then pass the token in the scrape job for the controller in `prometheus.yml`:
|
||||
|
||||
scrape_configs:
|
||||
- job_name: clusterio
|
||||
authorization:
|
||||
credentials: <token>
|
||||
static_configs:
|
||||
- targets: ["localhost:8080"]
|
||||
|
||||
Concurrent scrapes share a single round of metrics gathering from the hosts, so multiple scrapers polling the controller do not multiply the load on the hosts.
|
||||
@@ -5,7 +5,9 @@
|
||||
- CHANGELOG.md in the repository root
|
||||
- The changelog.txt file in mod directories
|
||||
|
||||
Pull requests will usually have suggestions for changelog entries.
|
||||
Pull requests will usually have suggestions for changelog entries. These can be automatically parsed and formatted by running `node scripts/pr_to_changelog.mts`, though you will have to manually sort through which should go in the changelog in the repository root and which should go in the changelogs in mod directories.
|
||||
|
||||
If the script fails due to GitHub's 60/hour public access rate limit, you can set the `GH_TOKEN` environment variable to a fine-grained personal access token which has a much higher rate limits.
|
||||
|
||||
## Packages
|
||||
|
||||
|
||||
+19
-4
@@ -63,10 +63,25 @@ This creates a private key file named `key.pem` and a public certificate file na
|
||||
npx clusteriocontroller config set controller.tls_certificate cert.pem
|
||||
npx clusteriocontroller config set controller.tls_private_key key.pem
|
||||
|
||||
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure it as the `host.tls_ca` option:
|
||||
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
|
||||
|
||||
npx clusteriohost config set host.tls_ca cert.pem
|
||||
**For run-host.cmd**
|
||||
|
||||
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
|
||||
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
|
||||
|
||||
npx clusterioctl control-config set control.tls_ca cert.pem
|
||||
**For run-host.sh**
|
||||
|
||||
export "NODE_EXTRA_CA_CERTS=path/to/cert.pem"
|
||||
|
||||
**For clusteriohost.service**
|
||||
|
||||
Environment=NODE_EXTRA_CA_CERTS=path/to/cert.pem
|
||||
|
||||
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and set the `NODE_EXTRA_CA_CERTS` environment variable as a path to that file before running clusterioctl. For example:
|
||||
|
||||
# On Windows
|
||||
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
|
||||
# On Linux
|
||||
export NODE_EXTRA_CA_CERTS="path/to/cert.pem"
|
||||
# You can now use clusterioctl
|
||||
npx clusterioctl instance list
|
||||
+194
-103
@@ -1,15 +1,16 @@
|
||||
# Writing Plugins
|
||||
|
||||
Plugins for Clusterio are classes written in JavaScript that run under Node.js.
|
||||
The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
|
||||
Plugins for Clusterio are JavaScript modules that run under Node.js.
|
||||
Each entrypoint of a plugin exports a function that attaches handlers to hooks called during various stages and operations of Clusterio.
|
||||
|
||||
|
||||
## Contents
|
||||
|
||||
- [Plugin Structure](#plugin-structure)
|
||||
- [Defining the plugin class](#defining-the-plugin-class)
|
||||
- [Defining the plugin entrypoint](#defining-the-plugin-entrypoint)
|
||||
- [Logging Messages](#logging-messages)
|
||||
- [Plugin Configuration](#plugin-configuration)
|
||||
- [Plugin Permissions](#plugin-permissions)
|
||||
- [Communicating with Factorio](#communicating-with-factorio)
|
||||
- [Defining Link Messages](#defining-link-messages)
|
||||
- [Sending Link Messages](#sending-link-messages)
|
||||
@@ -27,7 +28,7 @@ The basic file structure of a plugin is the following.
|
||||
+- package.json
|
||||
+- controller.js
|
||||
+- instance.js
|
||||
+- control.js
|
||||
+- ctl.js
|
||||
+- test/
|
||||
| +- plugin.js
|
||||
+- module/
|
||||
@@ -48,12 +49,12 @@ By default the main entrypoint is the `index.js` file, but this may be changed b
|
||||
Here's an example of `index.js`:
|
||||
|
||||
```js
|
||||
module.exports.plugin = {
|
||||
export const plugin = {
|
||||
name: "foo_frobber",
|
||||
title: "Foo Frobber",
|
||||
description: "Does advanced frobnication",
|
||||
instanceEntrypoint: "instance",
|
||||
controllerEntrypoint: "controller",
|
||||
instanceEntrypoint: "instance.js",
|
||||
controllerEntrypoint: "controller.js",
|
||||
messages: {
|
||||
/* See below */
|
||||
},
|
||||
@@ -75,7 +76,7 @@ The following properties are recognized:
|
||||
Currently not used.
|
||||
|
||||
**instanceEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
|
||||
Path to a Node.js module relative to the plugin directory which exports the instance entrypoint function for this plugin.
|
||||
This is an optional paramater.
|
||||
A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance.
|
||||
|
||||
@@ -84,7 +85,7 @@ The following properties are recognized:
|
||||
See [Plugin Configuration](#plugin-configuration)
|
||||
|
||||
**controllerEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which contains the ControllerPlugin class definiton for this plugin.
|
||||
Path to a Node.js module relative to the plugin directory which exports the controller entrypoint function for this plugin.
|
||||
This is an optional parameter.
|
||||
A plugin can be made that only runs on the controller.
|
||||
|
||||
@@ -93,77 +94,90 @@ The following properties are recognized:
|
||||
See [Plugin Configuration](#plugin-configuration)
|
||||
|
||||
**ctlEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which contains the CtlPlugin class definition for this plugin.
|
||||
Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin.
|
||||
This is an optional paramater.
|
||||
A plugin can be made that only runs on the clusterioctl side.
|
||||
|
||||
**features**:
|
||||
Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
|
||||
See [Handling Invalid Configuration](#handling-invalid-configuration)
|
||||
|
||||
**messages**:
|
||||
Object with link messages definitions for this plugin.
|
||||
See guide for [defining link messages](#defining-link-messages) below.
|
||||
|
||||
**permissions**:
|
||||
Array of permission definitions for this plugin.
|
||||
See [Plugin Permissions](#plugin-permissions)
|
||||
|
||||
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
|
||||
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
|
||||
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
|
||||
|
||||
While there is no standard for how to organize a plugin it's recommended to put the ControllerPlugin class definition into controller.js and the InstancePlugin class definition into instance.js.
|
||||
While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
|
||||
You can put them into whatever file you want (even the same one for both).
|
||||
|
||||
For both instanceEntrypoint and controllerEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
|
||||
The entrypoint paths must include the .js extension and use forward slashes for directory separators if any.
|
||||
|
||||
|
||||
## Defining the plugin class
|
||||
## Defining the plugin entrypoint
|
||||
|
||||
The plugin class should derive from its respective base class defined in `lib/plugin`.
|
||||
For example, to define a ControllerPlugin class the following code can be used:
|
||||
Each entrypoint module exports a default async function which is called once when the plugin is loaded.
|
||||
It is passed a context object with what the plugin needs to interact with Clusterio, and attaches handlers to the hooks it is interested in.
|
||||
For example, a controller entrypoint can look like this:
|
||||
|
||||
```js
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
export default async function(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
this.foo = 42;
|
||||
await this.startFrobnication();
|
||||
}
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
await saveFrobnicationData();
|
||||
});
|
||||
|
||||
// ...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("Stopping frobnication");
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", for host plugins "Host" is used and for the clusterioctl plugin "Ctl" is used.
|
||||
The available hooks that you can override are documented in the base classes.
|
||||
The context has the following properties:
|
||||
|
||||
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
|
||||
E.g.:
|
||||
**plugin**:
|
||||
The plugin's own info, as exported from the main entrypoint.
|
||||
|
||||
```js
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
**logger**:
|
||||
Logger for this plugin, see [Logging Messages](#logging-messages).
|
||||
|
||||
// Code here
|
||||
}
|
||||
```
|
||||
**controller**, **host**, **instance**, **hooks** or **control**:
|
||||
The object the entrypoint is loaded for.
|
||||
Controller entrypoints get `controller`, host entrypoints get `host`, instance entrypoints get both `instance` and `host`, clusterioctl entrypoints get `hooks` and web entrypoints get `control`.
|
||||
|
||||
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
|
||||
The async init method is always called immediatly after the constructor, so there's little reason to do this.
|
||||
The available hooks are documented on the `ControllerHooks`, `HostHooks`, `InstanceHooks`, `CtlHooks` and `WebHooks` classes.
|
||||
Handlers are attached under a name, which should be the name of the plugin, and a hook only accepts one handler per name.
|
||||
An error thrown by a handler is logged and does not affect the other handlers, the same goes for handlers that take more than 15 seconds to complete.
|
||||
|
||||
For TypeScript the context types are exported as `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from their respective packages.
|
||||
|
||||
### Class based plugins
|
||||
|
||||
Plugins written as classes deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin` still load, but this way of defining plugins is deprecated and a warning is logged when such a plugin is loaded.
|
||||
The base class constructor attaches the `on*` methods overridden by the plugin to the corresponding hooks, and the async `init` method is called immediately after.
|
||||
See [Migrating Plugins to 2.0](devs/plugin-migration-to-2.0.md) for how to convert them.
|
||||
|
||||
|
||||
## Logging Messages
|
||||
|
||||
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
|
||||
The load context provides a winston logger for logging messages to the shared cluster log.
|
||||
For instances a copy of the log is also stored on the host the instance is on.
|
||||
To use it, pass a string to one of the log levels functions, for example:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
this.logger.info("Initializing frobbing");
|
||||
export default async function(context) {
|
||||
context.logger.info("Initializing frobbing");
|
||||
}
|
||||
```
|
||||
|
||||
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
|
||||
Metadata covering which plugin and instance (for instance entrypoints) the log event happened on will automatically be attached to the log message.
|
||||
|
||||
The available levels are fatal, error, warn, audit, info, and verbose.
|
||||
These levels have roughly the following meanings:
|
||||
@@ -189,7 +203,7 @@ For guarding unexpected errors the best option is to log a short description alo
|
||||
try {
|
||||
// Operation that should not throw but may end up throwing
|
||||
} catch (err) {
|
||||
this.logger.error(`Operation failed:\n${err.stack}`);
|
||||
logger.error(`Operation failed:\n${err.stack}`);
|
||||
)
|
||||
```
|
||||
|
||||
@@ -213,7 +227,7 @@ declare module "@clusterio/lib" {
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
export const plugin = {
|
||||
...
|
||||
controllerConfigFields: {
|
||||
"foo_frobber.level": {
|
||||
@@ -225,12 +239,12 @@ export default {
|
||||
} satisfies lib.PluginDeclaration;
|
||||
```
|
||||
|
||||
Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass:
|
||||
The controller entrypoint will then be able to access the level config field through the `Config` object at `controller.config`:
|
||||
|
||||
```ts
|
||||
async init() {
|
||||
let level = this.controller.config.get("foo_frobber.level");
|
||||
this.logger.info(`I got a frobnication level of ${level}`);
|
||||
export default async function(context: ControllerPluginContext) {
|
||||
const level = context.controller.config.get("foo_frobber.level");
|
||||
context.logger.info(`I got a frobnication level of ${level}`);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -240,33 +254,76 @@ See [Configuration System](config-system.md) for more details on how this system
|
||||
|
||||
### Handling Invalid Configuration
|
||||
|
||||
If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
|
||||
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
|
||||
For example:
|
||||
If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("foo_frobber plugin requires save patching.");
|
||||
export const plugin = {
|
||||
name: "foo_frobber",
|
||||
// ...
|
||||
features: ["SavePatching"],
|
||||
};
|
||||
```
|
||||
|
||||
Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts.
|
||||
|
||||
Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable.
|
||||
Use `dependsOn` when the check involves other fields so that the validator runs when those change too.
|
||||
See [Configuration System](config-system.md) for details.
|
||||
|
||||
|
||||
## Plugin Permissions
|
||||
|
||||
Permissions are granted to roles and checked on users, both on the controller and in the web UI.
|
||||
A plugin declares the permissions it uses under `permissions` in the `plugin` export.
|
||||
Names must start with the plugin name followed by a dot.
|
||||
For example in index.ts:
|
||||
|
||||
```ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
// Extend the interface of known permission names so that
|
||||
// user.checkPermission(...) accepts our permission.
|
||||
export interface Permissions {
|
||||
"foo_frobber.frobnicate": never;
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin = {
|
||||
...
|
||||
permissions: [
|
||||
{
|
||||
name: "foo_frobber.frobnicate",
|
||||
title: "Frobnicate",
|
||||
description: "Run frobnication on instances.",
|
||||
grantByDefault: false, // Whether the generated Player role is granted this permission
|
||||
},
|
||||
],
|
||||
} satisfies lib.PluginDeclaration;
|
||||
```
|
||||
|
||||
The `Permissions` interface only exists at the type level, its values are always `never` and only the keys are used.
|
||||
Adding your permission names to it gives you autocomplete and a compile error on typos when calling `user.checkPermission`, `account.hasPermission` or setting the `permission` of a web UI page.
|
||||
In JavaScript the `declare module` block is not needed.
|
||||
|
||||
Permissions declared this way are registered when the plugin is loaded.
|
||||
The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
|
||||
|
||||
|
||||
## Communicating with Factorio
|
||||
|
||||
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
|
||||
This is done by calling the `sendRcon` method on the plugin object.
|
||||
This is done by calling the `sendRcon` method on the instance.
|
||||
For example:
|
||||
|
||||
```js
|
||||
async onStart() {
|
||||
let response = await this.sendRcon(
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
const response = await instance.sendRcon(
|
||||
"/sc rcon.print('data')"
|
||||
);
|
||||
|
||||
// Do stuff with response.
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
|
||||
@@ -279,15 +336,16 @@ From a plugin you listen for an event named `ipc-channel_name` in order to get d
|
||||
For example in the plugin code:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
this.instance.server.on("ipc-my_plugin_foo", content =>
|
||||
this.handleFoo(content).catch(err => this.logger.error(
|
||||
export default async function(context) {
|
||||
const { instance, logger } = context;
|
||||
instance.server.on("ipc-my_plugin_foo", content =>
|
||||
handleFoo(content).catch(err => logger.error(
|
||||
`Error handling foo:\n${err.stack}`
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
async handleFoo(content) {
|
||||
async function handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
```
|
||||
@@ -304,11 +362,11 @@ clusterio_api.send_json("my_plugin_foo", { data = 123 })
|
||||
For convience a helper function is exposed on server specifically for handling IPC events.
|
||||
|
||||
```js
|
||||
async init() {
|
||||
this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this));
|
||||
export default async function(context) {
|
||||
context.instance.server.handle("my_plugin_foo", handleFoo);
|
||||
}
|
||||
|
||||
async handleFoo(content) {
|
||||
async function handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
```
|
||||
@@ -445,15 +503,34 @@ Link messages are sent by calling the `.sendTo()` method on a connection object
|
||||
For example:
|
||||
|
||||
```js
|
||||
// In an InstancePlugin class
|
||||
async frobnicate() {
|
||||
const response = await this.instance.sendTo("controller", new messages.Frobnicate({
|
||||
// In an instance entrypoint
|
||||
async function frobnicate(instance) {
|
||||
const response = await instance.sendTo("controller", new messages.Frobnicate({
|
||||
verbosity: 2,
|
||||
special: false,
|
||||
}));
|
||||
console.log(response); // { report: [...] }
|
||||
}
|
||||
```
|
||||
Because the send and handle methods rely on this, you should always call them directly on the connection object.
|
||||
|
||||
If you extract them into a variable, destructure them, or pass them as unbound callbacks, they lose their this context. The method will eventually crash with an error like Cannot read properties of undefined (reading 'sendRequest').
|
||||
|
||||
While it's fine to type cast a direct method invocation, casting and then storing the result causes that same dangerous loss of context.
|
||||
|
||||
Here is what that looks like in practice:
|
||||
|
||||
```js
|
||||
// ❌ BAD: The extracted method loses its `this` binding and crashes when called.
|
||||
const send = instance.sendTo;
|
||||
send("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
|
||||
// ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed).
|
||||
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
|
||||
// ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler).
|
||||
instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin));
|
||||
```
|
||||
|
||||
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
|
||||
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
|
||||
@@ -474,23 +551,23 @@ sendTo({ controlId: 123 }, message);
|
||||
|
||||
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
|
||||
The most important is the prepare disconnect for the link between controller and host.
|
||||
This is signaled to `ControllerPlugin` classes via the `onPrepareHostDisconnect` hook and to `InstancePlugin` classes via the `onPrepareControllerDisconnect` hook.
|
||||
This is signaled to controller plugins via the `prepareHostDisconnect` hook and to instance plugins via the `prepareControllerDisconnect` hook.
|
||||
|
||||
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
|
||||
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
|
||||
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively.
|
||||
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
|
||||
For example the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
|
||||
|
||||
```js
|
||||
if (this.host.connected) {
|
||||
this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
if (host.connected) {
|
||||
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
}
|
||||
```
|
||||
|
||||
If the event or request needs to be sent to the controller it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
|
||||
The re-establishement of the connection is notified to plugins via the `connect` event to the `onControllerConnectionEvent` and `onHostConnectionEvent` hooks.
|
||||
If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
|
||||
The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks.
|
||||
|
||||
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onControllerConnectionEvent` for `InstancePlugin` classes and through `onHostConnectionEvent` for `ControllerPlugin` classes.
|
||||
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
|
||||
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
|
||||
When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost.
|
||||
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
|
||||
@@ -506,7 +583,7 @@ In its simplest form collecting data from plugins consists of defining the metri
|
||||
For example:
|
||||
|
||||
```js
|
||||
const { Counter } = require("@clusterio/lib");
|
||||
import { Counter } from "@clusterio/lib";
|
||||
|
||||
const fooMetric = new Counter(
|
||||
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
|
||||
@@ -516,13 +593,13 @@ const fooMetric = new Counter(
|
||||
fooMetric.inc();
|
||||
```
|
||||
|
||||
This works for controller plugins, and the metric will be automatically available through the /metric HTTP endpoint.
|
||||
This works for controller plugins, and the metric will be automatically available through the /metrics HTTP endpoint.
|
||||
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
|
||||
|
||||
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
|
||||
|
||||
```js
|
||||
const { Counter } = require("@clusterio/lib");
|
||||
import { Counter } from "@clusterio/lib";
|
||||
|
||||
const barMetric = new Gauge(
|
||||
"clusterio_foo_frobber_bar_metric", "Bar instance level",
|
||||
@@ -530,23 +607,48 @@ const barMetric = new Gauge(
|
||||
);
|
||||
|
||||
// Somewhere in the instance plugin code
|
||||
barMetric.labels(String(this.instance.id)).set(someValue);
|
||||
barMetric.labels(String(instance.id)).set(someValue);
|
||||
```
|
||||
|
||||
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the controller on hosts.
|
||||
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host.
|
||||
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
|
||||
|
||||
For statistics you need to update on collection there's an `onMetrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected.
|
||||
For statistics you need to update on collection there's a `metrics` hook on the controller, host and instance that is run before the metrics in the default registry are collected.
|
||||
|
||||
```js
|
||||
import { Gauge } from "@clusterio/lib";
|
||||
|
||||
const frobnicationsMetric = new Gauge(
|
||||
"clusterio_foo_frobber_frobnications", "Frobnications currently in progress"
|
||||
);
|
||||
|
||||
// In the controller entrypoint
|
||||
controller.hooks.metrics.attach(plugin.name, async () => {
|
||||
frobnicationsMetric.set(frobnications.size);
|
||||
});
|
||||
```
|
||||
|
||||
A handler may also return the results of collecting collectors that are not in the default registry, and they are added to the response.
|
||||
|
||||
```js
|
||||
import { CollectorRegistry, Counter } from "@clusterio/lib";
|
||||
|
||||
const fooRegistry = new CollectorRegistry();
|
||||
const fooCounter = new Counter("clusterio_foo_frobber_foos", "Foos frobbed", { register: false });
|
||||
fooRegistry.register(fooCounter);
|
||||
|
||||
controller.hooks.metrics.attach(plugin.name, async () => fooRegistry.collect());
|
||||
```
|
||||
|
||||
|
||||
## Adding Custom Commands to clusterioctl
|
||||
|
||||
The control entrypoint for plugins allows you to extend clustectl with your own commands.
|
||||
The ctl entrypoint for plugins allows you to extend clustectl with your own commands.
|
||||
The creation of custom commands typically starts with defining a command tree for the plugin:
|
||||
|
||||
```js
|
||||
const { Command, CommandTree } = require("@clusterio/lib");
|
||||
import { Command, CommandTree } from "@clusterio/lib";
|
||||
const fooFrobberCommands = new CommandTree({
|
||||
name: "foo-frobber", description: "Foo Frobber Plugin commands"
|
||||
});
|
||||
@@ -555,41 +657,30 @@ const fooFrobberCommands = new CommandTree({
|
||||
Then commands are added to the the plugin's command tree:
|
||||
|
||||
```js
|
||||
const info = require("./info");
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
fooFrobberCommands.add(new Command({
|
||||
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
|
||||
yargs.positional("level", {
|
||||
yargs.positional("type", {
|
||||
describe: "type of frobnication", type: "string"
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await info.messages.frobnicate.send(control, {
|
||||
instance_name: "Console",
|
||||
content: args.message,
|
||||
});
|
||||
handler: async function(args, ctl) {
|
||||
await ctl.sendTo("controller", new messages.FrobnicateRequest(args.type));
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
|
||||
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
|
||||
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
|
||||
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Ctl` class of clusterioctl.
|
||||
Note that messages sent from clusterioctl needs to have `"control"` as a part of `src` for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
|
||||
|
||||
Note that messages sent from clusterioctl needs to have `"control-controller"` as a part of the links array for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
|
||||
|
||||
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Ctl plugin:
|
||||
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in the `addCommands` hook of the ctl entrypoint:
|
||||
|
||||
```js
|
||||
const { BaseCtlPlugin } = require("@clusterio/ctl");
|
||||
|
||||
class CtlPlugin extends BaseCtlPlugin {
|
||||
async addCommands(rootCommand) {
|
||||
export default async function(context) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
rootCommand.add(fooFrobberCommands);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CtlPlugin,
|
||||
});
|
||||
}
|
||||
```
|
||||
+276
-186
@@ -1,118 +1,47 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"node",
|
||||
],
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"es2022": true,
|
||||
import {
|
||||
defineConfig,
|
||||
globalIgnores,
|
||||
} from "eslint/config";
|
||||
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import node from "eslint-plugin-node";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import js from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
|
||||
import {
|
||||
FlatCompat,
|
||||
} from "@eslint/eslintrc";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default defineConfig([{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
"@stylistic": stylistic,
|
||||
node,
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["{packages,plugins}/**/*.ts?(x)"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
"@typescript-eslint/no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.commonjs,
|
||||
},
|
||||
{
|
||||
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
"env": {
|
||||
"mocha": "true",
|
||||
},
|
||||
"parserOptions": { sourceType: "commonjs" },
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"valid-jsdoc": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "off",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/create/templates/**/*.{t,j}s?(x)",
|
||||
],
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"@stylistic/indent": ["error", "tab", {
|
||||
"ignoredNodes": [
|
||||
"JSXFragment", // TODO: Linter crashes if this is removed
|
||||
],
|
||||
"ignoreComments": true,
|
||||
"SwitchCase": 1, // Indent case statements one level from switch
|
||||
}],
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
@@ -120,26 +49,34 @@ module.exports = {
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
"arrow-spacing": ["error", { "after": true, "before": true }],
|
||||
|
||||
"arrow-spacing": ["error", {
|
||||
"after": true,
|
||||
"before": true,
|
||||
}],
|
||||
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
|
||||
|
||||
"brace-style": ["error", "1tbs", {
|
||||
"allowSingleLine": true,
|
||||
}],
|
||||
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
},
|
||||
],
|
||||
|
||||
"comma-dangle": ["error", {
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
}],
|
||||
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": "error",
|
||||
"complexity": ["error", { "max": 25 }],
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
@@ -148,27 +85,36 @@ module.exports = {
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "off",
|
||||
"dot-location": ["error", "property"],
|
||||
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
|
||||
|
||||
"dot-notation": ["error", {
|
||||
"allowPattern": "^[a-z]+(_[a-z]+)*$",
|
||||
}],
|
||||
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
|
||||
"func-style": ["error", "declaration", {
|
||||
// Allow arrow functions as it better expresses referencing this
|
||||
// of the containing scope, which is unweilding in plain functions.
|
||||
"allowArrowFunctions": true,
|
||||
}],
|
||||
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"generator-star-spacing": ["error", { "before": false, "after": true }],
|
||||
|
||||
"generator-star-spacing": ["error", {
|
||||
"before": false,
|
||||
"after": true,
|
||||
}],
|
||||
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "error",
|
||||
"indent": ["error", "tab", { "SwitchCase": 1 }],
|
||||
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
@@ -178,10 +124,18 @@ module.exports = {
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "off",
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
|
||||
"lines-between-class-members": ["error", "always", {
|
||||
"exceptAfterSingleLine": true,
|
||||
}],
|
||||
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "error",
|
||||
"max-len": ["error", { "code": 120 }],
|
||||
|
||||
"max-len": ["error", {
|
||||
"code": 120,
|
||||
}],
|
||||
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
@@ -190,10 +144,13 @@ module.exports = {
|
||||
"max-statements-per-line": "off",
|
||||
"multiline-comment-style": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
|
||||
"new-cap": ["error", {
|
||||
"capIsNewExceptions": ["StringEnum", "StringKey"],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
"newIsCapExceptions": ["constructor"],
|
||||
}],
|
||||
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
@@ -207,7 +164,11 @@ module.exports = {
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-console": "error",
|
||||
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||
|
||||
"no-constant-condition": ["error", {
|
||||
"checkLoops": false,
|
||||
}],
|
||||
|
||||
"no-constructor-return": "error",
|
||||
"no-continue": "off",
|
||||
"no-div-regex": "error",
|
||||
@@ -232,16 +193,15 @@ module.exports = {
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-magic-numbers": "off",
|
||||
"no-mixed-operators": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"no-mixed-operators": ["error", {
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
}],
|
||||
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
@@ -256,7 +216,11 @@ module.exports = {
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
|
||||
|
||||
"no-plusplus": ["error", {
|
||||
"allowForLoopAfterthoughts": true,
|
||||
}],
|
||||
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
@@ -268,9 +232,15 @@ module.exports = {
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
|
||||
|
||||
"no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
"no-spaced-func": "error",
|
||||
"no-tabs": ["error", { "allowIndentationTabs": true }],
|
||||
|
||||
"no-tabs": "off",
|
||||
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
@@ -289,7 +259,11 @@ module.exports = {
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
"no-void": ["error", { "allowAsStatement": true }],
|
||||
|
||||
"no-void": ["error", {
|
||||
"allowAsStatement": true,
|
||||
}],
|
||||
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
@@ -299,6 +273,7 @@ module.exports = {
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
@@ -308,6 +283,7 @@ module.exports = {
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
@@ -324,9 +300,13 @@ module.exports = {
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
|
||||
"quotes": ["error", "double", {
|
||||
"avoidEscape": true,
|
||||
}],
|
||||
|
||||
"radix": ["error", "always"],
|
||||
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
|
||||
"require-atomic-updates": "off",
|
||||
"require-await": "off",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
@@ -342,73 +322,183 @@ module.exports = {
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": "error",
|
||||
|
||||
"spaced-comment": ["error", "always", {
|
||||
"line": {
|
||||
"markers": ["/"],
|
||||
},
|
||||
}],
|
||||
"strict": "error",
|
||||
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": ["error", "never"],
|
||||
"valid-jsdoc": ["error", {
|
||||
"requireReturn": false,
|
||||
"requireReturnType": false,
|
||||
"requireParamType": false,
|
||||
}],
|
||||
|
||||
"vars-on-top": "error",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "off",
|
||||
"yield-star-spacing": "error",
|
||||
"yoda": ["error", "never", { "exceptRange": true }],
|
||||
|
||||
"yoda": ["error", "never", {
|
||||
"exceptRange": true,
|
||||
}],
|
||||
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
// Node.js callback passing style of (err, value) is not used in this project
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "error",
|
||||
"node/no-exports-assign": "error",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": ["error", {
|
||||
"tryExtensions": [".js", ".ts"],
|
||||
}],
|
||||
"node/no-missing-require": "error",
|
||||
"node/no-mixed-requires": "error",
|
||||
"node/no-new-require": "error",
|
||||
"node/no-path-concat": "error",
|
||||
"node/no-process-env": "error",
|
||||
"node/no-process-exit": "error",
|
||||
|
||||
"node/no-missing-import": "off",
|
||||
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "error",
|
||||
"node/no-unpublished-import": "error",
|
||||
"node/no-unpublished-require": [
|
||||
"error",
|
||||
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
|
||||
],
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
|
||||
"node/no-unpublished-require": "off",
|
||||
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "error",
|
||||
"node/prefer-global/console": "error",
|
||||
"node/prefer-global/process": "error",
|
||||
"node/prefer-global/text-decoder": "error",
|
||||
"node/prefer-global/text-encoder": "error",
|
||||
"node/prefer-global/url-search-params": "error",
|
||||
"node/prefer-global/url": "error",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "error",
|
||||
"node/shebang": ["error", {
|
||||
"convertPath": {
|
||||
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
|
||||
},
|
||||
}],
|
||||
"node/process-exit-as-throw": "off",
|
||||
|
||||
"node/shebang": "off",
|
||||
},
|
||||
};
|
||||
}, {
|
||||
files: ["{packages,plugins}/**/*.ts?(x)"],
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
extends: compat.extends("plugin:@typescript-eslint/recommended"),
|
||||
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
|
||||
"@typescript-eslint/no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.mocha,
|
||||
},
|
||||
|
||||
sourceType: "module",
|
||||
parserOptions: {},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
}, {
|
||||
files: [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
"sourceType": "module",
|
||||
|
||||
parserOptions: {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["packages/create/templates/**/*.?(c){t,j}s?(x)"],
|
||||
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"@stylistic/indent": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
}, globalIgnores(["**/dist/", "dist/"])]);
|
||||
+36
-31
@@ -2,32 +2,34 @@
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"files": [],
|
||||
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
|
||||
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
|
||||
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
|
||||
"test": "pnpm run build && NODE_EXTRA_CA_CERTS=$(pwd)/test/file/tls/cert.pem mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
|
||||
"silent-test": "SILENT_TEST=y pnpm test",
|
||||
"fast-test": "FAST_TEST=y pnpm test",
|
||||
"cover": "nyc pnpm test",
|
||||
"fast-cover": "FAST_TEST=y nyc pnpm test",
|
||||
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
|
||||
"no-external-test": "NO_EXTERNAL_TEST=y pnpm test",
|
||||
"cover": "c8 pnpm test",
|
||||
"fast-cover": "FAST_TEST=y c8 pnpm test",
|
||||
"ci-cover": "c8 --reporter=lcovonly pnpm test",
|
||||
"lint": "eslint packages plugins test",
|
||||
"lint-fix": "eslint --fix packages plugins test",
|
||||
"clean": "node ./clean.js fast",
|
||||
"clean-tests": "node ./clean.js tests",
|
||||
"clean-all": "node ./clean.js all",
|
||||
"clean": "node ./clean.js",
|
||||
"docs": "typedoc",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
"nyc": {
|
||||
"c8": {
|
||||
"exclude": [
|
||||
"test/**",
|
||||
"plugins/*/test/**"
|
||||
"plugins/*/test/**",
|
||||
"temp/**"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -36,26 +38,29 @@
|
||||
"external_plugins/*/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/ctl": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.57.1",
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/ctl": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"c8": "^12.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||
"@typescript-eslint/parser": "^8.70.0",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "^4.21.2",
|
||||
"form-data": "^4.0.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.0",
|
||||
"mocha": "^10.8.2",
|
||||
"nyc": "^15.1.0",
|
||||
"phin": "^3.7.0",
|
||||
"typedoc": "^0.26.11",
|
||||
"typescript": "^5.7.3",
|
||||
"yargs": "^17.7.2"
|
||||
"express": "catalog:",
|
||||
"form-data": "^4.0.6",
|
||||
"globals": "^17.12.0",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"mocha": "^11.8.0",
|
||||
"typedoc": "^0.28.20",
|
||||
"typescript": "catalog:",
|
||||
"yargs": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,12 @@ Common options:
|
||||
See the `plugin` command for managing this list.
|
||||
|
||||
* `--config <file>` JSON file to use for storing configuration for the controller.
|
||||
Defaults to `config-controller.json` and will be created if it does not exist.
|
||||
Defaults to `config-controller.json` and is required for operation.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
|
||||
Alternative to the `config create` command.
|
||||
|
||||
|
||||
### `plugin <command>`
|
||||
|
||||
@@ -25,17 +28,28 @@ Configure plugins available to be loaded by the controller.
|
||||
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
|
||||
|
||||
|
||||
#### `plugin install <package>`
|
||||
|
||||
Install a plugin from npm and add it to the plugin list.
|
||||
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
|
||||
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin add <path>`
|
||||
|
||||
Add plugin either by require path or relative/absolute path to the plugin directory.
|
||||
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
|
||||
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
For example, adding a plugin checked out in the external_plugins directory:
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
npx clusteriocontroller plugin add ./external_plugins/my_plugin
|
||||
|
||||
|
||||
#### `plugin remove <name>`
|
||||
@@ -63,6 +77,13 @@ Manage the controller configuration offline.
|
||||
This should only be used when the controller is stopped, otherwise the config read might be out of date and config changes will be overwritten when the controller shuts down.
|
||||
|
||||
|
||||
#### `config create`
|
||||
|
||||
Create a new config file with the default options inserted into if it doesn't already exist.
|
||||
|
||||
See also the `--create-config` option.
|
||||
|
||||
|
||||
#### `config set <config-entry> [value]`
|
||||
|
||||
Set a config entry to the given value.
|
||||
@@ -104,6 +125,16 @@ This can be used both in the web interface and as the `controller_token` to conn
|
||||
Creates a clusterioctl config for the given user with url and token set up for connecting to the cluster.
|
||||
|
||||
|
||||
### `copy-static [target]`
|
||||
|
||||
Copies the static web assets to the given target folder (which defaults to `./static/`).
|
||||
Useful in case you want to host the assets on a CDN or separate web server and want provide the files directly instead of fetching them from the controller.
|
||||
|
||||
Note that mod pack exports will create additional static files that also have to be hosted, these are written to the `./static/` folder in the clusterio installation directory.
|
||||
|
||||
See also the `controller.public_url` config option for setting where static assets are loaded from.
|
||||
|
||||
|
||||
### `run`
|
||||
|
||||
Runs the controller.
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import crypto from "crypto";
|
||||
import setBlocking from "set-blocking";
|
||||
import yargs from "yargs";
|
||||
import util from "util";
|
||||
import winston from "winston";
|
||||
@@ -28,11 +27,12 @@ import jwt from "jsonwebtoken";
|
||||
|
||||
// homebrew modules
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { ConsoleTransport, levels, logger } = lib;
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import Controller from "./src/Controller";
|
||||
import UserManager from "./src/UserManager";
|
||||
import { version } from "./package.json";
|
||||
import Controller from "./src/Controller.js";
|
||||
import UserManager from "./src/UserManager.js";
|
||||
import UserRecord from "./src/UserRecord.js";
|
||||
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
|
||||
|
||||
// globals
|
||||
let controller: Controller;
|
||||
@@ -101,6 +101,9 @@ void new lib.Gauge(
|
||||
"clusterio_controller_websocket_active_connections",
|
||||
"How many WebSocket connections are currently open to the controller",
|
||||
{ callback: function(gauge:lib.Gauge) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
gauge.set(controller.wsServer.activeConnectors.size);
|
||||
}}
|
||||
);
|
||||
@@ -109,6 +112,9 @@ void new lib.Gauge(
|
||||
"clusterio_controller_active_hosts",
|
||||
"How many hosts are currently connected to the controller",
|
||||
{ callback: function(gauge:lib.Gauge) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
gauge.set(controller.wsServer.hostConnections.size);
|
||||
}}
|
||||
);
|
||||
@@ -118,6 +124,9 @@ void new lib.Gauge(
|
||||
"How many clients are currently connected to this controller",
|
||||
{
|
||||
labels: ["type"], callback: async function(gauge:lib.Gauge) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
gauge.labels("host").set(controller.wsServer.hostConnections.size);
|
||||
gauge.labels("control").set(controller.wsServer.controlConnections.size);
|
||||
},
|
||||
@@ -127,35 +136,45 @@ void new lib.Gauge(
|
||||
|
||||
async function handleBootstrapCommand(
|
||||
args: any,
|
||||
controllerConfig: lib.ControllerConfig
|
||||
controllerConfig: lib.ControllerConfig,
|
||||
controllerConfigLock: lib.LockFile,
|
||||
): Promise<void> {
|
||||
let subCommand = args._[1];
|
||||
|
||||
const databaseDirectory = controllerConfig.get("controller.database_directory");
|
||||
await UserManager.attemptMigrateLegacyUsersFile(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
);
|
||||
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(controllerConfig, roles);
|
||||
await userManager.load(path.join(databaseDirectory, "users.json"));
|
||||
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
UserRecord.fromJSON.bind(UserRecord),
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(users, roles, controllerConfig);
|
||||
|
||||
if (subCommand === "create-admin") {
|
||||
await controllerConfigLock.acquire(); // Also needed to write to the database files
|
||||
|
||||
if (!args.name) {
|
||||
logger.error("name cannot be blank");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let admin = userManager.getByName(args.name);
|
||||
if (!admin) {
|
||||
admin = userManager.createUser(args.name);
|
||||
}
|
||||
const user = userManager.getOrCreateUser(args.name);
|
||||
user.roleIds.add(lib.Role.DefaultAdminRoleId);
|
||||
user.isAdmin = true;
|
||||
user.saveRecord();
|
||||
|
||||
let adminRole = lib.ensureDefaultAdminRole(roles);
|
||||
admin.roleIds.add(adminRole.id);
|
||||
admin.isAdmin = true;
|
||||
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
||||
await userManager.records.save();
|
||||
await controllerConfigLock.release();
|
||||
|
||||
} else if (subCommand === "generate-user-token") {
|
||||
let user = userManager.getByName(args.name);
|
||||
@@ -171,7 +190,8 @@ async function handleBootstrapCommand(
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(jwt.sign(
|
||||
{ aud: "host", host: args.id },
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
|
||||
// Auth secret is never null after startup
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
));
|
||||
|
||||
} else if (subCommand === "create-ctl-config") {
|
||||
@@ -181,25 +201,59 @@ async function handleBootstrapCommand(
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let controlConfig = new lib.ControlConfig("control");
|
||||
let ctlConfig = new lib.CtlConfig("control");
|
||||
|
||||
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
|
||||
controlConfig.set(
|
||||
"control.controller_token",
|
||||
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
|
||||
ctlConfig.set(
|
||||
"ctl.controller_token",
|
||||
userManager.signUserToken(admin),
|
||||
);
|
||||
|
||||
let content = JSON.stringify(controlConfig, null, "\t");
|
||||
let content = JSON.stringify(ctlConfig, null, "\t");
|
||||
if (args.output === "-") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
await lib.safeOutputFile(args.output, content);
|
||||
await lib.safeOutputFile(args.output, content, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyStaticCommand(
|
||||
args: any,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
) {
|
||||
await fs.cp(
|
||||
path.join(import.meta.dirname, "..", "web", "static"),
|
||||
args.target,
|
||||
{
|
||||
recursive: true,
|
||||
mode: fs.constants.COPYFILE_FICLONE,
|
||||
},
|
||||
);
|
||||
logger.info("Copied main web interface files");
|
||||
for (const pluginInfo of pluginInfos) {
|
||||
try {
|
||||
await fs.cp(
|
||||
pluginInfo.webStaticPath,
|
||||
args.target,
|
||||
{
|
||||
recursive: true,
|
||||
mode: fs.constants.COPYFILE_FICLONE,
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.info(`Skipped web interface files for ${pluginInfo.name}: ${err.message}`);
|
||||
} else {
|
||||
logger.error(`Unexpected error copying web interface files for ${pluginInfo.name}:\n${err.stack}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
logger.info(`Copied web interface files for ${pluginInfo.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ControllerArgs {
|
||||
[x: string]: unknown;
|
||||
@@ -223,7 +277,7 @@ interface InitializeParameters {
|
||||
|
||||
async function initialize(): Promise<InitializeParameters> {
|
||||
// argument parsing
|
||||
const args = await yargs
|
||||
const args = await yargs(process.argv.slice(2))
|
||||
.scriptName("controller")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
@@ -245,12 +299,19 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
default: "config-controller.json",
|
||||
type: "string",
|
||||
})
|
||||
.option("create-config", {
|
||||
nargs: 0,
|
||||
describe: "Create config file if it does not exist",
|
||||
default: false,
|
||||
type: "boolean",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
default: "plugin-list.json",
|
||||
type: "string",
|
||||
})
|
||||
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
|
||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||
.command("config", "Manage Controller config", lib.configCommand)
|
||||
.command("bootstrap", "Bootstrap access to cluster", yargs => {
|
||||
@@ -267,11 +328,18 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
|
||||
yargs.option("output", {
|
||||
describe: "Path to output config (- for stdout)", type: "string",
|
||||
nargs: 1, default: "config-control.json",
|
||||
nargs: 1, default: "config-ctl.json",
|
||||
});
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run");
|
||||
})
|
||||
.command("copy-static [target]", "Copy static Web UI files to a folder", yargs => {
|
||||
yargs.positional("target", {
|
||||
describe: "Target directory to copy files to",
|
||||
type: "string",
|
||||
default: "static",
|
||||
});
|
||||
})
|
||||
.command("run", "Run controller", yargs => {
|
||||
yargs.option("can-restart", {
|
||||
type: "boolean", nargs: 0, default: false,
|
||||
@@ -281,12 +349,13 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
type: "boolean", nargs: 0, default: false,
|
||||
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
|
||||
});
|
||||
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
|
||||
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
|
||||
yargs.option("dev-plugin", { hidden: true, type: "array" });
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.strict()
|
||||
.argv
|
||||
.parse()
|
||||
;
|
||||
|
||||
// Combined log stream of the whole cluster.
|
||||
@@ -323,7 +392,7 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
let command = args._[0];
|
||||
let shouldRun = false;
|
||||
if (command === "run") {
|
||||
logger.info(`Starting Clusterio controller ${version}`);
|
||||
logger.info(`Starting Clusterio controller ${packageConfig.version}`);
|
||||
if (args.recovery) {
|
||||
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
|
||||
}
|
||||
@@ -348,25 +417,16 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
logger.info("Loading Plugin info");
|
||||
const pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
|
||||
let controllerConfig;
|
||||
const controllerConfigPath = args.config;
|
||||
logger.info(`Loading config from ${controllerConfigPath}`);
|
||||
try {
|
||||
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
|
||||
const [controllerConfig, controllerConfigLock] = await lib.loadConfigFromArgs(
|
||||
args,
|
||||
lib.ControllerConfig,
|
||||
"controller",
|
||||
);
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.info("Config not found, initializing new config");
|
||||
controllerConfig = new lib.ControllerConfig("controller", undefined, controllerConfigPath);
|
||||
|
||||
} else {
|
||||
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.stack ?? err.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
controllerConfig.set("controller.version", version); // Allows tracking last loaded version
|
||||
controllerConfig.set("controller.version", packageConfig.version); // Allows tracking last loaded version
|
||||
|
||||
if (!controllerConfig.get("controller.auth_secret")) {
|
||||
logger.info("Generating new controller authentication secret");
|
||||
@@ -377,10 +437,13 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
await lib.handleConfigCommand(args, controllerConfig);
|
||||
|
||||
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
|
||||
} else if (command === "bootstrap") {
|
||||
await handleBootstrapCommand(args, controllerConfig);
|
||||
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
|
||||
} else if (command === "copy-static") {
|
||||
await handleCopyStaticCommand(args, pluginInfos);
|
||||
} else if (shouldRun) {
|
||||
await controllerConfigLock.acquire(); // Hold the lock until process exit
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -417,33 +480,17 @@ async function startup() {
|
||||
...await Controller.bootstrap(controllerConfig)
|
||||
);
|
||||
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
// Refuse to start if there are no users loaded
|
||||
if (args.checkUserCount && controller.users.records.size === 0) {
|
||||
logger.fatal(
|
||||
"Cannot start controller, no users loaded.\n" +
|
||||
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
controller.stop();
|
||||
@@ -462,7 +509,7 @@ I version of clusterio. Expect things to break. I
|
||||
`
|
||||
);
|
||||
startup().catch(err => {
|
||||
if (err instanceof lib.StartupError) {
|
||||
if (err instanceof lib.StartupError || err.code === "InstallationError") {
|
||||
logger.fatal(`
|
||||
+-------------------------------+
|
||||
| Unable to to start controller |
|
||||
@@ -490,6 +537,6 @@ ${err.stack}`
|
||||
});
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { bootstrap } from "./controller";
|
||||
export { default as Controller } from "./src/Controller";
|
||||
export { default as ControllerUser } from "./src/ControllerUser";
|
||||
export { default as ControllerRouter } from "./src/ControllerRouter";
|
||||
export { default as ControlConnection } from "./src/ControlConnection";
|
||||
export { default as HostConnection } from "./src/HostConnection";
|
||||
export { default as InstanceInfo } from "./src/InstanceInfo";
|
||||
export { default as HostInfo } from "./src/HostInfo";
|
||||
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
|
||||
export { default as UserManager } from "./src/UserManager";
|
||||
export { default as WsServer } from "./src/WsServer";
|
||||
import { bootstrap } from "./controller.js";
|
||||
export { default as Controller } from "./src/Controller.js";
|
||||
export { default as ControllerRouter } from "./src/ControllerRouter.js";
|
||||
export { default as ControlConnection } from "./src/ControlConnection.js";
|
||||
export { default as HostConnection } from "./src/HostConnection.js";
|
||||
export { default as InstanceRecord } from "./src/InstanceRecord.js";
|
||||
export { default as InstanceManager } from "./src/InstanceManager.js";
|
||||
export { default as HostRecord } from "./src/HostRecord.js";
|
||||
export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
|
||||
export { default as UserManager } from "./src/UserManager.js";
|
||||
export { default as UserRecord } from "./src/UserRecord.js";
|
||||
export { default as User } from "./src/User.js";
|
||||
export { default as WsServer } from "./src/WsServer.js";
|
||||
|
||||
if (module === require.main) {
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -1,63 +1,74 @@
|
||||
{
|
||||
"name": "@clusterio/controller",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clusterio/clusterio"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
"type": "module",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusteriocontroller": "./dist/node/controller.js"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.0",
|
||||
"express": "^4.21.2",
|
||||
"finalhandler": "^1.3.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"ws": "^8.18.1",
|
||||
"yargs": "^17.7.2"
|
||||
"compression": "^1.8.2",
|
||||
"express": "catalog:",
|
||||
"finalhandler": "^2.1.1",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"yargs": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/finalhandler": "^1.2.0",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.29.0",
|
||||
"typescript": "^5.7.3",
|
||||
"webpack": "^5.98.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-middleware": "^6.1.1",
|
||||
"webpack-merge": "^5.9.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "catalog:",
|
||||
"@types/finalhandler": "catalog:",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-dev-middleware": "^6.1.3",
|
||||
"webpack-merge": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type Controller from "./Controller";
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
import type Controller from "./Controller.js";
|
||||
import type WsServerConnector from "./WsServerConnector.js";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
import * as routes from "./routes";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import * as routes from "./routes.js";
|
||||
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ import * as routes from "./routes";
|
||||
* @alias module:controller/src/BaseConnection
|
||||
*/
|
||||
export default class BaseConnection extends lib.Link {
|
||||
declare ["connector"]: WsServerConnector;
|
||||
declare "connector": WsServerConnector;
|
||||
|
||||
constructor(
|
||||
connector: lib.WebSocketBaseConnector,
|
||||
@@ -32,6 +32,8 @@ export default class BaseConnection extends lib.Link {
|
||||
this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this));
|
||||
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
|
||||
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
|
||||
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
|
||||
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
|
||||
|
||||
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
|
||||
this.connector.on("close", () => {
|
||||
@@ -78,7 +80,7 @@ export default class BaseConnection extends lib.Link {
|
||||
return modPack;
|
||||
}
|
||||
|
||||
getMod(mod: { name: string, version: string, sha1?: string }) {
|
||||
getMod(mod: { name: string, version: lib.SourceVersion, sha1?: string }) {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
@@ -93,10 +95,12 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
|
||||
let mod = this.getMod(request);
|
||||
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
|
||||
// Open source file before creating the stream in case it throws.
|
||||
let source = (await fs.open(modPath)).createReadStream();
|
||||
|
||||
let stream = await routes.createProxyStream(this._controller.app);
|
||||
stream.filename = mod.filename;
|
||||
stream.source = fs.createReadStream(modPath);
|
||||
stream.source = source;
|
||||
stream.mime = "application/zip";
|
||||
stream.size = String(mod.size);
|
||||
|
||||
@@ -106,4 +110,12 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) {
|
||||
return await this._controller.subscriptions.handleRequest(this, request, src, dst);
|
||||
}
|
||||
|
||||
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
|
||||
return await this._controller.factorioVersions.get(request.maxAgeMs);
|
||||
}
|
||||
|
||||
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
|
||||
return await this._controller.latestReleases.get(request.maxAgeMs);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,35 @@
|
||||
import type {
|
||||
CollectorResult, Event, InstanceStatus, Logger,
|
||||
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
|
||||
} from "@clusterio/lib";
|
||||
import type Controller from "./Controller";
|
||||
import type InstanceInfo from "./InstanceInfo";
|
||||
import type ControlConnection from "./ControlConnection";
|
||||
import type HostConnection from "./HostConnection";
|
||||
import type Controller from "./Controller.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import type ControlConnection from "./ControlConnection.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export type ControllerPluginContext = lib.PluginLoadContext<{
|
||||
controller: Controller;
|
||||
metrics: any;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Base class for controller plugins
|
||||
*
|
||||
* Controller plugins are subclasses of this class which get instantiated by
|
||||
* the controller on startup when the plugin is enabled in the config.
|
||||
* To be discovered the class must be exported under the name `ControllerPlugin`
|
||||
* in the module specified by the `controllerEntrypoint` in the plugin's
|
||||
* `plugin` export.
|
||||
* Collection of controller plugin hooks
|
||||
*/
|
||||
export default class BaseControllerPlugin {
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
logger: Logger;
|
||||
|
||||
constructor(
|
||||
public info: PluginNodeEnvInfo,
|
||||
public controller: Controller,
|
||||
public metrics: any,
|
||||
logger: Logger
|
||||
) {
|
||||
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
|
||||
export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
constructor(logger: lib.Logger) {
|
||||
super(logger);
|
||||
this.save = this.newHook();
|
||||
this.metrics = this.newHook();
|
||||
this.shutdown = this.newHook();
|
||||
this.instanceStatusChanged = this.newHook();
|
||||
this.instanceConfigFieldChanged = this.newHook();
|
||||
this.controllerConfigFieldChanged = this.newHook();
|
||||
this.controlConnectionEvent = this.newHook();
|
||||
this.hostConnectionEvent = this.newHook();
|
||||
this.prepareHostDisconnect = this.newHook();
|
||||
this.modPacksUpdated = this.newHook();
|
||||
this.modsUpdated = this.newHook();
|
||||
this.rolesUpdated = this.newHook();
|
||||
this.playerEvent = this.newHook();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() { }
|
||||
|
||||
/**
|
||||
* Called when the controller saves data in memory to disk
|
||||
*
|
||||
@@ -51,7 +41,7 @@ export default class BaseControllerPlugin {
|
||||
* BaseControllerPlugin.onShutdown} have been invoked and all links have
|
||||
* been disconnected.
|
||||
*/
|
||||
async onSaveData() { }
|
||||
readonly save: lib.AsyncHook<[]>;
|
||||
|
||||
/**
|
||||
* Called when the status of an instance changes
|
||||
@@ -91,7 +81,7 @@ export default class BaseControllerPlugin {
|
||||
* The instance that changed.
|
||||
* @param prev - the previous status of the instance.
|
||||
*/
|
||||
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
|
||||
readonly instanceStatusChanged: lib.AsyncHook<[instance: InstanceRecord, prev: lib.InstanceStatus | undefined]>;
|
||||
|
||||
/**
|
||||
* Called when the value of a controller config field changed.
|
||||
@@ -103,7 +93,7 @@ export default class BaseControllerPlugin {
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
|
||||
readonly controllerConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
|
||||
|
||||
/**
|
||||
* Called when the value of an instance config field changed.
|
||||
@@ -117,7 +107,8 @@ export default class BaseControllerPlugin {
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
|
||||
// eslint-disable-next-line max-len
|
||||
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
|
||||
|
||||
/**
|
||||
* Called before collecting Prometheus metrics
|
||||
@@ -134,12 +125,12 @@ export default class BaseControllerPlugin {
|
||||
*
|
||||
* @returns an async iterator of prometheus metric results or undefined.
|
||||
*/
|
||||
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
|
||||
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
|
||||
|
||||
/**
|
||||
* Called when the controller is shutting down
|
||||
*/
|
||||
async onShutdown() { }
|
||||
readonly shutdown: lib.AsyncHook<[]>;
|
||||
|
||||
/**
|
||||
* Called when an event on a host connection happens
|
||||
@@ -174,7 +165,8 @@ export default class BaseControllerPlugin {
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume and close
|
||||
*/
|
||||
onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { }
|
||||
// eslint-disable-next-line max-len
|
||||
readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
|
||||
|
||||
/**
|
||||
* Called when an avent on a control connection happens
|
||||
@@ -210,7 +202,8 @@ export default class BaseControllerPlugin {
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume, and close.
|
||||
*/
|
||||
onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { }
|
||||
// eslint-disable-next-line max-len
|
||||
readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
|
||||
|
||||
/**
|
||||
* Called when a host is preparing to disconnect from the controller
|
||||
@@ -224,7 +217,7 @@ export default class BaseControllerPlugin {
|
||||
* @param connection -
|
||||
* The connection to the host preparing to disconnect.
|
||||
*/
|
||||
async onPrepareHostDisconnect(connection: HostConnection) { }
|
||||
readonly prepareHostDisconnect: lib.AsyncHook<[connection: HostConnection]>;
|
||||
|
||||
/**
|
||||
* Called when one or more mod packs are updated
|
||||
@@ -237,7 +230,7 @@ export default class BaseControllerPlugin {
|
||||
*
|
||||
* @param modPacks - Mod packs that updated.
|
||||
*/
|
||||
async onModPacksUpdated(modPacks: ModPack[]) { }
|
||||
readonly modPacksUpdated: lib.AsyncHook<[modPacks: lib.ModPack[]]>;
|
||||
|
||||
/**
|
||||
* Called when one or more mod stored on the controller are updated
|
||||
@@ -249,7 +242,18 @@ export default class BaseControllerPlugin {
|
||||
*
|
||||
* @param mods - Mods that updated.
|
||||
*/
|
||||
async onModsUpdated(mods: ModInfo[]) { }
|
||||
readonly modsUpdated: lib.AsyncHook<[mods: lib.ModInfo[]]>;
|
||||
|
||||
/**
|
||||
* Called when one or more roles stored on the controller are updated
|
||||
*
|
||||
* Invoked when one or more roles have been added, updated or deleted.
|
||||
*
|
||||
* If a role has been deleted its `.isDeleted` property will be true.
|
||||
*
|
||||
* @param roles - Roles that updated.
|
||||
*/
|
||||
readonly rolesUpdated: lib.AsyncHook<[roles: lib.Role[]]>;
|
||||
|
||||
/**
|
||||
* Called when a player joins or leaves an instance
|
||||
@@ -261,7 +265,79 @@ export default class BaseControllerPlugin {
|
||||
* The instance it occured on.
|
||||
* @param event - Information about the event.
|
||||
*/
|
||||
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
|
||||
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface BaseControllerPlugin {
|
||||
onSaveData?(): Promise<void>;
|
||||
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
|
||||
onShutdown?(): Promise<void>;
|
||||
onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise<void>;
|
||||
onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
|
||||
onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise<void>;
|
||||
onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void;
|
||||
onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void;
|
||||
onPrepareHostDisconnect?(connection: HostConnection): Promise<void>;
|
||||
onModPacksUpdated?(modPacks: lib.ModPack[]): Promise<void>;
|
||||
onModsUpdated?(mods: lib.ModInfo[]): Promise<void>;
|
||||
onRolesUpdated?(roles: lib.Role[]): Promise<void>;
|
||||
onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for controller plugins
|
||||
*
|
||||
* Controller plugins are subclasses of this class which get instantiated by
|
||||
* the controller on startup when the plugin is enabled in the config.
|
||||
* To be discovered the class must be exported under the name `ControllerPlugin`
|
||||
* in the module specified by the `controllerEntrypoint` in the plugin's
|
||||
* `plugin` export.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class BaseControllerPlugin {
|
||||
constructor(
|
||||
public info: lib.PluginNodeEnvInfo,
|
||||
public controller: Controller,
|
||||
public metrics: any,
|
||||
public logger: lib.Logger,
|
||||
) {
|
||||
const attach = <Args extends unknown[], Return>(
|
||||
hook: lib.AsyncHook<Args, Return>,
|
||||
fn?: lib.HookHandler<Args, Return>,
|
||||
) => {
|
||||
if (fn) {
|
||||
hook.attach(info.name, fn.bind(this));
|
||||
}
|
||||
};
|
||||
|
||||
attach(controller.hooks.save, this.onSaveData);
|
||||
attach(controller.hooks.instanceStatusChanged, this.onInstanceStatusChanged);
|
||||
attach(controller.hooks.controllerConfigFieldChanged, this.onControllerConfigFieldChanged);
|
||||
attach(controller.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
|
||||
attach(controller.hooks.metrics, this.onMetrics);
|
||||
attach(controller.hooks.shutdown, this.onShutdown);
|
||||
attach(controller.hooks.hostConnectionEvent, this.onHostConnectionEvent);
|
||||
attach(controller.hooks.controlConnectionEvent, this.onControlConnectionEvent);
|
||||
attach(controller.hooks.prepareHostDisconnect, this.onPrepareHostDisconnect);
|
||||
attach(controller.hooks.modPacksUpdated, this.onModPacksUpdated);
|
||||
attach(controller.hooks.modsUpdated, this.onModsUpdated);
|
||||
attach(controller.hooks.rolesUpdated, this.onRolesUpdated);
|
||||
attach(controller.hooks.playerEvent, this.onPlayerEvent);
|
||||
}
|
||||
|
||||
static fromContext(context: ControllerPluginContext) {
|
||||
return new this(context.plugin, context.controller, context.metrics, context.logger);
|
||||
}
|
||||
|
||||
detachHooks() {
|
||||
this.controller.hooks.detachAll(this.info.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() {}
|
||||
|
||||
/**
|
||||
* Broadcast event to all connected hosts
|
||||
@@ -272,7 +348,7 @@ export default class BaseControllerPlugin {
|
||||
*
|
||||
* @param event - Event to send
|
||||
*/
|
||||
broadcastEventToHosts<T>(event: Event<T>) {
|
||||
broadcastEventToHosts<T>(event: lib.Event<T>) {
|
||||
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
|
||||
if (
|
||||
!hostConnection.connector.closing
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
import type WsServerConnector from "./WsServerConnector.js";
|
||||
|
||||
import events from "events";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logFilter, logger } = lib;
|
||||
import { logFilter, logger } from "@clusterio/lib";
|
||||
|
||||
import BaseConnection from "./BaseConnection";
|
||||
import ControllerUser from "./ControllerUser";
|
||||
import * as routes from "./routes";
|
||||
import Controller from "./Controller";
|
||||
import BaseConnection from "./BaseConnection.js";
|
||||
import User from "./User.js";
|
||||
import UserRecord from "./UserRecord.js";
|
||||
import * as routes from "./routes.js";
|
||||
import Controller from "./Controller.js";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -40,19 +41,23 @@ export default class ControlConnection extends BaseConnection {
|
||||
registerData: { version: string },
|
||||
connector: WsServerConnector,
|
||||
controller: Controller,
|
||||
public user: ControllerUser, // The user making this connection.
|
||||
public user: User, // The user making this connection.
|
||||
public id: number
|
||||
) {
|
||||
super(connector, controller);
|
||||
|
||||
this._version = registerData.version;
|
||||
|
||||
this.connector.on("connect", () => {
|
||||
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
|
||||
});
|
||||
// A resume installs a fresh socket, so re-apply the ignore flag or the
|
||||
// dumper's own frames get echoed back into it and recurse forever.
|
||||
for (let event of ["connect", "resume"] as const) {
|
||||
this.connector.on(event, () => {
|
||||
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
|
||||
});
|
||||
}
|
||||
this.connector.on("close", () => {
|
||||
if (this.logTransport) {
|
||||
logger.remove(this.logTransport);
|
||||
this._controller.clusterLogger.remove(this.logTransport);
|
||||
this.logTransport = null;
|
||||
}
|
||||
if (this.ws_dumper) {
|
||||
@@ -62,16 +67,18 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
this.connector.on(event, () => {
|
||||
for (let controllerPlugin of this._controller.plugins.values()) {
|
||||
controllerPlugin.onControlConnectionEvent(this, event);
|
||||
}
|
||||
this._controller.hooks.controlConnectionEvent.invoke(this, event);
|
||||
});
|
||||
}
|
||||
|
||||
this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this));
|
||||
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
|
||||
this.handle(
|
||||
lib.ControllerMarkRestartRequiredRequest, this.handleControllerMarkRestartRequiredRequest.bind(this)
|
||||
);
|
||||
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
|
||||
@@ -83,6 +90,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
|
||||
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
|
||||
@@ -100,6 +108,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
|
||||
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
|
||||
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this));
|
||||
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
|
||||
this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this));
|
||||
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
|
||||
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
|
||||
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
|
||||
@@ -123,8 +133,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
|
||||
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
||||
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
||||
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
|
||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
|
||||
}
|
||||
|
||||
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
||||
@@ -165,7 +175,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
if (typeof permission === "string") {
|
||||
this.user.checkPermission(permission);
|
||||
this.user.checkPermission(permission as lib.PermissionName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -185,6 +195,15 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (!this._controller.canRestart) {
|
||||
throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it.");
|
||||
}
|
||||
const downgrade = await this._controller.checkRestartDowngrade();
|
||||
if (downgrade) {
|
||||
const { installedVersion, runningVersion } = downgrade;
|
||||
throw new lib.RequestError(
|
||||
`Cannot restart controller with installed Clusterio version ${installedVersion} because it ` +
|
||||
`is older than running version ${runningVersion}. ` +
|
||||
"Stop the controller before starting the older version manually."
|
||||
);
|
||||
}
|
||||
this._controller.shouldRestart = true;
|
||||
this._controller.stop();
|
||||
}
|
||||
@@ -193,6 +212,26 @@ export default class ControlConnection extends BaseConnection {
|
||||
return this._controller.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleControllerConfigSetRequest(request: lib.ControllerConfigSetRequest) {
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
this._controller.config.stageProp(
|
||||
fieldName as keyof lib.ControllerConfigFields, propName, propValue, "control"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._controller.config.stage(fieldName as keyof lib.ControllerConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.config.commitStaging();
|
||||
} finally {
|
||||
this._controller.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
|
||||
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
|
||||
}
|
||||
@@ -248,7 +287,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
|
||||
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
|
||||
return this._controller.instances.getForRequest(request.instanceId).toInstanceDetails();
|
||||
}
|
||||
|
||||
async handleInstanceDetailsListRequest() {
|
||||
@@ -256,6 +295,9 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
|
||||
if ((request.config["instance.assigned_host"] ?? null) !== null) {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
if (request.cloneFromId) {
|
||||
const baseInstance = this._controller.instances.get(request.cloneFromId);
|
||||
@@ -266,42 +308,69 @@ export default class ControlConnection extends BaseConnection {
|
||||
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
|
||||
}
|
||||
instanceConfig.update(request.config, false, "control");
|
||||
await this._controller.instanceCreate(instanceConfig);
|
||||
await this._controller.instances.createInstance(instanceConfig);
|
||||
}
|
||||
|
||||
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
|
||||
await this._controller.instanceDelete(request.instanceId);
|
||||
await this._controller.instances.deleteInstance(request.instanceId);
|
||||
}
|
||||
|
||||
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
return instance.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
if (request.field === "instance.assigned_host") {
|
||||
private static validateInstanceConfigSetField(fieldName: string) {
|
||||
if (fieldName === "instance.assigned_host") {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
|
||||
if (request.field === "instance.id") {
|
||||
if (fieldName === "instance.id") {
|
||||
// XXX is this worth implementing? It's race condition galore.
|
||||
throw new lib.RequestError("Setting instance.id is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetRequest(request: lib.InstanceConfigSetRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
ControlConnection.validateInstanceConfigSetField(fieldName);
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
instance.config.stageProp(
|
||||
fieldName as keyof lib.InstanceConfigFields, propName, propValue, "control"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
instance.config.stage(fieldName as keyof lib.InstanceConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
instance.config.commitStaging();
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
ControlConnection.validateInstanceConfigSetField(request.field);
|
||||
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
let { field, prop, value } = request;
|
||||
ControlConnection.validateInstanceConfigSetField(field);
|
||||
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
|
||||
await this._controller.instanceAssign(request.instanceId, request.hostId);
|
||||
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsListRequest() {
|
||||
@@ -335,8 +404,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (request.sourceInstanceId === request.targetInstanceId) {
|
||||
throw new lib.RequestError("Source and target instance may not be the same");
|
||||
}
|
||||
let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId);
|
||||
let targetInstance = this._controller.getRequestInstance(request.targetInstanceId);
|
||||
let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
|
||||
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
|
||||
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
|
||||
let targetHostId = targetInstance.config.get("instance.assigned_host");
|
||||
if (sourceHostId === null) {
|
||||
@@ -564,52 +633,232 @@ export default class ControlConnection extends BaseConnection {
|
||||
* Handle request to download a mod from the Factorio Mod Portal to the controller.
|
||||
* @param request - Request object with mod name, version, and factorioVersion.
|
||||
*/
|
||||
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise<lib.ModInfo> {
|
||||
const { name, version, factorioVersion } = request;
|
||||
const filename = lib.ModInfo.filename(name, version);
|
||||
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) {
|
||||
const factorioVersion = request.factorioVersion;
|
||||
const toDownload = this._controller.modStore.filterInstalled(request.mods, false);
|
||||
|
||||
// Check if mod already exists
|
||||
if (this._controller.modStore.files.has(filename)) {
|
||||
logger.verbose(`Mod ${filename} already exists, skipping download.`);
|
||||
return this._controller.modStore.files.get(filename)!;
|
||||
}
|
||||
if (toDownload.length > 0) {
|
||||
// Get Factorio credentials from config
|
||||
const username = this._controller.config.get("controller.factorio_username");
|
||||
const token = this._controller.config.get("controller.factorio_token");
|
||||
|
||||
// Get Factorio credentials from config
|
||||
const username = this._controller.config.get("controller.factorio_username");
|
||||
const token = this._controller.config.get("controller.factorio_token");
|
||||
|
||||
if (!username || !token) {
|
||||
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`);
|
||||
const modMap = new Map([[name, version]]);
|
||||
await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion);
|
||||
|
||||
// Verify download and return ModInfo
|
||||
const downloadedMod = this._controller.modStore.files.get(filename);
|
||||
if (!downloadedMod) {
|
||||
throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`);
|
||||
if (!username || username === "" || !token || token === "") {
|
||||
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
|
||||
}
|
||||
logger.info(`Successfully downloaded mod ${filename}.`);
|
||||
return downloadedMod;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Error downloading mod ${filename} from portal: ${error.message}`);
|
||||
// Improve error message clarity
|
||||
let errorMessage = `Mod portal download failed for ${name} v${version}`;
|
||||
if (error instanceof lib.RequestError) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
|
||||
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
errorMessage += ": Mod version not found on the portal.";
|
||||
try {
|
||||
logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`);
|
||||
await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion);
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Error downloading mods from portal: ${error.message}`);
|
||||
// Improve error message clarity
|
||||
let errorMessage = "Mod portal download failed";
|
||||
if (error instanceof lib.RequestError) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
|
||||
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
errorMessage += ": Mod version not found on the portal.";
|
||||
} else {
|
||||
errorMessage += `: ${error.message}`;
|
||||
}
|
||||
throw new lib.RequestError(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all mods are present
|
||||
const modInfos = [];
|
||||
const installedMods = [...this._controller.modStore.mods()];
|
||||
for (const mod of request.mods) {
|
||||
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
|
||||
if (!modInfo) {
|
||||
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
|
||||
}
|
||||
modInfos.push(modInfo);
|
||||
}
|
||||
|
||||
return modInfos;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) {
|
||||
const {factorioVersion, checkForUpdates} = request;
|
||||
const searchQueue = [...request.mods];
|
||||
const localInfos = [...this._controller.modStore.mods()];
|
||||
|
||||
const builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
|
||||
const skipSearch = new Set(builtInModNames);
|
||||
|
||||
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
|
||||
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
|
||||
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const recommendedRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const optionalRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const candidateReleases = new Map<string, lib.ModInfo>();
|
||||
const errors = new Map<string, lib.ModDependencyResolveErrors>();
|
||||
|
||||
const _modPortalReleases = new Map<string, ModPortalModType | null>();
|
||||
async function fetchModReleases(modName: string) {
|
||||
let releases = _modPortalReleases.get(modName);
|
||||
if (releases === undefined) {
|
||||
try {
|
||||
releases = await lib.ModStore.fetchModReleases(modName);
|
||||
} catch (error: any) {
|
||||
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
|
||||
logger.error(`Error fetching mod from portal: ${error.message}`);
|
||||
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
|
||||
} else {
|
||||
releases = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
_modPortalReleases.set(modName, releases);
|
||||
return releases;
|
||||
}
|
||||
|
||||
function selectModRelease(releases: ModPortalModType | null, versionRange: lib.ModVersionRange) {
|
||||
if (!releases || !releases.releases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Select the latest matching version for the mod pack's Factorio version
|
||||
const release = releases.releases
|
||||
.filter(info => (
|
||||
versionRange.testVersion(info.version)
|
||||
&& lib.normaliseMajorMinorVersion(
|
||||
lib.normaliseSourceVersion(info.info_json.factorio_version)
|
||||
) === factorioVersion
|
||||
))
|
||||
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
|
||||
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
|
||||
), undefined);
|
||||
|
||||
if (!release) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create the mod info class from the selected
|
||||
try {
|
||||
return lib.ModInfo.fromJSON({
|
||||
...release.info_json, // includes dependencies
|
||||
sha1: release.sha1,
|
||||
version: release.version,
|
||||
name: releases.name,
|
||||
author: releases.owner,
|
||||
title: releases.title,
|
||||
});
|
||||
} catch {
|
||||
// Can error if there are invalid dependencies
|
||||
errors.set(releases.name, "invalidDependency");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
while (searchQueue.length > 0) {
|
||||
// Dequeue the next mod, skip if incompatible
|
||||
const mod = searchQueue.shift()!;
|
||||
if (mod.incompatible) {
|
||||
errors.set(mod.name, "incompatible");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the current version range (dependencies can be promoted to a stricter requirement)
|
||||
const versionRange =
|
||||
dependencyRequirements.get(mod.name)
|
||||
?? recommendedRequirements.get(mod.name)
|
||||
?? optionalRequirements.get(mod.name)
|
||||
?? new lib.ModVersionRange();
|
||||
|
||||
if (mod.required) {
|
||||
// Add to required, remove from recommended and optional
|
||||
dependencyRequirements.set(mod.name, versionRange);
|
||||
recommendedRequirements.delete(mod.name);
|
||||
optionalRequirements.delete(mod.name);
|
||||
|
||||
} else if (mod.recommended) {
|
||||
// If not required, add to recommended and remove from optional
|
||||
if (!dependencyRequirements.has(mod.name)) {
|
||||
recommendedRequirements.set(mod.name, versionRange);
|
||||
optionalRequirements.delete(mod.name);
|
||||
}
|
||||
|
||||
} else if (
|
||||
!dependencyRequirements.has(mod.name)
|
||||
&& !recommendedRequirements.has(mod.name)
|
||||
) {
|
||||
// If not required or recommended, add to optional
|
||||
optionalRequirements.set(mod.name, versionRange);
|
||||
}
|
||||
|
||||
// Update the version range if needed
|
||||
if (mod.version) {
|
||||
versionRange.combineVersion(mod.version);
|
||||
if (!versionRange.valid) {
|
||||
candidateReleases.delete(mod.name);
|
||||
errors.set(mod.name, "unsatisfiable");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not add dependencies for errors, skips, optionals, or valid candidates
|
||||
const existingCandidate = candidateReleases.get(mod.name);
|
||||
if (errors.has(mod.name) || skipSearch.has(mod.name) || optionalRequirements.has(mod.name) || (
|
||||
existingCandidate && versionRange.testVersion(existingCandidate.version)
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if a local version fits the requirements
|
||||
let candidate = localInfos
|
||||
.filter(info => (
|
||||
info.name === mod.name
|
||||
&& info.factorioVersion === factorioVersion
|
||||
&& versionRange.testVersion(info.version)
|
||||
))
|
||||
.reduce<lib.ModInfo | undefined>((max, cur) => (
|
||||
max && max.integerVersion > cur.integerVersion ? max : cur
|
||||
), undefined);
|
||||
|
||||
// Check if a mod poral version fits the requirements
|
||||
if (!candidate || checkForUpdates) {
|
||||
const releases = await fetchModReleases(mod.name);
|
||||
const release = selectModRelease(releases, versionRange);
|
||||
// We only select the mod portal release if it is more recent than the local candidate
|
||||
if (release && (!candidate || release.integerVersion > candidate.integerVersion)) {
|
||||
candidate = release;
|
||||
}
|
||||
}
|
||||
|
||||
// Queue the dependencies of the new candidate
|
||||
if (candidate) {
|
||||
candidateReleases.set(mod.name, candidate);
|
||||
for (const dep of candidate.dependencies) {
|
||||
searchQueue.push(dep);
|
||||
}
|
||||
} else {
|
||||
errorMessage += `: ${error.message}`;
|
||||
candidateReleases.delete(mod.name);
|
||||
skipSearch.add(mod.name);
|
||||
}
|
||||
throw new lib.RequestError(errorMessage);
|
||||
}
|
||||
|
||||
// Add the not found dependencies as errors
|
||||
for (const modName of dependencyRequirements.keys()) {
|
||||
if (!candidateReleases.has(modName) && !errors.has(modName)) {
|
||||
errors.set(modName, "notFound");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove incompatible and unsatisfiable dependencies if they aren't required by others
|
||||
for (const [modName, error] of errors) {
|
||||
if (!dependencyRequirements.has(modName) && (error === "incompatible" || error === "unsatisfiable")) {
|
||||
errors.delete(modName);
|
||||
}
|
||||
}
|
||||
|
||||
return new lib.ModDependencyResolveRequest.Response(
|
||||
[...candidateReleases.values()], errors,
|
||||
);
|
||||
}
|
||||
|
||||
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
|
||||
@@ -626,6 +875,16 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.updateLogSubscriptions();
|
||||
}
|
||||
|
||||
/** Stop following logs if the user no longer has permission to */
|
||||
checkLogPermission() {
|
||||
try {
|
||||
this.user.checkPermission("core.log.follow");
|
||||
} catch {
|
||||
this.logSubscriptions = { all: false, controller: false, hostIds: [], instanceIds: [] };
|
||||
this.updateLogSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
updateLogSubscriptions() {
|
||||
let { all, controller, hostIds, instanceIds } = this.logSubscriptions;
|
||||
if (all || controller || hostIds.length || instanceIds.length) {
|
||||
@@ -715,35 +974,33 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
this._controller.roles.delete(role);
|
||||
|
||||
this._controller.userManager.dirty = true;
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
if (user.roleIds.delete(id)) {
|
||||
for (const user of this._controller.users.values()) {
|
||||
if (user.removeRole(id)) {
|
||||
this._controller.userPermissionsUpdated(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
|
||||
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
|
||||
let name = request.name;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User ${name} does not exist`);
|
||||
}
|
||||
|
||||
return user;
|
||||
return user.toUserDetails();
|
||||
}
|
||||
|
||||
async handleUserListRequest(): Promise<lib.User[]> {
|
||||
return [...this._controller.userManager.users.values()];
|
||||
async handleUserListRequest(): Promise<lib.UserDetails[]> {
|
||||
return [...this._controller.users.records.values()].map(user => user.toUserDetails());
|
||||
}
|
||||
|
||||
async handleUserCreateRequest(request: lib.UserCreateRequest) {
|
||||
let user = this._controller.userManager.createUser(request.name);
|
||||
this._controller.usersUpdated([user]);
|
||||
this._controller.users.createUser(request.name);
|
||||
}
|
||||
|
||||
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
|
||||
let user = this._controller.userManager.getByName(request.name);
|
||||
let user = this._controller.users.getByName(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
@@ -757,11 +1014,10 @@ export default class ControlConnection extends BaseConnection {
|
||||
controlConnection.connector.terminate();
|
||||
}
|
||||
}
|
||||
this._controller.usersUpdated([user]);
|
||||
}
|
||||
|
||||
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
|
||||
let user = this._controller.userManager.getByName(request.name);
|
||||
let user = this._controller.users.getByName(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
@@ -772,35 +1028,33 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
user.roleIds = new Set(request.roles);
|
||||
user.set("roleIds", new Set(request.roles));
|
||||
this._controller.userPermissionsUpdated(user);
|
||||
this._controller.usersUpdated([user]);
|
||||
}
|
||||
|
||||
async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) {
|
||||
let { name, create, admin } = request;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
user.isAdmin = admin;
|
||||
this._controller.usersUpdated([user]);
|
||||
user.set("isAdmin", admin);
|
||||
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin));
|
||||
}
|
||||
|
||||
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
|
||||
let { name, create, banned, reason } = request;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByNameMutable(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
@@ -808,37 +1062,39 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
user.isBanned = banned;
|
||||
user.banReason = reason;
|
||||
this._controller.usersUpdated([user]);
|
||||
user.saveRecord();
|
||||
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason));
|
||||
}
|
||||
|
||||
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
|
||||
let { name, create, whitelisted } = request;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
user.isWhitelisted = whitelisted;
|
||||
this._controller.usersUpdated([user]);
|
||||
user.set("isWhitelisted", whitelisted);
|
||||
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
|
||||
}
|
||||
|
||||
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
|
||||
let name = request.name;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
|
||||
user.isDeleted = true;
|
||||
this._controller.userManager.users.delete(name);
|
||||
this._controller.usersUpdated([user]);
|
||||
this._controller.users.deleteUser(user);
|
||||
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
|
||||
if (controlConnection.user.id === user.id) {
|
||||
controlConnection.connector.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
|
||||
@@ -851,14 +1107,15 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, ControllerUser>) {
|
||||
/** This does not do a full restore, rather it demotes / unbanns / unwhitelistes users */
|
||||
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, UserRecord>) {
|
||||
if (request.importType === "users") {
|
||||
// Update all fields for all users
|
||||
const users = request.users as Extract<typeof request.users, { username: string }[]>;
|
||||
const admin = new Set(users.filter(u => u.is_admin).map(u => u.username.toLowerCase()));
|
||||
const banned = new Set(users.filter(u => u.is_banned).map(u => u.username.toLowerCase()));
|
||||
const whitelist = new Set(users.filter(u => u.is_whitelisted).map(u => u.username.toLowerCase()));
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.valuesMutable()) {
|
||||
if (user.isAdmin && !admin.has(user.name)) {
|
||||
user.isAdmin = false;
|
||||
updated.set(user.id, user);
|
||||
@@ -879,7 +1136,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const expected = new Set(
|
||||
users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase()))
|
||||
);
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.valuesMutable()) {
|
||||
if (user.isBanned && !expected.has(user.name)) {
|
||||
user.isBanned = false;
|
||||
user.banReason = "";
|
||||
@@ -891,7 +1148,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const users = request.users as string[];
|
||||
const expected = new Set(users.map(u => u.toLowerCase()));
|
||||
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.valuesMutable()) {
|
||||
if (user[prop] && !expected.has(user.name)) {
|
||||
user[prop] = false;
|
||||
updated.set(user.id, user);
|
||||
@@ -900,11 +1157,9 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// A large number of small if statements are unavoidable when reducing the number of updates sent
|
||||
// eslint-disable-next-line complexity
|
||||
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
|
||||
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
|
||||
const updated = new Map<ControllerUser["id"], ControllerUser>();
|
||||
const updated = new Map<User["id"], User>();
|
||||
if (request.restore) {
|
||||
// Unban / Demote / Unwhitelist players not on the list
|
||||
backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType));
|
||||
@@ -912,11 +1167,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
// Will get a user or attempt to create one
|
||||
// UserManager.getOrCreateUser not used due to inclusion of checkPermission
|
||||
const getUserOrCreate = (username: string) => {
|
||||
const user = this._controller.userManager.getByName(username);
|
||||
if (user) { return user; }
|
||||
const user = this._controller.users.getByNameMutable(username);
|
||||
if (user) { return updated.get(user.id) ?? user; }
|
||||
this.user.checkPermission("core.user.create");
|
||||
return this._controller.userManager.createUser(username);
|
||||
return this._controller.users.createUser(username);
|
||||
};
|
||||
|
||||
// Merge the imported data with existing data, this is strictly additive
|
||||
@@ -974,14 +1230,14 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
// Send the necessary update events
|
||||
const updatedUsers = [...updated.values()];
|
||||
this._controller.usersUpdated(updatedUsers);
|
||||
this._controller.users.records.setMany(updatedUsers);
|
||||
|
||||
// Resync the host user lists
|
||||
const adminlist: Set<string> = new Set();
|
||||
const banlist: Map<string, string> = new Map();
|
||||
const whitelist: Set<string> = new Set();
|
||||
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
for (let user of this._controller.users.values()) {
|
||||
if (user.isAdmin) {
|
||||
adminlist.add(user.id);
|
||||
}
|
||||
@@ -1005,7 +1261,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (request.exportType === "users") {
|
||||
// Send a full user export
|
||||
const usersToSend = [] as lib.ClusterioUserExport["users"];
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.values()) {
|
||||
let send = false;
|
||||
const userToSend = { username: user.name } as typeof usersToSend[0];
|
||||
if (user.isBanned) {
|
||||
@@ -1031,7 +1287,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
} else if (request.exportType === "bans") {
|
||||
// Bans have extra logic to handle ban reason
|
||||
const usersToSend = [] as Array<string | { username: string, reason: string }>;
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.values()) {
|
||||
if (user.isBanned) {
|
||||
if (user.banReason && user.banReason !== "") {
|
||||
usersToSend.push({ username: user.name, reason: user.banReason });
|
||||
@@ -1046,7 +1302,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
// This is a whitelist or admins export
|
||||
const usersToSend = [] as string[];
|
||||
const prop = request.exportType === "admins" ? "isAdmin" : "isWhitelisted";
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.values()) {
|
||||
if (user[prop]) {
|
||||
usersToSend.push(user.name);
|
||||
}
|
||||
@@ -1054,11 +1310,15 @@ export default class ControlConnection extends BaseConnection {
|
||||
return usersToSend;
|
||||
}
|
||||
|
||||
async handleControllerMarkRestartRequiredRequest() {
|
||||
this._controller.config.restartRequired = true;
|
||||
}
|
||||
|
||||
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
|
||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
}
|
||||
return lib.updatePackage("@clusterio/controller");
|
||||
return await lib.updatePackage("@clusterio/controller");
|
||||
}
|
||||
|
||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||
@@ -1072,20 +1332,35 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (!this._controller.config.get("controller.allow_plugin_install")) {
|
||||
throw new lib.RequestError("Plugin installs are disabled on this machine");
|
||||
}
|
||||
return await lib.handlePluginInstall(request.pluginPackage);
|
||||
await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
|
||||
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
|
||||
this._controller.config.restartRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
}
|
||||
if (!this._controller.config.get("controller.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this._controller.plugins.has(pluginInfo.name),
|
||||
this._controller.config.get(
|
||||
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields
|
||||
) as boolean,
|
||||
this._controller.loadedPlugins.has(pluginInfo),
|
||||
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
));
|
||||
}
|
||||
|
||||
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
|
||||
if (this.ws_dumper) {
|
||||
return; // Already dumping
|
||||
}
|
||||
this.ws_dumper = data => {
|
||||
if (this.connector.connected) {
|
||||
this.send(new lib.DebugWsMessageEvent(data.direction, data.content));
|
||||
@@ -1095,5 +1370,3 @@ export default class ControlConnection extends BaseConnection {
|
||||
this._controller.debugEvents.on("message", this.ws_dumper);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ControlConnection;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,10 @@
|
||||
import type Controller from "./Controller";
|
||||
import type BaseConnection from "./BaseConnection";
|
||||
import type Controller from "./Controller.js";
|
||||
import type BaseConnection from "./BaseConnection.js";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import HostConnection from "./HostConnection";
|
||||
import ControlConnection from "./ControlConnection";
|
||||
const { logger } = lib;
|
||||
import { logger } from "@clusterio/lib";
|
||||
import HostConnection from "./HostConnection.js";
|
||||
import ControlConnection from "./ControlConnection.js";
|
||||
|
||||
export default class ControllerRouter {
|
||||
constructor(
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
import {
|
||||
IControllerUser, PermissionError, PlayerStats,
|
||||
Role, SubscribableDatastore, User, permissions,
|
||||
} from "@clusterio/lib";
|
||||
|
||||
export default class ControllerUser extends User implements IControllerUser {
|
||||
constructor(
|
||||
private _controllerRoles: SubscribableDatastore<Role>,
|
||||
/** Unix time in seconds the user token must be issued after to be valid. */
|
||||
public tokenValidAfter = 0,
|
||||
...args: ConstructorParameters<typeof User>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
...User.jsonSchema.properties,
|
||||
token_valid_after: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, controllerRoles: SubscribableDatastore<Role>) {
|
||||
const roleIds = new Set<number>(json.roles ?? []);
|
||||
const instanceStats = new Map(
|
||||
(json.instance_stats ?? []).map(
|
||||
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
|
||||
)
|
||||
);
|
||||
const playerStats = User._calculatePlayerStats(instanceStats);
|
||||
return new this(
|
||||
controllerRoles,
|
||||
json.token_valid_after,
|
||||
json.name,
|
||||
roleIds,
|
||||
new Set(json.instances),
|
||||
json.is_admin,
|
||||
json.is_banned,
|
||||
json.is_whitelisted,
|
||||
json.ban_reason,
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
playerStats,
|
||||
instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
|
||||
if (!controller) {
|
||||
return super.toJSON();
|
||||
}
|
||||
|
||||
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
|
||||
if (this.tokenValidAfter) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
get roles(): ReadonlySet<Readonly<Role>> {
|
||||
return new Set([...this.roleIds].map(
|
||||
id => this._controllerRoles.get(id)
|
||||
).filter(
|
||||
(r): r is Role => Boolean(r)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate current tokens for the user
|
||||
*
|
||||
* Sets the tokenValidAfter property to the current time, which causes
|
||||
* all currently issued tokens for the user to become invalid.
|
||||
*/
|
||||
invalidateToken() {
|
||||
this.tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
checkPermission(permission: string) {
|
||||
if (!permissions.has(permission)) {
|
||||
throw new Error(`permission ${permission} does not exist`);
|
||||
}
|
||||
|
||||
for (let roleId of this.roleIds) {
|
||||
let role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PermissionError("Permission denied");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import type Controller from "./Controller";
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
import type Controller from "./Controller.js";
|
||||
import type WsServerConnector from "./WsServerConnector.js";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import BaseConnection from "./BaseConnection";
|
||||
import InstanceInfo from "./InstanceInfo";
|
||||
import HostInfo from "./HostInfo";
|
||||
import BaseConnection from "./BaseConnection.js";
|
||||
import InstanceRecord from "./InstanceRecord.js";
|
||||
import HostRecord from "./HostRecord.js";
|
||||
|
||||
|
||||
// How long log message warnings are suppressed for after one is logged.
|
||||
const logMessageWarnIntervalMs = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Represents the connection to a host
|
||||
*
|
||||
@@ -16,8 +19,10 @@ import HostInfo from "./HostInfo";
|
||||
* @alias module:controller/src/HostConnection
|
||||
*/
|
||||
export default class HostConnection extends BaseConnection {
|
||||
private info: HostInfo;
|
||||
private info: HostRecord;
|
||||
plugins: Map<string, string>;
|
||||
// Shared by all log message warnings, a misbehaving host could log in a loop.
|
||||
private _lastLogMessageWarnMs = Number.NEGATIVE_INFINITY;
|
||||
|
||||
constructor(
|
||||
registerData: lib.RegisterHostData,
|
||||
@@ -30,7 +35,7 @@ export default class HostConnection extends BaseConnection {
|
||||
this.plugins = new Map(Object.entries(registerData.plugins));
|
||||
|
||||
const previousHostInfo = this._controller.hosts.get(registerData.id);
|
||||
this.info = new HostInfo(
|
||||
this.info = new HostRecord(
|
||||
registerData.id,
|
||||
previousHostInfo?.name ?? "",
|
||||
registerData.version,
|
||||
@@ -47,17 +52,12 @@ export default class HostConnection extends BaseConnection {
|
||||
this._checkPluginVersions();
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
this.connector.on(event, () => {
|
||||
for (let plugin of this._controller.plugins.values()) {
|
||||
plugin.onHostConnectionEvent(this, event);
|
||||
}
|
||||
});
|
||||
this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event));
|
||||
}
|
||||
|
||||
this.connector.on("close", () => {
|
||||
// Update status to unknown for instances on this host.
|
||||
const instances: InstanceInfo[] = [];
|
||||
const instances: InstanceRecord[] = [];
|
||||
for (let instance of this._controller.instances.valuesMutable()) {
|
||||
if (instance.config.get("instance.assigned_host") !== this.id) {
|
||||
continue;
|
||||
@@ -66,9 +66,9 @@ export default class HostConnection extends BaseConnection {
|
||||
instances.push(instance);
|
||||
let prev = instance.status;
|
||||
instance.status = "unknown";
|
||||
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
|
||||
}
|
||||
this._controller.instances.setMany(instances);
|
||||
this._controller.instances.records.setMany(instances);
|
||||
|
||||
this.info.connected = false;
|
||||
this._controller.hosts.set(this.info);
|
||||
@@ -143,7 +143,7 @@ export default class HostConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async prepareDisconnect() {
|
||||
await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
|
||||
await this._controller.hooks.prepareHostDisconnect.invoke(this);
|
||||
return await super.prepareDisconnect();
|
||||
}
|
||||
|
||||
@@ -207,11 +207,16 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
let prev = instance.status;
|
||||
instance.status = request.status;
|
||||
// If the instance has transitioned to running then updated the started at time
|
||||
if (request.status === "running" && prev === "starting") {
|
||||
instance.startedAtMs = Date.now();
|
||||
}
|
||||
|
||||
instance.gamePort = request.gamePort;
|
||||
instance.factorioVersion = request.factorioVersion;
|
||||
this._controller.instances.set(instance);
|
||||
this._controller.instances.records.set(instance);
|
||||
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
|
||||
}
|
||||
|
||||
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
|
||||
@@ -225,7 +230,7 @@ export default class HostConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
// Assign instances the host has but controller does not
|
||||
const instanceUpdates: InstanceInfo[] = [];
|
||||
const instanceUpdates: InstanceRecord[] = [];
|
||||
for (let instanceData of request.instances) {
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
instanceConfig.update(instanceData.config, false, "host");
|
||||
@@ -246,41 +251,41 @@ export default class HostConnection extends BaseConnection {
|
||||
controllerInstance.status = instanceData.status;
|
||||
controllerInstance.gamePort = instanceData.gamePort;
|
||||
controllerInstance.factorioVersion = instanceData.factorioVersion;
|
||||
if (instanceData.status === "running" && prev === "starting") {
|
||||
controllerInstance.startedAtMs = Date.now();
|
||||
}
|
||||
instanceUpdates.push(controllerInstance);
|
||||
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
|
||||
await lib.invokeHook(
|
||||
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
|
||||
);
|
||||
await this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
instanceConfig.set("instance.assigned_host", this.id);
|
||||
let newInstance = new InstanceInfo(
|
||||
instanceConfig,
|
||||
instanceData.status,
|
||||
instanceData.gamePort,
|
||||
instanceData.factorioVersion,
|
||||
Date.now(),
|
||||
);
|
||||
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
|
||||
newInstance.factorioVersion = instanceData.factorioVersion;
|
||||
newInstance.gamePort = instanceData.gamePort;
|
||||
newInstance.status = instanceData.status;
|
||||
instanceUpdates.push(newInstance);
|
||||
this._controller.addInstanceHooks(newInstance);
|
||||
await this.send(
|
||||
new lib.InstanceAssignInternalRequest(
|
||||
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
|
||||
)
|
||||
);
|
||||
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
|
||||
}
|
||||
|
||||
this._controller.instances.setMany(instanceUpdates);
|
||||
// We suppress the changes made by createInstance so we must manually make them here
|
||||
this._controller.instances.records.setMany(instanceUpdates);
|
||||
await Promise.all(instanceUpdates.flatMap(newInstance => [
|
||||
this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined),
|
||||
this.send(
|
||||
new lib.InstanceAssignInternalRequest(
|
||||
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
|
||||
)
|
||||
),
|
||||
]));
|
||||
|
||||
// Push lists to make sure they are in sync.
|
||||
let adminlist: Set<string> = new Set();
|
||||
let banlist: Map<string, string> = new Map();
|
||||
let whitelist: Set<string> = new Set();
|
||||
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
for (let user of this._controller.users.values()) {
|
||||
if (user.isAdmin) {
|
||||
adminlist.add(user.id);
|
||||
}
|
||||
@@ -295,7 +300,37 @@ export default class HostConnection extends BaseConnection {
|
||||
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
|
||||
}
|
||||
|
||||
// A host may only report on instances assigned to it. Otherwise any host
|
||||
// could speak for an instance on a different host. See
|
||||
// handleInstanceStatusChangedEvent.
|
||||
private _assignedInstance(instanceId: number) {
|
||||
const instance = this._controller.instances.get(instanceId);
|
||||
return instance?.config.get("instance.assigned_host") === this.id ? instance : undefined;
|
||||
}
|
||||
|
||||
// Host connections live for days, so the suppression expires instead of
|
||||
// latching for the life of the connection.
|
||||
private _warnLogMessage(message: string) {
|
||||
const now = Date.now();
|
||||
if (now - this._lastLogMessageWarnMs < logMessageWarnIntervalMs) {
|
||||
return;
|
||||
}
|
||||
this._lastLogMessageWarnMs = now;
|
||||
logger.warn(message);
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
|
||||
// Otherwise any host could inject save records for, or wipe the save
|
||||
// list of, an instance on a different host.
|
||||
if (event.instanceId !== undefined && !this._assignedInstance(event.instanceId)) {
|
||||
logger.warn(`Got bogus save updates for instance id ${event.instanceId}`);
|
||||
return;
|
||||
}
|
||||
if (event.updates.some(save => !this._assignedInstance(save.instanceId))) {
|
||||
logger.warn(`Got bogus save updates from host id ${this.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: lib.SaveDetails[] = [];
|
||||
const deletes: lib.SaveDetails[] = [];
|
||||
for (const save of event.updates) {
|
||||
@@ -320,8 +355,33 @@ export default class HostConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async handleLogMessageEvent(event: lib.LogMessageEvent) {
|
||||
// Fields beyond level and message survive schema validation, so the
|
||||
// instance a log line claims to come from is whatever the host sent.
|
||||
const info: typeof event.info & { instance_id?: number, instance_name?: string } = { ...event.info };
|
||||
if (info.instance_id !== undefined) {
|
||||
const instance = this._assignedInstance(info.instance_id);
|
||||
if (!instance) {
|
||||
// Demote to a host level line instead of dropping it. The host did
|
||||
// log this, it just does not get to pin it on someone else's
|
||||
// instance, and a host racing a reassignment still gets heard.
|
||||
this._warnLogMessage(`Got bogus log message for instance id ${info.instance_id}`);
|
||||
delete info.instance_id;
|
||||
delete info.instance_name;
|
||||
} else {
|
||||
// The name the host sent may be stale or made up, ours is authoritative.
|
||||
const name = instance.config.get("instance.name");
|
||||
if (info.instance_name !== name) {
|
||||
this._warnLogMessage(
|
||||
`Got log message for instance id ${info.instance_id} named ` +
|
||||
`${info.instance_name} which is named ${name} on the controller`
|
||||
);
|
||||
info.instance_name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.clusterLogger.log({
|
||||
...event.info,
|
||||
...info,
|
||||
host_id: this.id,
|
||||
host_name: this.info.name,
|
||||
});
|
||||
@@ -329,24 +389,21 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
|
||||
let instanceId = src.id;
|
||||
let user = this._controller.userManager.getByName(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
|
||||
if (event.type === "join") {
|
||||
this._controller.userManager.notifyJoin(user, instanceId);
|
||||
user.notifyJoin(instanceId);
|
||||
} else if (event.type === "leave") {
|
||||
this._controller.userManager.notifyLeave(user, instanceId);
|
||||
user.notifyLeave(instanceId);
|
||||
}
|
||||
|
||||
user.instanceStats.set(instanceId, event.stats);
|
||||
|
||||
user.recalculatePlayerStats();
|
||||
this._controller.usersUpdated([user]);
|
||||
user.saveRecord();
|
||||
|
||||
let instance = this._controller.instances.get(instanceId)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
await this._controller.hooks.playerEvent.invoke(instance, {
|
||||
type: event.type,
|
||||
name: event.name,
|
||||
reason: event.reason,
|
||||
@@ -359,15 +416,11 @@ export default class HostConnection extends BaseConnection {
|
||||
return;
|
||||
}
|
||||
|
||||
let user = this._controller.userManager.getByName(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.set("isAdmin", event.admin);
|
||||
|
||||
user.isAdmin = event.admin;
|
||||
this._controller.usersUpdated([user]);
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
await this._controller.hooks.playerEvent.invoke(instance, {
|
||||
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
@@ -379,20 +432,17 @@ export default class HostConnection extends BaseConnection {
|
||||
return;
|
||||
}
|
||||
|
||||
let user = this._controller.userManager.getByName(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.isBanned = event.banned;
|
||||
user.banReason = event.reason;
|
||||
this._controller.usersUpdated([user]);
|
||||
user.saveRecord();
|
||||
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
await this._controller.hooks.playerEvent.invoke(instance, {
|
||||
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
resaon: event.reason,
|
||||
reason: event.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -401,15 +451,11 @@ export default class HostConnection extends BaseConnection {
|
||||
return;
|
||||
}
|
||||
|
||||
let user = this._controller.userManager.getByName(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.set("isWhitelisted", event.whitelisted);
|
||||
|
||||
user.isWhitelisted = event.whitelisted;
|
||||
this._controller.usersUpdated([user]);
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
await this._controller.hooks.playerEvent.invoke(instance, {
|
||||
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { HostDetails } from "@clusterio/lib";
|
||||
|
||||
/**
|
||||
* Info about a host known to the controller.
|
||||
*/
|
||||
export default class HostInfo {
|
||||
/** Underlying data class for hosts on the controller */
|
||||
export default class HostRecord {
|
||||
constructor(
|
||||
/** Id of this host */
|
||||
public id: number,
|
||||
@@ -33,12 +31,12 @@ export default class HostInfo {
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"plugins": Type.Record(Type.String(), Type.String()),
|
||||
"connected": Type.Boolean(),
|
||||
"remote_address": Type.String(),
|
||||
"public_address": Type.String(),
|
||||
"token_valid_after": Type.Number(),
|
||||
"updated_at_ms": Type.Number(),
|
||||
"is_deleted": Type.Boolean(),
|
||||
"connected": Type.Optional(Type.Boolean()),
|
||||
"remote_address": Type.Optional(Type.String()),
|
||||
"public_address": Type.Optional(Type.String()),
|
||||
"token_valid_after": Type.Optional(Type.Number()),
|
||||
"updated_at_ms": Type.Optional(Type.Number()),
|
||||
"is_deleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -56,19 +54,50 @@ export default class HostInfo {
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): Static<typeof HostInfo.jsonSchema> {
|
||||
return {
|
||||
toJSON() {
|
||||
const json = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
version: this.version,
|
||||
plugins: Object.fromEntries(this.plugins),
|
||||
connected: this.connected,
|
||||
remote_address: this.remoteAddress,
|
||||
public_address: this.publicAddress,
|
||||
token_valid_after: this.tokenValidAfter,
|
||||
updated_at_ms: this.updatedAtMs,
|
||||
is_deleted: this.isDeleted,
|
||||
};
|
||||
} as Static<typeof HostRecord.jsonSchema>;
|
||||
|
||||
if (this.connected !== false) {
|
||||
json.connected = this.connected;
|
||||
}
|
||||
if (this.remoteAddress !== "") {
|
||||
json.remote_address = this.remoteAddress;
|
||||
}
|
||||
if (this.publicAddress !== "") {
|
||||
json.public_address = this.publicAddress;
|
||||
}
|
||||
if (this.tokenValidAfter !== 0) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updated_at_ms = this.updatedAtMs;
|
||||
}
|
||||
if (this.isDeleted !== false) {
|
||||
json.is_deleted = this.isDeleted;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
static fromHostDetails(details: HostDetails, plugins: Map<string, string>) {
|
||||
return new HostRecord(
|
||||
details.id,
|
||||
details.name,
|
||||
details.version,
|
||||
plugins,
|
||||
details.connected,
|
||||
details.remoteAddress,
|
||||
details.publicAddress,
|
||||
details.tokenValidAfter,
|
||||
details.updatedAtMs,
|
||||
details.isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
toHostDetails() {
|
||||
@@ -85,4 +114,3 @@ export default class HostInfo {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
|
||||
/**
|
||||
* Runtime status of an instance on the controller
|
||||
* @alias module:controller/src/InstanceInfo
|
||||
*/
|
||||
export default class InstanceInfo {
|
||||
constructor(
|
||||
public config: lib.InstanceConfig,
|
||||
public status: lib.InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public factorioVersion?: string,
|
||||
public updatedAtMs = 0,
|
||||
) {
|
||||
this.config = config;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"config": lib.InstanceConfig.jsonSchema,
|
||||
"status": lib.InstanceStatus,
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
|
||||
return new this(
|
||||
lib.InstanceConfig.fromJSON(json.config, location),
|
||||
json.status,
|
||||
json.gamePort,
|
||||
json.factorioVersion,
|
||||
json.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
toInstanceDetails() {
|
||||
return new lib.InstanceDetails(
|
||||
this.config.get("instance.name"),
|
||||
this.id,
|
||||
this.config.get("instance.assigned_host") ?? undefined,
|
||||
this.gamePort,
|
||||
this.status,
|
||||
this.factorioVersion,
|
||||
this.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
get isDeleted() {
|
||||
return this.status === "deleted";
|
||||
}
|
||||
|
||||
set isDeleted(value: boolean) {
|
||||
if (value === false) {
|
||||
throw new Error("Setting isDeleted to false is not supported, use status instead.");
|
||||
}
|
||||
this.status = "deleted";
|
||||
}
|
||||
|
||||
/** Shorthand for `instance.config.get("instance.id")` */
|
||||
get id():number {
|
||||
return this.config.get("instance.id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import InstanceRecord from "./InstanceRecord.js";
|
||||
|
||||
export default class InstanceManager {
|
||||
private static readonly DefaultFactorioSettings = {
|
||||
tags: ["clusterio"],
|
||||
max_players: 0,
|
||||
visibility: { public: true, lan: true },
|
||||
game_password: "",
|
||||
require_user_verification: true,
|
||||
max_upload_in_kilobytes_per_second: 0,
|
||||
max_upload_slots: 5,
|
||||
ignore_player_limit_for_returning_players: false,
|
||||
allow_commands: "admins-only",
|
||||
autosave_interval: 10,
|
||||
autosave_slots: 5,
|
||||
afk_autokick_interval: 0,
|
||||
auto_pause: false,
|
||||
only_admins_can_pause_the_game: true,
|
||||
autosave_only_on_server: true,
|
||||
non_blocking_saving: false,
|
||||
} as const;
|
||||
|
||||
constructor(
|
||||
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
|
||||
private readonly _controller: Controller,
|
||||
) {
|
||||
for (const instance of records.values()) {
|
||||
this.addInstanceHooks(instance);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Basic Accessors */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
has(id: InstanceRecord["id"]): boolean {
|
||||
return this.records.has(id);
|
||||
}
|
||||
|
||||
getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined {
|
||||
return this.records.get(id);
|
||||
}
|
||||
|
||||
get(id: InstanceRecord["id"]): Readonly<InstanceRecord> | undefined {
|
||||
return this.getMutable(id);
|
||||
}
|
||||
|
||||
valuesMutable(): IterableIterator<InstanceRecord> {
|
||||
return this.records.values();
|
||||
}
|
||||
|
||||
values(): IterableIterator<Readonly<InstanceRecord>> {
|
||||
return this.valuesMutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instance by ID for a request.
|
||||
*
|
||||
* Convenience wrapper around {@link getMutable} that throws
|
||||
* if the instance does not exist.
|
||||
*
|
||||
* @param instanceId - ID of instance to get.
|
||||
* @returns The instance record.
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
*/
|
||||
getForRequest(instanceId: number): InstanceRecord {
|
||||
const instance = this.records.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Internal Helpers */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
private async _notifyStatusChanged(
|
||||
instance: InstanceRecord,
|
||||
prev?: lib.InstanceStatus,
|
||||
): Promise<void> {
|
||||
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
|
||||
}
|
||||
|
||||
private async _notifyConfigFieldChanged(
|
||||
instance: InstanceRecord,
|
||||
field: string,
|
||||
curr: unknown,
|
||||
prev: unknown,
|
||||
): Promise<void> {
|
||||
await this._controller.hooks.instanceConfigFieldChanged.invoke(instance, field, curr, prev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach change listeners to an instance record.
|
||||
*
|
||||
* Hooks into configuration field changes and ensures the datastore
|
||||
* is updated and plugins are notified when a config field changes.
|
||||
*
|
||||
* @param instance - The instance record to attach hooks to.
|
||||
* @internal
|
||||
*/
|
||||
addInstanceHooks(instance: InstanceRecord): void {
|
||||
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
|
||||
this.records.set(instance);
|
||||
this._notifyConfigFieldChanged(instance, field, curr, prev);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @example
|
||||
* const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" });
|
||||
* const instance = await controller.instances.createInstance(config);
|
||||
*
|
||||
* @param instanceConfig - Config to base the newly created instance on.
|
||||
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
|
||||
* @returns The created instance record.
|
||||
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
|
||||
*/
|
||||
async createInstance(
|
||||
instanceConfig: lib.InstanceConfig,
|
||||
_suppressChanges?: boolean,
|
||||
): Promise<InstanceRecord> {
|
||||
const instanceId = instanceConfig.get("instance.id");
|
||||
if (this.records.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
|
||||
}
|
||||
|
||||
const controllerName = this._controller.config.get("controller.name");
|
||||
instanceConfig.set("factorio.settings", {
|
||||
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
|
||||
description: `Clusterio instance for ${controllerName}`,
|
||||
...InstanceManager.DefaultFactorioSettings,
|
||||
...instanceConfig.get("factorio.settings"),
|
||||
});
|
||||
|
||||
const instance = new InstanceRecord(instanceConfig, "unassigned");
|
||||
this.addInstanceHooks(instance);
|
||||
|
||||
if (!_suppressChanges) {
|
||||
this.records.set(instance);
|
||||
await this._notifyStatusChanged(instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the assigned host of an instance.
|
||||
*
|
||||
* Unassigns the instance from the currently assigned host (if connected)
|
||||
* and assigns it to the provided host.
|
||||
*
|
||||
* This is the only supported way of modifying the
|
||||
* `instance.assigned_host` configuration entry.
|
||||
*
|
||||
* Note:
|
||||
* - This does NOT transfer any files or saves between hosts.
|
||||
* - If the instance is already assigned to the specified host, no action is taken.
|
||||
*
|
||||
* @param instanceId - ID of instance to assign.
|
||||
* @param hostId - ID of host to assign the instance to. If undefined,
|
||||
* the instance will be unassigned.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.RequestError} if the target host is not connected.
|
||||
*/
|
||||
async assignInstance(
|
||||
instanceId: number,
|
||||
hostId: number | null,
|
||||
): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
const hostConnections = this._controller.wsServer.hostConnections;
|
||||
|
||||
const currentAssignedHost = instance.config.get("instance.assigned_host");
|
||||
if (currentAssignedHost === hostId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newHostConnection: HostConnection | undefined;
|
||||
if (hostId !== null) {
|
||||
newHostConnection = hostConnections.get(hostId);
|
||||
if (!newHostConnection) {
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
}
|
||||
}
|
||||
|
||||
if (currentAssignedHost !== null) {
|
||||
const oldHostConnection = hostConnections.get(currentAssignedHost);
|
||||
if (oldHostConnection && !oldHostConnection.connector.closing) {
|
||||
await oldHostConnection.send(
|
||||
new lib.InstanceUnassignInternalRequest(instanceId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.clearSavesOfInstance(instanceId);
|
||||
instance.config.set("instance.assigned_host", hostId);
|
||||
|
||||
if (newHostConnection) {
|
||||
try {
|
||||
await newHostConnection.send(
|
||||
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
|
||||
);
|
||||
} catch (err) {
|
||||
// Leave unassigned so the assign can be retried
|
||||
instance.config.set("instance.assigned_host", null);
|
||||
instance.status = "unassigned";
|
||||
this.records.set(instance);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
instance.status = "unassigned";
|
||||
}
|
||||
|
||||
this.records.set(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign an instance from its current host.
|
||||
*
|
||||
* Convenience wrapper around {@link assignInstance} that removes
|
||||
* the assigned host.
|
||||
*
|
||||
* @param instanceId - ID of instance to unassign.
|
||||
*/
|
||||
async unassignInstance(instanceId: number): Promise<void> {
|
||||
return this.assignInstance(instanceId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an instance permanently.
|
||||
*
|
||||
* Sends a delete request to the assigned host (if any),
|
||||
* removes the instance from the datastore,
|
||||
* clears associated saves and user statistics.
|
||||
*
|
||||
* @param instanceId - ID of instance to delete.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
*/
|
||||
async deleteInstance(instanceId: number): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
|
||||
if (hostId !== null) {
|
||||
await this._controller.sendTo(
|
||||
{ hostId },
|
||||
new lib.InstanceDeleteInternalRequest(instanceId),
|
||||
);
|
||||
}
|
||||
|
||||
const prevStatus = instance.status;
|
||||
this.records.delete(instance);
|
||||
this._controller.clearSavesOfInstance(instanceId);
|
||||
this._controller.users.clearStatsOfInstance(instanceId);
|
||||
|
||||
await this._notifyStatusChanged(instance, prevStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
/** Underlying data class for instances on the controller */
|
||||
export default class InstanceRecord {
|
||||
constructor(
|
||||
public config: lib.InstanceConfig,
|
||||
public status: lib.InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public factorioVersion?: lib.TargetVersion,
|
||||
public startedAtMs = 0,
|
||||
public updatedAtMs = 0,
|
||||
) {
|
||||
this.config = config;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
return this.config.get("instance.id");
|
||||
}
|
||||
|
||||
get isDeleted() {
|
||||
return this.status === "deleted";
|
||||
}
|
||||
|
||||
set isDeleted(value: boolean) {
|
||||
if (value === false) {
|
||||
throw new Error("Setting isDeleted to false is not supported, use status instead.");
|
||||
}
|
||||
this.status = "deleted";
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"config": lib.InstanceConfig.jsonSchema,
|
||||
"status": lib.InstanceStatus,
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"startedAtMs": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
lib.InstanceConfig.fromJSON(json.config, "controller"),
|
||||
json.status,
|
||||
json.gamePort,
|
||||
json.factorioVersion,
|
||||
json.startedAtMs,
|
||||
json.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): Static<typeof InstanceRecord.jsonSchema> {
|
||||
const json: any = {
|
||||
config: this.config.toJSON(),
|
||||
status: this.status,
|
||||
};
|
||||
|
||||
if (this.gamePort !== undefined) {
|
||||
json.gamePort = this.gamePort;
|
||||
}
|
||||
if (this.factorioVersion !== undefined) {
|
||||
json.factorioVersion = this.factorioVersion;
|
||||
}
|
||||
if (this.startedAtMs !== 0) {
|
||||
json.startedAtMs = this.startedAtMs;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updatedAtMs = this.updatedAtMs;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
toInstanceDetails() {
|
||||
return new lib.InstanceDetails(
|
||||
this.config.get("instance.name"),
|
||||
this.id,
|
||||
this.config.get("instance.assigned_host") ?? undefined,
|
||||
this.gamePort,
|
||||
this.status,
|
||||
this.factorioVersion,
|
||||
this.startedAtMs,
|
||||
this.updatedAtMs,
|
||||
this.config.get("instance.exclude_from_start_all"),
|
||||
);
|
||||
}
|
||||
|
||||
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
|
||||
return new InstanceRecord(
|
||||
config,
|
||||
details.status,
|
||||
details.gamePort,
|
||||
details.factorioVersion,
|
||||
details.startedAtMs,
|
||||
details.updatedAtMs,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
|
||||
import {
|
||||
IUser, Role, SubscribableDatastore,
|
||||
PermissionError, PermissionName, permissions,
|
||||
} from "@clusterio/lib";
|
||||
|
||||
import UserRecord from "./UserRecord.js";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* Represents a user as viewed from the controller.
|
||||
*
|
||||
* All methods are safe to use when readonly.
|
||||
* Only use mutable when needing to change multiple values.
|
||||
*/
|
||||
export default class User extends UserRecord implements IUser {
|
||||
constructor(
|
||||
private _controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
private _controllerRoles: SubscribableDatastore<Role>,
|
||||
...args: ConstructorParameters<typeof UserRecord>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static fromJSON(
|
||||
json: Static<typeof this.jsonSchema>,
|
||||
_controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
_controllerRoles: SubscribableDatastore<Role>,
|
||||
) {
|
||||
return this.fromUserRecord(
|
||||
UserRecord.fromJSON(json),
|
||||
_controllerUsers,
|
||||
_controllerRoles,
|
||||
);
|
||||
}
|
||||
|
||||
static fromUserRecord(
|
||||
userRecord: UserRecord,
|
||||
_controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
_controllerRoles: SubscribableDatastore<Role>,
|
||||
): User {
|
||||
return new this(
|
||||
_controllerUsers,
|
||||
_controllerRoles,
|
||||
userRecord.tokenValidAfter,
|
||||
userRecord.name,
|
||||
userRecord.roleIds,
|
||||
userRecord.instances,
|
||||
userRecord.isAdmin,
|
||||
userRecord.isBanned,
|
||||
userRecord.isWhitelisted,
|
||||
userRecord.banReason,
|
||||
userRecord.updatedAtMs,
|
||||
userRecord.isDeleted,
|
||||
userRecord.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
/** Roles this user has */
|
||||
get roles(): ReadonlySet<Readonly<Role>> {
|
||||
return new Set([...this.roleIds].map(
|
||||
id => this._controllerRoles.get(id)
|
||||
).filter(
|
||||
(r): r is Role => Boolean(r)
|
||||
));
|
||||
}
|
||||
|
||||
/** Save the underlying user record */
|
||||
saveRecord() {
|
||||
this._controllerUsers.set(this);
|
||||
}
|
||||
|
||||
/** Set a property on the underlying record, then save it. */
|
||||
set<K extends keyof this>(key: K, value: this[K]) {
|
||||
this[key] = value;
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate current tokens for the user
|
||||
*
|
||||
* Sets the tokenValidAfter property to the current time, which causes
|
||||
* all currently issued tokens for the user to become invalid.
|
||||
*/
|
||||
invalidateToken() {
|
||||
this.tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Add a role to this user */
|
||||
addRole(roleId: number) {
|
||||
const role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`role ${roleId} does not exist`);
|
||||
}
|
||||
this.roleIds.add(roleId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Remove a role from user */
|
||||
removeRole(roleId: number) {
|
||||
if (!this.roleIds.delete(roleId)) {
|
||||
return false;
|
||||
}
|
||||
this.saveRecord();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Set a user as being online on a specific instance */
|
||||
notifyJoin(instanceId: number) {
|
||||
this.instances.add(instanceId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Set a user as being offline on a specific instance */
|
||||
notifyLeave(instanceId: number) {
|
||||
this.instances.delete(instanceId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Clear the stats of a player on a specific instance */
|
||||
clearInstanceStats(instanceId: number) {
|
||||
this.instances.delete(instanceId);
|
||||
this.instanceStats.delete(instanceId);
|
||||
this.recalculatePlayerStats();
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
checkPermission(permission: PermissionName) {
|
||||
if (!permissions.has(permission)) {
|
||||
throw new Error(`permission ${permission} does not exist`);
|
||||
}
|
||||
|
||||
for (const roleId of this.roleIds) {
|
||||
const role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PermissionError("Permission denied");
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,47 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControllerUser from "./ControllerUser";
|
||||
import User from "./User.js";
|
||||
import UserRecord from "./UserRecord.js";
|
||||
|
||||
/**
|
||||
* Manages users and roles
|
||||
* Access layer for users on the controller.
|
||||
*
|
||||
* In performance critical paths the raw records can be accessed directly.
|
||||
* Otherwise this class will wrap data in a view class with a set and save method.
|
||||
*
|
||||
* @alias module:controller/src/UserManager
|
||||
*/
|
||||
export default class UserManager {
|
||||
users: Map<ControllerUser["id"], ControllerUser> = new Map();
|
||||
dirty = false;
|
||||
|
||||
/**
|
||||
* Set of users currently online in the cluster.
|
||||
*/
|
||||
onlineUsers = new Set<ControllerUser>();
|
||||
|
||||
constructor(
|
||||
private _config: lib.ControllerConfig,
|
||||
private _roles: lib.SubscribableDatastore<lib.Role>,
|
||||
public records: lib.SubscribableDatastore<UserRecord>,
|
||||
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
|
||||
private _controllerConfig: lib.ControllerConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
getByName(name: string) {
|
||||
return this.users.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
async load(filePath: string): Promise<void> {
|
||||
/**
|
||||
* Prior to 2.0.0-alpha.22 users and roles were stored in a single file.
|
||||
* This migration separates them so they can be loaded by datastore.
|
||||
*
|
||||
* @param usersFilePath The src and dst file path for the users json file
|
||||
* @param rolesFilePath The dst for the roles json file
|
||||
*/
|
||||
static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) {
|
||||
try {
|
||||
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
|
||||
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
|
||||
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
|
||||
await this._roles.save(); // Force a save to prevent data loss
|
||||
content = content.users;
|
||||
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
|
||||
if (!("roles" in combinedJson)) {
|
||||
return; // Files are already split, no action required
|
||||
}
|
||||
|
||||
let duplicates = 0;
|
||||
for (const serializedUser of content) {
|
||||
const user = ControllerUser.fromJSON(serializedUser, this._roles);
|
||||
const existingUser = this.users.get(user.id);
|
||||
if (existingUser) {
|
||||
// Required migration to all lowercase ids in alpha 19
|
||||
duplicates += 1;
|
||||
// We assume the user with the lower online time is the duplicate
|
||||
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
|
||||
existingUser.merge(user);
|
||||
continue; // Skip users.set
|
||||
}
|
||||
user.merge(existingUser);
|
||||
}
|
||||
this.users.set(user.id, user);
|
||||
}
|
||||
|
||||
if (duplicates) {
|
||||
const backupPath = `${filePath}.${Date.now()}.bak`;
|
||||
lib.logger.warn(
|
||||
`A total of ${duplicates} users were merged, a backup was written to: ${backupPath}`
|
||||
);
|
||||
await lib.safeOutputFile(backupPath, JSON.stringify(content, null, "\t"));
|
||||
if (!("users" in combinedJson)) {
|
||||
throw new Error("Legacy users json does not contain users property");
|
||||
}
|
||||
|
||||
// We intentionally write the roles first because if it fails then the old user file will still contain them
|
||||
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
|
||||
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
@@ -69,66 +49,119 @@ export default class UserManager {
|
||||
}
|
||||
}
|
||||
|
||||
async save(filePath: string): Promise<void> {
|
||||
this.dirty = false;
|
||||
await lib.safeOutputFile(filePath,
|
||||
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Basic Accessors */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
has(id: UserRecord["id"]) {
|
||||
return this.records.has(id);
|
||||
}
|
||||
|
||||
getMutable(id: UserRecord["id"]) {
|
||||
const userRecord = this.records.getMutable(id);
|
||||
if (!userRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return User.fromUserRecord(
|
||||
userRecord,
|
||||
this.records,
|
||||
this._controllerRoles,
|
||||
);
|
||||
}
|
||||
|
||||
get(id: UserRecord["id"]): Readonly<User> | undefined {
|
||||
return this.getMutable(id);
|
||||
}
|
||||
|
||||
hasName(name: UserRecord["name"]) {
|
||||
return this.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
getByNameMutable(name: UserRecord["name"]) {
|
||||
return this.getMutable(name.toLowerCase());
|
||||
}
|
||||
|
||||
getByName(name: UserRecord["name"]) {
|
||||
return this.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
* valuesMutable(): IterableIterator<User> {
|
||||
const roles = this._controllerRoles;
|
||||
const users = this.records;
|
||||
|
||||
for (const record of users.values()) {
|
||||
yield User.fromUserRecord(record, users, roles);
|
||||
}
|
||||
}
|
||||
|
||||
values(): IterableIterator<Readonly<User>> {
|
||||
return this.valuesMutable();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates a new user and add it to the user database.
|
||||
* @param name - Name of the user to create.
|
||||
* @returns The created user.
|
||||
*/
|
||||
createUser(name: string): ControllerUser {
|
||||
createUser(name: string): User {
|
||||
if (this.getByName(name)) {
|
||||
throw new Error(`User '${name}' already exists`);
|
||||
}
|
||||
|
||||
let roles = new Set<number>();
|
||||
let defaultRoleId = this._config.get("controller.default_role_id");
|
||||
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
|
||||
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
|
||||
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
|
||||
roles.add(defaultRoleId);
|
||||
}
|
||||
|
||||
let user = new ControllerUser(this._roles, 0, name, roles);
|
||||
this.users.set(user.id, user);
|
||||
this.dirty = true;
|
||||
// Reject tokens issued before the user existed, e.g. for a previous user by the same name
|
||||
const tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
const user = new User(this.records, this._controllerRoles, tokenValidAfter, name, roles);
|
||||
this.records.set(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Get or create a user with a given name. */
|
||||
getOrCreateUser(name: string): User {
|
||||
return this.getByNameMutable(name) ?? this.createUser(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing user
|
||||
*
|
||||
* @param user - user to delete
|
||||
*/
|
||||
deleteUser(user: UserRecord) {
|
||||
return this.records.delete(user);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sign access token for the given user
|
||||
*
|
||||
* @param user - user to sign token for
|
||||
* @returns JWT access token for the user.
|
||||
*/
|
||||
signUserToken(user: ControllerUser): string {
|
||||
signUserToken(user: UserRecord): string {
|
||||
return jwt.sign(
|
||||
{ aud: "user", user: user.id },
|
||||
Buffer.from(this._config.get("controller.auth_secret"), "base64")
|
||||
// auth secret is never null after startup
|
||||
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
);
|
||||
}
|
||||
|
||||
notifyJoin(user: ControllerUser, instance_id: number) {
|
||||
user.instances.add(instance_id);
|
||||
this.onlineUsers.add(user);
|
||||
}
|
||||
|
||||
notifyLeave(user: ControllerUser, instance_id: number) {
|
||||
user.instances.delete(instance_id);
|
||||
if (!user.instances.size) {
|
||||
this.onlineUsers.delete(user);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all player stats for specific instance */
|
||||
clearStatsOfInstance(instanceId: number) {
|
||||
for (const user of this.users.values()) {
|
||||
this.notifyLeave(user, instanceId);
|
||||
user.instanceStats.delete(instanceId);
|
||||
user.recalculatePlayerStats();
|
||||
for (const user of this.values()) {
|
||||
user.clearInstanceStats(instanceId);
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { UserDetails } from "@clusterio/lib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
/** Underlying data class for users on the controller */
|
||||
export default class UserRecord extends UserDetails {
|
||||
constructor(
|
||||
/** Unix time in seconds the user token must be issued after to be valid. */
|
||||
public tokenValidAfter = 0,
|
||||
...args: ConstructorParameters<typeof UserDetails>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
token_valid_after: Type.Optional(Type.Number()),
|
||||
...UserDetails.jsonSchema.properties,
|
||||
});
|
||||
|
||||
// _a and _b are a ts workaround for allowing User to have extra arguments
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, _a?: any, _b?: any) {
|
||||
return this.fromUserDetails(
|
||||
UserDetails.fromJSON(json),
|
||||
json.token_valid_after,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json = super.toJSON() as Static<typeof UserRecord.jsonSchema>;
|
||||
|
||||
if (this.tokenValidAfter > 0) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
static fromUserDetails(
|
||||
userDetails: UserDetails,
|
||||
tokenValidAfter = 0,
|
||||
) {
|
||||
return new this(
|
||||
tokenValidAfter,
|
||||
userDetails.name,
|
||||
userDetails.roleIds,
|
||||
userDetails.instances,
|
||||
userDetails.isAdmin,
|
||||
userDetails.isBanned,
|
||||
userDetails.isWhitelisted,
|
||||
userDetails.banReason,
|
||||
userDetails.updatedAtMs,
|
||||
userDetails.isDeleted,
|
||||
userDetails.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
toUserDetails() {
|
||||
return new UserDetails(
|
||||
this.name,
|
||||
this.roleIds,
|
||||
this.instances,
|
||||
this.isAdmin,
|
||||
this.isBanned,
|
||||
this.isWhitelisted,
|
||||
this.banReason,
|
||||
this.updatedAtMs,
|
||||
this.isDeleted,
|
||||
this.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge another user into this one, must have matching ids, user is not deleted
|
||||
* @param otherUser - User who's details are merged from
|
||||
*/
|
||||
merge(otherUser: UserDetails) {
|
||||
if (this.id !== otherUser.id) {
|
||||
throw new Error("Cannot merge users with different ids");
|
||||
}
|
||||
|
||||
// Merge properties
|
||||
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
|
||||
this.instances = new Set([...this.instances, ...otherUser.instances]);
|
||||
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
|
||||
this.isBanned = this.isBanned || otherUser.isBanned;
|
||||
this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted;
|
||||
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
|
||||
this.updatedAtMs = Date.now();
|
||||
|
||||
// Merge instance stats
|
||||
const thisInstanceStats = this.instanceStats;
|
||||
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
|
||||
if (thisInstanceStats.has(instanceId)) {
|
||||
thisInstanceStats.get(instanceId)!.merge(instanceStats);
|
||||
} else {
|
||||
thisInstanceStats.set(instanceId, instanceStats);
|
||||
}
|
||||
}
|
||||
|
||||
// Player stats must be recalculated after modifying instance stats
|
||||
this.recalculatePlayerStats();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type Controller from "./Controller";
|
||||
import type Controller from "./Controller.js";
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import ControlConnection from "./ControlConnection";
|
||||
import HostConnection from "./HostConnection";
|
||||
import WsServerConnector from "./WsServerConnector";
|
||||
import ControlConnection from "./ControlConnection.js";
|
||||
import HostConnection from "./HostConnection.js";
|
||||
import WsServerConnector from "./WsServerConnector.js";
|
||||
|
||||
import { version as packageVersion } from "../package.json";
|
||||
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
|
||||
|
||||
const wsMessageCounter = new lib.Counter(
|
||||
"clusterio_controller_websocket_message_total",
|
||||
@@ -45,12 +45,12 @@ export default class WsServer {
|
||||
sessionAud: string = `session-${Date.now()}`;
|
||||
nextSessionId: number = 1;
|
||||
nextControlId: number = 1;
|
||||
wss: WebSocket.Server;
|
||||
wss: WebSocketServer;
|
||||
|
||||
constructor(controller: Controller) {
|
||||
this.controller = controller;
|
||||
|
||||
this.wss = new WebSocket.Server({
|
||||
this.wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
path: "/api/socket",
|
||||
});
|
||||
@@ -135,11 +135,11 @@ export default class WsServer {
|
||||
|
||||
// Start connection handshake.
|
||||
let loadedPlugins: {[key:string]: string} = {};
|
||||
for (let [name, plugin] of this.controller.plugins) {
|
||||
loadedPlugins[name] = plugin.info.version;
|
||||
for (const plugin of this.controller.loadedPlugins) {
|
||||
loadedPlugins[plugin.name] = plugin.version;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageVersion, loadedPlugins))));
|
||||
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageConfig.version, loadedPlugins))));
|
||||
this.attachHandler(socket, req);
|
||||
}
|
||||
|
||||
@@ -223,13 +223,11 @@ ${err.stack}`
|
||||
createSession(dst: lib.Address): [ WsServerConnector, string ] {
|
||||
let sessionId = this.nextSessionId;
|
||||
this.nextSessionId += 1;
|
||||
let sessionToken = jwt.sign(
|
||||
{ aud: this.sessionAud, sid: sessionId },
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
);
|
||||
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
|
||||
let sessionTimeout = this.controller.config.get("controller.session_timeout");
|
||||
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
|
||||
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
|
||||
let maxSendBufferSize = this.controller.config.get("controller.max_send_buffer_size") * 1024 * 1024;
|
||||
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval, maxSendBufferSize);
|
||||
this.activeConnectors.set(sessionId, connector);
|
||||
connector.on("close", () => {
|
||||
this.activeConnectors.delete(sessionId);
|
||||
@@ -247,13 +245,8 @@ ${err.stack}`
|
||||
}
|
||||
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
{ audience: ["host", "slave"] }
|
||||
);
|
||||
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -295,10 +288,11 @@ ${err.stack}`
|
||||
logger.info(
|
||||
`WsServer | registered host ${data.id} using version ${data.version}`
|
||||
);
|
||||
if (data.version !== packageVersion) {
|
||||
if (data.version !== packageConfig.version) {
|
||||
logger.warn(
|
||||
`Host ${data.id} connected using version ${data.version} which does not match the ` +
|
||||
`version of the controller is currently running (${packageVersion}). It may not work as expected.`
|
||||
`version of the controller is currently running (${packageConfig.version}). ` +
|
||||
"It may not work as expected."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,17 +318,12 @@ ${err.stack}`
|
||||
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
|
||||
let user;
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
user = this.controller.userManager.getByName(tokenPayload.user);
|
||||
user = this.controller.users.getByNameMutable(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
@@ -376,12 +365,7 @@ ${err.stack}`
|
||||
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
|
||||
let connector;
|
||||
try {
|
||||
let payload = jwt.verify(
|
||||
data.sessionToken,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: this.sessionAud }
|
||||
);
|
||||
|
||||
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
|
||||
if (typeof payload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type WebSocket from "ws";
|
||||
import { type WebSocket } from "ws";
|
||||
|
||||
import events from "events";
|
||||
import assert from "assert/strict";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
|
||||
/**
|
||||
@@ -21,8 +21,10 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
private _sessionId: number,
|
||||
private _sessionTimeout: number,
|
||||
public _heartbeatInterval: number,
|
||||
maxSendBufferSize = 0,
|
||||
) {
|
||||
super(new lib.Address(lib.Address.controller, 0), dst);
|
||||
this.maxSendBufferSize = maxSendBufferSize;
|
||||
|
||||
// The following states are used in the server connector
|
||||
// closed: Connection is closed
|
||||
@@ -49,7 +51,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
* @param account - account data to provide to control connection
|
||||
*/
|
||||
ready(
|
||||
socket: WebSocket.WebSocket,
|
||||
socket: WebSocket,
|
||||
src: lib.Address,
|
||||
sessionToken: string,
|
||||
account: lib.AccountDetails | undefined
|
||||
@@ -80,7 +82,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
* @param lastSeq - The last message the client received.
|
||||
*/
|
||||
continue(
|
||||
socket: WebSocket.WebSocket,
|
||||
socket: WebSocket,
|
||||
lastSeq: number
|
||||
) {
|
||||
|
||||
@@ -106,14 +108,17 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
this._state = "connected";
|
||||
this._attachSocketHandlers();
|
||||
this._dropSendBufferSeq(lastSeq);
|
||||
logger.verbose(
|
||||
`Connector | resuming session with ${this.dst}, resending ${this._sendBuffer.length} buffered messages`
|
||||
);
|
||||
for (let message of this._sendBuffer) {
|
||||
this._socket.send(JSON.stringify(message));
|
||||
this._socket.send(message.text);
|
||||
}
|
||||
this.emit("resume");
|
||||
}
|
||||
|
||||
_timedOut() {
|
||||
logger.verbose("Connector | Connection timed out");
|
||||
logger.verbose(`Connector | Connection to ${this.dst} timed out`);
|
||||
this._close();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Application, Request, Response } from "express";
|
||||
import type Controller from "./Controller";
|
||||
import Controller from "./Controller.js";
|
||||
|
||||
import busboy from "busboy";
|
||||
import crypto from "crypto";
|
||||
import events from "events";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import JSZip from "jszip";
|
||||
import jwt from "jsonwebtoken";
|
||||
import path from "path";
|
||||
@@ -12,7 +12,7 @@ import nodeStream from "stream";
|
||||
import util from "util";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
const finished = util.promisify(nodeStream.finished);
|
||||
|
||||
@@ -22,8 +22,10 @@ declare global {
|
||||
export interface Locals {
|
||||
controller: Controller,
|
||||
mainBundle: string,
|
||||
staticRoot: string | null,
|
||||
devPlugins: Map<string, number>,
|
||||
streams: Map<string, ProxyStream>
|
||||
streams: Map<string, ProxyStream>,
|
||||
pendingMetrics?: Promise<string>,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,12 +45,10 @@ function mergeSamples(destinationResult: lib.CollectorResultSerialized, sourceRe
|
||||
}
|
||||
}
|
||||
|
||||
// Prometheus polling endpoint
|
||||
async function getMetrics(req: Request, res: Response, next: any) {
|
||||
const controller: Controller = req.app.locals.controller;
|
||||
|
||||
// Collect metrics from the controller, its plugins and all connected hosts
|
||||
async function gatherMetrics(controller: Controller) {
|
||||
let results: lib.CollectorResult[] = [];
|
||||
let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics");
|
||||
let pluginResults = await controller.hooks.metrics.collect();
|
||||
for (let metricIterator of pluginResults) {
|
||||
for await (let metric of metricIterator) {
|
||||
results.push(metric);
|
||||
@@ -98,20 +98,46 @@ async function getMetrics(req: Request, res: Response, next: any) {
|
||||
results.push(lib.deserializeResult(result));
|
||||
}
|
||||
|
||||
let text = await lib.exposition(results);
|
||||
return await lib.exposition(results);
|
||||
}
|
||||
|
||||
// Prometheus polling endpoint
|
||||
async function getMetrics(req: Request, res: Response) {
|
||||
try {
|
||||
res.locals.user.checkPermission("core.controller.metrics");
|
||||
} catch (err: any) {
|
||||
res.status(403).json({ request_errors: [err.message] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Concurrent scrapes share one gather so hosts see at most one
|
||||
// metrics request at a time regardless of how many clients poll.
|
||||
const locals = req.app.locals;
|
||||
if (!locals.pendingMetrics) {
|
||||
locals.pendingMetrics = gatherMetrics(locals.controller).finally(() => {
|
||||
locals.pendingMetrics = undefined;
|
||||
});
|
||||
}
|
||||
const text = await locals.pendingMetrics;
|
||||
res.set("Content-Type", lib.expositionContentType);
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Removes the static/ prefix webpack adds to bundle paths so they can be
|
||||
* resolved relative to staticRoot.
|
||||
*/
|
||||
export function stripStaticPrefix(bundlePath: string) {
|
||||
return bundlePath.replace(/^static\//, "");
|
||||
}
|
||||
|
||||
function getPlugins(req: Request, res: Response) {
|
||||
let plugins: lib.PluginWebApi[] = [];
|
||||
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
|
||||
let name = pluginInfo.name;
|
||||
let loaded = req.app.locals.controller.plugins.has(name);
|
||||
let enabled = loaded && req.app.locals.controller.config.get(
|
||||
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields,
|
||||
) as unknown as boolean;
|
||||
const loaded = req.app.locals.controller.loadedPlugins.has(pluginInfo);
|
||||
const enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
|
||||
// Note: Cast through unknown is needed because load_plugin is
|
||||
// defined at runtime and no other fields in the controller config
|
||||
// currently have the boolean type.
|
||||
@@ -119,37 +145,54 @@ function getPlugins(req: Request, res: Response) {
|
||||
let devPlugins = req.app.locals.devPlugins;
|
||||
if (devPlugins && devPlugins.has(name)) {
|
||||
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
|
||||
web.main = stats.toJson().assetsByChunkName[name];
|
||||
web.main = stats.toJson().assetsByChunkName[name][0];
|
||||
} else if (pluginInfo.manifest) {
|
||||
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
|
||||
if (!web.main) {
|
||||
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
|
||||
}
|
||||
} else {
|
||||
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
|
||||
web.error = "Missing dist/web/manifest.json";
|
||||
}
|
||||
if (web.main === "remoteEntry.js") {
|
||||
web.error = "Incompatible old remoteEntry.js entrypoint.";
|
||||
}
|
||||
if (web.main) {
|
||||
web.main = stripStaticPrefix(web.main);
|
||||
}
|
||||
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage });
|
||||
}
|
||||
res.send(plugins);
|
||||
}
|
||||
|
||||
function getClusterName(req: Request, res: Response) {
|
||||
const controller: Controller = req.app.locals.controller;
|
||||
const clusterName = controller.config.get("controller.name");
|
||||
res.json({ name: clusterName });
|
||||
}
|
||||
|
||||
// Reads the access token from X-Access-Token or Authorization: Bearer
|
||||
function getAccessToken(req: Request) {
|
||||
const token = req.header("x-access-token");
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
const authorization = req.header("authorization");
|
||||
if (authorization && /^Bearer /i.test(authorization)) {
|
||||
return authorization.slice("Bearer ".length).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateHostToken(req: Request, res: Response, next: any) {
|
||||
let token = req.header("x-access-token");
|
||||
let token = getAccessToken(req);
|
||||
if (!token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "host" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -169,23 +212,18 @@ function validateHostToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
function validateUserToken(req: Request, res: Response, next: any) {
|
||||
let token = req.header("x-access-token");
|
||||
let token = getAccessToken(req);
|
||||
if (!token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
let user = req.app.locals.controller.userManager.getByName(tokenPayload.user);
|
||||
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
@@ -236,13 +274,14 @@ async function uploadExport(req: Request, res: Response) {
|
||||
let exportFiles = [
|
||||
"export/settings.json",
|
||||
"export/prototypes.json",
|
||||
"export/item-spritesheet.png",
|
||||
"export/item-metadata.json",
|
||||
"export/locale.json",
|
||||
"export/defines.json",
|
||||
"export/spritesheet.png",
|
||||
"export/metadata.json",
|
||||
];
|
||||
|
||||
let assets: any = {};
|
||||
let settingPrototypes = {};
|
||||
let settingPrototypes: lib.ExportSettings = {};
|
||||
for (let filePath of exportFiles) {
|
||||
let file = zip.file(filePath);
|
||||
if (!file) {
|
||||
@@ -259,7 +298,7 @@ async function uploadExport(req: Request, res: Response) {
|
||||
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
|
||||
}
|
||||
|
||||
modPack.exportManifest = new lib.ExportManifest(assets);
|
||||
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
|
||||
modPack.fillDefaultSettings(settingPrototypes, logger);
|
||||
res.app.locals.controller.modPacks.set(modPack);
|
||||
|
||||
@@ -310,7 +349,7 @@ export async function createProxyStream(app: Application): Promise<ProxyStream>
|
||||
return stream;
|
||||
}
|
||||
|
||||
async function putStream(req: Request, res: Response) {
|
||||
async function putStream(req: Request<{ id: string }>, res: Response) {
|
||||
const stream = req.app.locals.streams.get(req.params.id);
|
||||
if (!stream || stream.source) {
|
||||
res.sendStatus(404);
|
||||
@@ -349,7 +388,7 @@ function startStream(res: Response, stream: ProxyStream) {
|
||||
clearTimeout(stream.timeout);
|
||||
}
|
||||
|
||||
async function getStream(req: Request, res: Response) {
|
||||
async function getStream(req: Request<{ id: string }>, res: Response) {
|
||||
const stream = req.app.locals.streams.get(req.params.id);
|
||||
if (!stream || stream.flowing) {
|
||||
res.sendStatus(404);
|
||||
@@ -548,30 +587,16 @@ async function uploadMod(req: Request, res: Response) {
|
||||
}
|
||||
|
||||
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
|
||||
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
const filePath = path.join(modsDirectory, filename);
|
||||
const [tempFilePath, writeStream] = await lib.createTempWriteStream(filePath);
|
||||
|
||||
let writeStream;
|
||||
try {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
|
||||
const modInfo = await lib.ModInfo.fromModFile(tempFilePath);
|
||||
checkModName(modInfo.filename);
|
||||
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
|
||||
await fs.rename(tempFilePath, path.join(modsDirectory, modInfo.filename));
|
||||
req.app.locals.controller.modStore.addMod(modInfo);
|
||||
mods.push(modInfo.toJSON());
|
||||
|
||||
@@ -581,15 +606,13 @@ async function uploadMod(req: Request, res: Response) {
|
||||
stream.resume();
|
||||
|
||||
// Attempt to clean up.
|
||||
if (writeStream) {
|
||||
writeStream.destroy();
|
||||
}
|
||||
writeStream.destroy();
|
||||
|
||||
try {
|
||||
await fs.unlink(path.join(modsDirectory, tempFilename));
|
||||
await fs.unlink(tempFilePath);
|
||||
} catch (unlinkErr: any) {
|
||||
if (unlinkErr.code !== "ENOENT") {
|
||||
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
|
||||
logger.error(`Error removing ${tempFilePath}: ${unlinkErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,14 +685,22 @@ async function uploadMod(req: Request, res: Response) {
|
||||
|
||||
|
||||
export function addRouteHandlers(app: Application) {
|
||||
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
|
||||
app.get("/metrics",
|
||||
validateUserToken,
|
||||
(req:Request, res:Response, next:any) => getMetrics(req, res).catch(next)
|
||||
);
|
||||
app.get("/api/plugins", getPlugins);
|
||||
app.get("/api/cluster-name", getClusterName);
|
||||
app.put("/api/upload-export",
|
||||
validateHostToken,
|
||||
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
|
||||
);
|
||||
app.put("/api/stream/:id", (req:Request, res:Response, next:any) => putStream(req, res).catch(next));
|
||||
app.get("/api/stream/:id", (req:Request, res:Response, next:any) => getStream(req, res).catch(next));
|
||||
app.put("/api/stream/:id",
|
||||
(req:Request<{ id: string }>, res:Response, next:any) => putStream(req, res).catch(next)
|
||||
);
|
||||
app.get("/api/stream/:id",
|
||||
(req:Request<{ id: string }>, res:Response, next:any) => getStream(req, res).catch(next)
|
||||
);
|
||||
app.post("/api/upload-save",
|
||||
validateUserToken,
|
||||
(req:Request, res:Response, next:any) => uploadSave(req, res).catch(next)
|
||||
@@ -690,6 +721,7 @@ export const webRoutes = [
|
||||
"/instances/:id/view",
|
||||
"/mods",
|
||||
"/mods/mod-packs/:id/view",
|
||||
"/mods/mod-packs/:id/icon-reference",
|
||||
"/users",
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
|
||||
<script>
|
||||
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
|
||||
staticRoot = new URL("__STATIC_ROOT__", document.location).pathname;
|
||||
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
|
||||
function scriptError(err) {
|
||||
console.error(err);
|
||||
let bootstrapMsg = document.getElementById("bootstrap-msg");
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
devServer: {
|
||||
+73
-129
@@ -1,21 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
"use strict";
|
||||
const child_process = require("child_process");
|
||||
const fs = require("fs-extra");
|
||||
const inquirer = require("inquirer");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
const yargs = require("yargs");
|
||||
import child_process from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import inquirer from "inquirer";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import stream from "node:stream";
|
||||
import util from "node:util";
|
||||
import yargs from "yargs";
|
||||
|
||||
const { levels, logger, setLogLevel } = require("./logging");
|
||||
const { copyPluginTemplates } = require("./template");
|
||||
import { levels, logger, setLogLevel } from "./logging.js";
|
||||
import { copyPluginTemplates } from "./template.js";
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const finished = util.promisify(stream.finished);
|
||||
const { escapeArg } = require("./escape_arg");
|
||||
import { escapeArg } from "./escape_arg.js";
|
||||
|
||||
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
|
||||
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
|
||||
@@ -97,24 +95,36 @@ class LineSplitter extends stream.Transform {
|
||||
|
||||
class InstallError extends Error { }
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let { dir, name, ext } = path.parse(file);
|
||||
let temporary = path.join(dir, `${name}.tmp${ext}`);
|
||||
await fs.outputFile(temporary, data, options);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(temporary, data, { ...options, flush: true });
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
async function execFile(cmd, args) {
|
||||
const escaped = args.map(escapeArg);
|
||||
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
|
||||
const command = [cmd, ...args.map(escapeArg)].join(" ");
|
||||
logger.verbose(`executing ${command}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
let child = child_process.execFile(
|
||||
cmd,
|
||||
escaped,
|
||||
command,
|
||||
{
|
||||
shell: true,
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
@@ -136,21 +146,21 @@ async function execFile(cmd, args) {
|
||||
|
||||
async function execController(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
|
||||
return await execFile("node", [path.join(import.meta.dirname, "..", "controller"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execHost(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
|
||||
return await execFile("node", [path.join(import.meta.dirname, "..", "host"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execCtl(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
|
||||
return await execFile("node", [path.join(import.meta.dirname, "..", "ctl"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
||||
}
|
||||
@@ -221,10 +231,10 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
async function migrateConfig(source, destination) {
|
||||
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
|
||||
if (await pathExists(source) && !await pathExists(destination)) {
|
||||
await safeOutputFile(destination, JSON.stringify(
|
||||
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
|
||||
));
|
||||
), { mode: 0o600 });
|
||||
logger.info(`Migrated ${source} to ${destination}`);
|
||||
}
|
||||
}
|
||||
@@ -233,7 +243,7 @@ async function migrateRename(args) {
|
||||
await migrateConfig("config-slave.json", "config-host.json");
|
||||
|
||||
let instancesFile = path.join("database", "instances.json");
|
||||
if (await fs.pathExists(instancesFile)) {
|
||||
if (await pathExists(instancesFile)) {
|
||||
await safeOutputFile(instancesFile, JSON.stringify(
|
||||
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
|
||||
));
|
||||
@@ -241,7 +251,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
let usersFile = path.join("database", "users.json");
|
||||
if (await fs.pathExists(usersFile)) {
|
||||
if (await pathExists(usersFile)) {
|
||||
let users = JSON.parse(await fs.readFile(usersFile));
|
||||
for (let role of users["roles"]) {
|
||||
role["permissions"] = role["permissions"].map(rename);
|
||||
@@ -258,7 +268,7 @@ async function migrateRename(args) {
|
||||
|
||||
async function migrateLog(inputFile, outputFile) {
|
||||
let lineStream = new LineSplitter({ readableObjectMode: true });
|
||||
let fileStream = fs.createReadStream(inputFile);
|
||||
let fileStream = (await fs.open(inputFile)).createReadStream();
|
||||
fileStream.pipe(lineStream);
|
||||
let lines = [];
|
||||
|
||||
@@ -278,7 +288,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
async function migrateLogsDir(inputLogs, outputLogs) {
|
||||
if (await fs.pathExists(inputLogs)) {
|
||||
if (await pathExists(inputLogs)) {
|
||||
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
|
||||
for (let logFile of await fs.readdir(inputLogs)) {
|
||||
if (!logFile.endsWith(".log")) {
|
||||
@@ -286,7 +296,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
let inputFile = path.join(inputLogs, logFile);
|
||||
let outputFile = path.join(outputLogs, rename(logFile));
|
||||
if (!await fs.pathExists(outputFile)) {
|
||||
if (!await pathExists(outputFile)) {
|
||||
await migrateLog(inputFile, outputFile);
|
||||
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
|
||||
}
|
||||
@@ -297,16 +307,16 @@ async function migrateRename(args) {
|
||||
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
|
||||
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
|
||||
if (
|
||||
await fs.pathExists(path.join("logs", "cluster"))
|
||||
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
|
||||
await pathExists(path.join("logs", "cluster"))
|
||||
&& !await pathExists(path.join("logs", "cluster-prerename"))
|
||||
) {
|
||||
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
|
||||
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
|
||||
}
|
||||
|
||||
if (
|
||||
await fs.pathExists("sharedMods")
|
||||
&& !await fs.pathExists("mods")
|
||||
await pathExists("sharedMods")
|
||||
&& !await pathExists("mods")
|
||||
) {
|
||||
logger.info("Moving sharedMods/ to mods/");
|
||||
await fs.rename("sharedMods", "mods");
|
||||
@@ -328,14 +338,14 @@ async function migrateRename(args) {
|
||||
await execFile(`npm${scriptExt}`, ["update"]);
|
||||
}
|
||||
|
||||
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
|
||||
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
|
||||
const hasRunMaster = await pathExists("run-master.sh") || await pathExists("run-master.cmd");
|
||||
const hasRunSlave = await pathExists("run-slave.sh") || await pathExists("run-slave.cmd");
|
||||
if (hasRunMaster || hasRunSlave) {
|
||||
logger.info("Writing run scripts");
|
||||
let mode = "standalone";
|
||||
if (!hasRunSlave) { mode = "controller"; }
|
||||
if (!hasRunMaster) { mode = "host"; }
|
||||
await writeScripts(mode);
|
||||
await writeScripts(mode, "");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -354,7 +364,7 @@ async function migrateRename(args) {
|
||||
|
||||
async function installClusterio(mode, plugins) {
|
||||
try {
|
||||
await fs.outputFile("package.json", JSON.stringify({
|
||||
await fs.writeFile("package.json", JSON.stringify({
|
||||
name: "clusterio-install",
|
||||
private: true,
|
||||
}, null, 2), { flag: "wx" });
|
||||
@@ -396,8 +406,8 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
if (!pluginList.has(plugin)) {
|
||||
// eslint-disable-next-line node/global-require
|
||||
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
|
||||
|
||||
let pluginInfo = (await import(plugin)).plugin;
|
||||
pluginList.set(pluginInfo.name, plugin);
|
||||
}
|
||||
}
|
||||
@@ -420,7 +430,7 @@ async function groupIdToName(gid) {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeScripts(mode) {
|
||||
async function writeScripts(mode, selfSignedCert) {
|
||||
if (["standalone", "controller"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await safeOutputFile(
|
||||
@@ -474,12 +484,13 @@ WantedBy=multi-user.target
|
||||
|
||||
if (["standalone", "host"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
|
||||
await safeOutputFile(
|
||||
"run-host.cmd",
|
||||
`\
|
||||
@echo off
|
||||
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
|
||||
:restart
|
||||
${extraCaCertsLine}:restart
|
||||
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
|
||||
if %errorlevel% equ 0 exit /b
|
||||
if %errorlevel% equ 8 exit /b
|
||||
@@ -487,18 +498,20 @@ goto restart
|
||||
`
|
||||
);
|
||||
} else {
|
||||
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
|
||||
await safeOutputFile(
|
||||
"run-host.sh",
|
||||
`\
|
||||
#!/bin/bash
|
||||
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
|
||||
while true; do
|
||||
${extraCaCertsLine}while true; do
|
||||
./node_modules/.bin/clusteriohost run --can-restart
|
||||
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
|
||||
done
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriohost.service",
|
||||
`\
|
||||
@@ -512,7 +525,7 @@ WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
Environment=NODE_OPTIONS=--enable-source-maps
|
||||
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
|
||||
${extraCaCertsLine}ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=8
|
||||
|
||||
@@ -640,26 +653,10 @@ async function inquirerMissingArgs(args) {
|
||||
let locations = factorioLocations[process.platform] || [];
|
||||
let foundLocations = [];
|
||||
for (let location of locations) {
|
||||
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
if (await pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
foundLocations.push(location);
|
||||
}
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "downloadHeadless",
|
||||
message: "(Linux only) Automatically download latest factorio release?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (!answers.factorioDir && answers.downloadHeadless) {
|
||||
answers.factorioDir = "factorio";
|
||||
}
|
||||
}
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
@@ -684,11 +681,12 @@ async function inquirerMissingArgs(args) {
|
||||
},
|
||||
], answers);
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.ensureDir("factorio");
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
@@ -750,56 +748,8 @@ async function inquirerMissingArgs(args) {
|
||||
return answers;
|
||||
}
|
||||
|
||||
async function downloadLinuxServer() {
|
||||
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
|
||||
|
||||
const url = new URL(res.headers.location);
|
||||
// get the filename of the latest factorio archive from redirected url
|
||||
const filename = path.posix.basename(url.pathname);
|
||||
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
|
||||
if (!match || !match.length) {
|
||||
throw Error(`Unable to extract version from filename: ${filename}`);
|
||||
}
|
||||
const version = match[0];
|
||||
|
||||
const tmpDir = "temp/create-temp/";
|
||||
const archivePath = tmpDir + filename;
|
||||
const tmpArchivePath = `${archivePath}.tmp`;
|
||||
const factorioDir = `factorio/${version}/`;
|
||||
const tmpFactorioDir = tmpDir + version;
|
||||
|
||||
if (await fs.pathExists(factorioDir)) {
|
||||
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
||||
} else {
|
||||
await fs.ensureDir(tmpDir);
|
||||
|
||||
// follow the redirect
|
||||
res = await fetch(url.href);
|
||||
|
||||
logger.info("Downloading latest Factorio server release. This may take a while.");
|
||||
const writeStream = fs.createWriteStream(tmpArchivePath);
|
||||
stream.Readable.fromWeb(res.body).pipe(writeStream);
|
||||
|
||||
await finished(writeStream);
|
||||
|
||||
await fs.rename(tmpArchivePath, archivePath);
|
||||
try {
|
||||
await fs.ensureDir(tmpFactorioDir);
|
||||
await execFile("tar", [
|
||||
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.error("error executing command- do you have 'xz-utils' installed?");
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.unlink(archivePath);
|
||||
await fs.rename(tmpFactorioDir, factorioDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let args = yargs
|
||||
let yarg = yargs(process.argv.slice(2))
|
||||
.option("log-level", {
|
||||
nargs: 1, describe: "Log level to print to stdout", default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)), type: "string",
|
||||
@@ -835,6 +785,9 @@ async function main() {
|
||||
.option("factorio-dir", {
|
||||
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
|
||||
})
|
||||
.option("self-signed-cert", {
|
||||
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
|
||||
})
|
||||
.option("remote-npm", {
|
||||
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
|
||||
})
|
||||
@@ -851,20 +804,14 @@ async function main() {
|
||||
;
|
||||
|
||||
if (process.platform === "linux") {
|
||||
args = args
|
||||
yarg = yarg
|
||||
.option("allow-install-as-root", {
|
||||
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
|
||||
})
|
||||
.option("download-headless", {
|
||||
nargs: 0,
|
||||
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
|
||||
"Can be set to false using --no-download-headless.",
|
||||
type: "boolean",
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
args = args.argv;
|
||||
const args = yarg.parse();
|
||||
|
||||
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
|
||||
dev = args.dev;
|
||||
@@ -887,13 +834,6 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (answers.downloadHeadless) {
|
||||
if (answers.factorioDir !== "factorio") {
|
||||
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
|
||||
}
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
if (!dev) {
|
||||
await installClusterio(answers.mode, answers.plugins);
|
||||
}
|
||||
@@ -901,6 +841,7 @@ async function main() {
|
||||
let adminToken = null;
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
logger.info("Setting up controller");
|
||||
await execController(["config", "create"]);
|
||||
await execController(["bootstrap", "create-admin", answers.admin]);
|
||||
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
||||
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
|
||||
@@ -911,6 +852,7 @@ async function main() {
|
||||
|
||||
if (answers.mode === "standalone") {
|
||||
logger.info("Setting up host");
|
||||
await execHost(["config", "create"]);
|
||||
await execHost(["config", "set", "host.name", "local"]);
|
||||
|
||||
let result = await execHost(["config", "show", "host.id"]);
|
||||
@@ -931,6 +873,7 @@ async function main() {
|
||||
if (answers.mode === "host") {
|
||||
logger.info("Setting up host");
|
||||
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
|
||||
await execHost(["config", "create"]);
|
||||
await execHost(["config", "set", "host.id", hostId]);
|
||||
await execHost(["config", "set", "host.name", answers.hostName]);
|
||||
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
|
||||
@@ -943,12 +886,13 @@ async function main() {
|
||||
|
||||
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
logger.info("Writing run scripts");
|
||||
await writeScripts(answers.mode);
|
||||
await writeScripts(answers.mode, args.selfSignedCert);
|
||||
}
|
||||
|
||||
if (answers.mode === "ctl") {
|
||||
await execCtl(["control-config", "set", "control.controller_url", answers.controllerUrl]);
|
||||
await execCtl(["control-config", "set", "control.controller_token", answers.controllerToken]);
|
||||
await execCtl(["config", "create"]);
|
||||
await execCtl(["config", "set", "ctl.controller_url", answers.controllerUrl]);
|
||||
await execCtl(["config", "set", "ctl.controller_token", answers.controllerToken]);
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
@@ -959,7 +903,7 @@ async function main() {
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
if (import.meta.main) {
|
||||
main().catch(err => {
|
||||
if (err instanceof InstallError) {
|
||||
logger.error(err.message);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use strict";
|
||||
|
||||
function escapeArg(arg) {
|
||||
export function escapeArg(arg) {
|
||||
// When shell: true is used execFile does not escaping of arguments, they are
|
||||
// simply joined by space and then passed to the shell.
|
||||
|
||||
@@ -50,13 +48,9 @@ function escapeArg(arg) {
|
||||
// For all other platforms the command is /bin/sh -c <command arg arg ...>
|
||||
// The command and its arguments are passed as a single argument to the shell.
|
||||
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
|
||||
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeArg,
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable no-console */
|
||||
"use strict";
|
||||
const chalk = require("chalk");
|
||||
import chalk from "chalk";
|
||||
|
||||
const levels = Object.freeze({
|
||||
export const levels = Object.freeze({
|
||||
fatal: 0,
|
||||
error: 1,
|
||||
warn: 2,
|
||||
@@ -11,7 +10,7 @@ const levels = Object.freeze({
|
||||
verbose: 6,
|
||||
});
|
||||
|
||||
const colors = {
|
||||
export const colors = {
|
||||
fatal: "inverse red",
|
||||
error: "bold redBright",
|
||||
warn: "bold yellowBright",
|
||||
@@ -21,7 +20,7 @@ const colors = {
|
||||
};
|
||||
|
||||
let maxLogLevel = levels.info;
|
||||
const logger = {};
|
||||
export const logger = {};
|
||||
for (let [name, level] of Object.entries(levels)) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
logger[name] = message => {
|
||||
@@ -42,10 +41,6 @@ for (let [name, level] of Object.entries(levels)) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
colors,
|
||||
levels,
|
||||
setLogLevel: (level) => { maxLogLevel = level; },
|
||||
logger,
|
||||
};
|
||||
|
||||
export function setLogLevel(level) {
|
||||
maxLogLevel = level;
|
||||
}
|
||||
@@ -1,27 +1,39 @@
|
||||
{
|
||||
"name": "@clusterio/create",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clusterio/clusterio"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"inquirer": "^8.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
"type": "module",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"chalk": "catalog:",
|
||||
"inquirer": "^8.2.4",
|
||||
"yargs": "catalog:"
|
||||
}
|
||||
}
|
||||
+34
-22
@@ -1,7 +1,8 @@
|
||||
"use strict";
|
||||
const { logger } = require("./logging");
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
import { logger } from "./logging.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import _package from "./package.json" with { type: "json" };
|
||||
|
||||
async function copyTemplateFile(src, dst, properties) {
|
||||
logger.verbose(`Writing ${dst} from template ${src}`);
|
||||
@@ -59,9 +60,15 @@ async function copyTemplateFile(src, dst, properties) {
|
||||
return include ? content : "";
|
||||
});
|
||||
|
||||
// Create folder for output file
|
||||
const { dir } = path.parse(dst);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Attempt to write the output file, warn if it already exists
|
||||
try {
|
||||
await fs.outputFile(dst, outputData, { flag: "wx" });
|
||||
await fs.writeFile(dst, outputData, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
logger.warn(`Could not create file ${dst} because it already exists`);
|
||||
@@ -71,7 +78,7 @@ async function copyTemplateFile(src, dst, properties) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPluginTemplates(pluginName, templates) {
|
||||
export async function copyPluginTemplates(pluginName, templates) {
|
||||
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
|
||||
const files = new Map();
|
||||
const prepare = [];
|
||||
@@ -88,12 +95,15 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Get the file extension and path to the templates
|
||||
const ext = javascriptOnly ? "js" : "ts";
|
||||
const templatePath = path.resolve(__dirname, javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts");
|
||||
const commonPath = path.resolve(__dirname, "./templates/common");
|
||||
const templatePath = path.resolve(
|
||||
import.meta.dirname,
|
||||
javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts"
|
||||
);
|
||||
const commonPath = path.resolve(import.meta.dirname, "./templates/common");
|
||||
|
||||
// Files included in all templates
|
||||
files.set(".gitignore", path.join(commonPath, ".gitignore"));
|
||||
files.set(".npmignore", path.join(commonPath, ".npmignore"));
|
||||
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
|
||||
files.set(".npmignore", path.join(commonPath, "template.npmignore"));
|
||||
files.set("package.json", path.join(commonPath, "package.json"));
|
||||
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
|
||||
|
||||
@@ -102,6 +112,7 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
prepare.push("tsc --build");
|
||||
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
|
||||
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
||||
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
|
||||
if (webpack) {
|
||||
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
|
||||
}
|
||||
@@ -109,8 +120,8 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Files and dependences to support webpack
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --env production");
|
||||
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
|
||||
prepare.push("webpack-cli --mode production");
|
||||
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
|
||||
if (templates.includes("web")) {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||
pluginContexts += 1;
|
||||
@@ -139,7 +150,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Files for lua modules
|
||||
if (templates.includes("module")) {
|
||||
files.set(".luacheckrc", path.join(commonPath, ".luacheckrc"));
|
||||
files.set("module/module.json", path.join(commonPath, "module/module.json"));
|
||||
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
|
||||
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
|
||||
@@ -147,7 +157,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
|
||||
} else {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,18 +173,25 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Properties that will control the replacements in the templates
|
||||
const properties = {
|
||||
config: config,
|
||||
// Weather this is ts or js
|
||||
typescript: !javascriptOnly,
|
||||
ext: ext,
|
||||
// Which templates where requested
|
||||
multi_context: pluginContexts > 1,
|
||||
controller: templates.includes("controller"),
|
||||
host: templates.includes("host"),
|
||||
instance: templates.includes("instance"),
|
||||
module: templates.includes("module"),
|
||||
ctl: templates.includes("ctl"),
|
||||
web: templates.includes("web"),
|
||||
multi_context: pluginContexts > 1,
|
||||
plugin_name: pluginName,
|
||||
// Macro flags for context requirements
|
||||
webpack: webpack,
|
||||
config: config,
|
||||
// String values for package json
|
||||
clusterio_version: _package.version,
|
||||
node_version: _package.engines.node,
|
||||
prepare: prepare.join(" && "),
|
||||
ext: ext,
|
||||
plugin_name: pluginName,
|
||||
};
|
||||
|
||||
// Write all the template files
|
||||
@@ -188,7 +204,3 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
await Promise.all(writes);
|
||||
logger.info("Successfully wrote all template files");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
copyPluginTemplates,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,14 @@
|
||||
--[[
|
||||
The 'control' file within factorio is used as the entry point for mods, in Clusterio this is only a convention
|
||||
You can change which files are loaded (exports event handlers) and required (no events) by Clusterio within module.json
|
||||
It is highly advised to use FMTK (justarandomgeek.factoriomod-debug) when writing Lua code for factorio
|
||||
Most of your code will be within this one file, but you can create more and require them
|
||||
|
||||
clusterio_api exposes functions to enable communication with your InstancePlugin
|
||||
compat exposes shims to simplify compatibly between factorio versions, use the following to require 2.0
|
||||
`assert(compat.version_ge("2.0.0"), "__plugin_name__ requires factorio 2.0")`
|
||||
]]
|
||||
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local compat = require("modules/clusterio/compat")
|
||||
|
||||
@@ -6,10 +17,12 @@ local compat = require("modules/clusterio/compat")
|
||||
local MyModule = {
|
||||
}
|
||||
|
||||
--- script_data is 'synced' between players, you should use your plugin name to avoid name conflicts
|
||||
-- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup
|
||||
-- There are edge cases in the following events: pre player left, player left, console command, and config changed
|
||||
-- If you use any of these events and access globalData then make sure to call this function first
|
||||
--[[
|
||||
`compat.script_data` persists between restarts and is shared between modules, therefore you should use your plugin name to avoid conflicts
|
||||
The following helper function is desync safe and avoids name conflicts after being called during `clusterio_api.events.on_server_startup`
|
||||
There are edge cases in the following events: pre player left, player left, console command, and config changed
|
||||
If you use any of these events and access `script_data` then make sure to call this function first
|
||||
]]
|
||||
local script_data = {}
|
||||
local function setup_script_data()
|
||||
if compat.script_data["__plugin_name__"] == nil then
|
||||
@@ -28,12 +41,13 @@ end
|
||||
|
||||
--- Public methods should be available though your top level module table
|
||||
function MyModule.foo()
|
||||
game.print("foo")
|
||||
game.print("foo: " .. (script_data.counter or 0))
|
||||
end
|
||||
|
||||
--- Private methods should be local to the file, this will prevent others from calling it
|
||||
local function bar()
|
||||
game.print("bar")
|
||||
script_data.counter = (script_data.counter or 0) + 1
|
||||
game.print("bar: " .. script_data.counter)
|
||||
end
|
||||
|
||||
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
|
||||
@@ -61,7 +75,7 @@ local function on_nth_tick_300()
|
||||
bar()
|
||||
end
|
||||
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per module
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per file
|
||||
local e = defines.events
|
||||
local events = {
|
||||
[clusterio_api.events.on_server_startup] = on_server_startup,
|
||||
@@ -73,7 +87,7 @@ local on_nth_tick = {
|
||||
[300] = on_nth_tick_300,
|
||||
}
|
||||
|
||||
--- Always return the top level module table from control, this is how clusterio will access your event handlers
|
||||
--- Always return the top level module table from control, this is how Clusterio will access your event handlers
|
||||
MyModule.events = events --- @package
|
||||
MyModule.on_nth_tick = on_nth_tick --- @package
|
||||
return MyModule
|
||||
@@ -1,3 +1,9 @@
|
||||
--- Expose any globals here, typically just your control file if you want to access it through rcon or commands
|
||||
-- luacheck: globals __plugin_name__
|
||||
--[[
|
||||
It is best practice to not expose any globals because all modules share a global environment
|
||||
However, sometimes you need globals, for example to access functions within rcon commands
|
||||
Therefore, we advise that this should be the only file in your module to expose globals
|
||||
Typically this would be your control file as shown in the example below
|
||||
]]
|
||||
|
||||
-- Access using `/sc __plugin_name__.foo()`
|
||||
__plugin_name__ = require("modules/__plugin_name__/control")
|
||||
@@ -1,4 +1,8 @@
|
||||
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
|
||||
--- If you only export a single file then it may be better to rename that file to module_exports and save the redirection
|
||||
-- Access the exports from other modules using require("modules/__plugin_name__")
|
||||
--[[
|
||||
It is typical for a module to have a control file as the entry point, although this is not a requirement
|
||||
Therefore, Clusterio allows you to specify a single default export that can be accessed by other modules
|
||||
You are not required to specify any default export, but note that a module can still require yours directly
|
||||
]]
|
||||
|
||||
-- Access using `local __plugin_name__ = require("modules/__plugin_name__")`
|
||||
return require("modules/__plugin_name__/control")
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "__plugin_name__",
|
||||
"version": "0.1.0",
|
||||
"description": "Example Description. Package. Change me in package.json",
|
||||
"type": "module",
|
||||
//%if typescript
|
||||
"main": "dist/node/index.js",
|
||||
//%endif
|
||||
@@ -12,36 +13,81 @@
|
||||
"prepare": "__prepare__"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "__node_version__"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "workspace:*"
|
||||
//%if controller
|
||||
"@clusterio/controller": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if host
|
||||
"@clusterio/host": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if ctl
|
||||
"@clusterio/ctl": "^__clusterio_version__",
|
||||
//%endif
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
//%if controller | host | ctl
|
||||
"peerDependenciesMeta": {
|
||||
//%endif
|
||||
//%if controller
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if controller & host | controller & ctl
|
||||
},
|
||||
//%endif
|
||||
//%if host
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if host & ctl
|
||||
},
|
||||
//%endif
|
||||
//%if ctl
|
||||
"@clusterio/ctl": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if controller | host | ctl
|
||||
}
|
||||
},
|
||||
//%endif
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
//%if typescript
|
||||
"typescript": "^5.5.3",
|
||||
"@types/node": "^20.4.5",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^24.13.4",
|
||||
//%endif
|
||||
//%if typescript & web
|
||||
"@types/react": "^18.2.21",
|
||||
"@types/react": "^18.3.31",
|
||||
//%endif
|
||||
//%// Everything else
|
||||
//%if web
|
||||
"antd": "^5.13.0",
|
||||
"antd": "^5.29.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
//%endif
|
||||
//%if web | controller // The controller uses webpack even when there is no plugin code
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
//%if webpack
|
||||
"webpack": "^5.110.3",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-merge": "^5.9.0",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
//%endif
|
||||
"@clusterio/lib": "workspace:*"
|
||||
//%if controller
|
||||
"@clusterio/controller": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if webpack
|
||||
"@clusterio/web_ui": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if host
|
||||
"@clusterio/host": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if ctl
|
||||
"@clusterio/ctl": "^__clusterio_version__",
|
||||
//%endif
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4"
|
||||
"@sinclair/typebox": "^0.30.6"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
dist/
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
webpack.config.js
|
||||
dist/web/build
|
||||
dist/browser
|
||||
dist/web
|
||||
+1
-3
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.__ext__x",
|
||||
output: {
|
||||
@@ -18,9 +18,7 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
exposes: {
|
||||
"./": "./index.__ext__",
|
||||
"./package.json": "./package.json",
|
||||
//%if web
|
||||
"./web": "./web/index.__ext__x",
|
||||
//%endif
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
@@ -1,83 +1,66 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
const {
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} = require("./messages");
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages.js";
|
||||
//%endif
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
export default async function loadControllerPlugin(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
exampleDatabase;
|
||||
storageDirty = false;
|
||||
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
async init() {
|
||||
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
async onControllerConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
|
||||
}
|
||||
//%if instance
|
||||
controller.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
async onInstanceConfigFieldChanged(instance, field, curr, prev) {
|
||||
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onSaveData() {
|
||||
this.logger.info("controller::onSaveData");
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("controller::onShutdown");
|
||||
}
|
||||
|
||||
async onPlayerEvent(instance, event) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
controller.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request) {
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
const values = [...exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return values.length ? new ExampleSubscribableUpdate(values) : null;
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
};
|
||||
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`controller::controllerConfigFieldChanged ${field}`);
|
||||
});
|
||||
//%if instance
|
||||
|
||||
controller.hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field, curr, prev) => {
|
||||
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
});
|
||||
//%endif
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
logger.info("controller::save");
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("controller::shutdown");
|
||||
});
|
||||
|
||||
controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
|
||||
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use strict";
|
||||
const { BaseCtlPlugin } = require("@clusterio/ctl");
|
||||
const { CommandTree, Command } = require("@clusterio/lib");
|
||||
import { CommandTree, Command } from "@clusterio/lib";
|
||||
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const pluginCommands = new CommandTree({
|
||||
@@ -13,20 +11,16 @@ pluginCommands.add(new Command({
|
||||
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
|
||||
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
handler: async function(args, ctl) {
|
||||
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
console.log(response);
|
||||
console.log(args);
|
||||
},
|
||||
}));
|
||||
|
||||
/* eslint-enable no-console */
|
||||
class CtlPlugin extends BaseCtlPlugin {
|
||||
async addCommands(rootCommand) {
|
||||
export default async function loadCtlPlugin(context) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
rootCommand.add(pluginCommands);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CtlPlugin,
|
||||
};
|
||||
@@ -1,41 +1,30 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseHostPlugin } = require("@clusterio/host");
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
//%endif
|
||||
|
||||
class HostPlugin extends BaseHostPlugin {
|
||||
//%if multi_context
|
||||
async init() {
|
||||
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onHostConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("host::onShutdown");
|
||||
}
|
||||
export default async function loadHostPlugin(context) {
|
||||
const { host, logger, plugin } = context;
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
host.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
host.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HostPlugin,
|
||||
};
|
||||
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`host::hostConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
host.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("host::shutdown");
|
||||
});
|
||||
}
|
||||
@@ -1,38 +1,9 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
const Messages = require("./messages");
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.js",
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.js",
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.js",
|
||||
});
|
||||
import * as Messages from "./messages.js";
|
||||
//%endif
|
||||
|
||||
const plugin = {
|
||||
export const plugin = {
|
||||
name: "__plugin_name__",
|
||||
title: "__plugin_name__",
|
||||
description: "Example Description. Plugin. Change me in index.js",
|
||||
@@ -40,7 +11,7 @@ const plugin = {
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
@@ -56,7 +27,7 @@ controllerEntrypoint: "./dist/node/controller",
|
||||
|
||||
//%endif
|
||||
//%if host
|
||||
hostEntrypoint: "./dist/node/host",
|
||||
hostEntrypoint: "./dist/node/host.js",
|
||||
//%endif
|
||||
//%if host & config
|
||||
hostConfigFields: {
|
||||
@@ -72,7 +43,7 @@ controllerEntrypoint: "./dist/node/controller",
|
||||
|
||||
//%endif
|
||||
//%if instance | module // Modules load an empty instance plugin
|
||||
instanceEntrypoint: "./dist/node/instance",
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
//%endif
|
||||
//%if instance & config
|
||||
instanceConfigFields: {
|
||||
@@ -88,12 +59,12 @@ controllerEntrypoint: "./dist/node/controller",
|
||||
|
||||
//%endif
|
||||
//%if ctl
|
||||
ctlEntrypoint: "./dist/node/ctl",
|
||||
ctlEntrypoint: "./dist/node/ctl.js",
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
controlConfigFields: {
|
||||
"__plugin_name__.myControlField": {
|
||||
title: "My Control Field",
|
||||
ctlConfigFields: {
|
||||
"__plugin_name__.myCtlField": {
|
||||
title: "My Ctl Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
@@ -112,6 +83,43 @@ controllerEntrypoint: "./dist/node/controller",
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
],
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
|
||||
permissions: [
|
||||
//%endif
|
||||
//%if multi_context
|
||||
{
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
{
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if web
|
||||
{
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
],
|
||||
//%endif
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
@@ -120,7 +128,3 @@ controllerEntrypoint: "./dist/node/controller",
|
||||
],
|
||||
//%endif
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
@@ -1,66 +1,56 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
//%endif
|
||||
|
||||
class InstancePlugin extends BaseInstancePlugin {
|
||||
//%if multi_context | module
|
||||
async init() {
|
||||
//%if multi_context
|
||||
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if module
|
||||
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
|
||||
//%endif
|
||||
//%if multi_context | module
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onInstanceConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
export default async function loadInstancePlugin(context) {
|
||||
const { instance, logger, plugin } = context;
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
instance.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
instance.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
async handlePluginExampleIPC(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
//%endif
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InstancePlugin,
|
||||
};
|
||||
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`instance::instanceConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
// Called once rcon becomes available
|
||||
logger.info("instance::start");
|
||||
});
|
||||
|
||||
instance.hooks.stop.attach(plugin.name, async () => {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
logger.info("instance::stop");
|
||||
});
|
||||
|
||||
instance.hooks.exit.attach(plugin.name, () => {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
logger.info("instance::exit");
|
||||
});
|
||||
|
||||
instance.hooks.playerEvent.attach(plugin.name, async (event) => {
|
||||
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (instance.status === "running") {
|
||||
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
|
||||
}
|
||||
//%endif
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,3 @@
|
||||
"use strict";
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
|
||||
class InstancePlugin extends BaseInstancePlugin {
|
||||
// This class is empty because an instance plugin must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InstancePlugin,
|
||||
};
|
||||
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
export default async function loadInstancePlugin(context) { }
|
||||
@@ -1,8 +1,7 @@
|
||||
"use strict";
|
||||
const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib");
|
||||
const { Type } = require("@sinclair/typebox");
|
||||
import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
class PluginExampleEvent {
|
||||
export class PluginExampleEvent {
|
||||
static type = "event";
|
||||
static src = ["host", "control"];
|
||||
static dst = ["controller", "host", "instance"];
|
||||
@@ -24,7 +23,7 @@ class PluginExampleEvent {
|
||||
}
|
||||
}
|
||||
|
||||
class PluginExampleRequest {
|
||||
export class PluginExampleRequest {
|
||||
static type = "request";
|
||||
static src = ["host", "control"];
|
||||
static dst = ["controller", "host", "instance"];
|
||||
@@ -52,7 +51,7 @@ class PluginExampleRequest {
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
class ExampleSubscribableValue {
|
||||
export class ExampleSubscribableValue {
|
||||
constructor(id, updatedAtMs, isDeleted) {
|
||||
this.id = id;
|
||||
this.updatedAtMs = updatedAtMs;
|
||||
@@ -70,7 +69,7 @@ class ExampleSubscribableValue {
|
||||
}
|
||||
}
|
||||
|
||||
class ExampleSubscribableUpdate {
|
||||
export class ExampleSubscribableUpdate {
|
||||
static type = "event";
|
||||
static src = "controller";
|
||||
static dst = "control";
|
||||
@@ -90,12 +89,3 @@ class ExampleSubscribableUpdate {
|
||||
}
|
||||
}
|
||||
//%endif
|
||||
|
||||
module.exports = {
|
||||
PluginExampleEvent,
|
||||
PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableValue,
|
||||
ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
};
|
||||
@@ -10,9 +10,9 @@ import React, {
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,17 +20,27 @@ import {
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "../messages";
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages.js";
|
||||
//%endif
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
|
||||
// Created when the plugin is loaded, before any page is rendered
|
||||
let exampleSubscriber;
|
||||
|
||||
function useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback) => exampleSubscriber.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
|
||||
function MyTemplatePage() {
|
||||
let control = useContext(ControlContext);
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
const plugin = control.plugins.get("__plugin_name__");
|
||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
||||
const [subscribableData, synced] = useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
@@ -41,53 +51,39 @@ function MyTemplatePage() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
export default async function loadWebPlugin(context) {
|
||||
const { control, logger, plugin } = context;
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
||||
|
||||
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
|
||||
//%endif
|
||||
async init() {
|
||||
this.pages = [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
|
||||
control.hooks.pages.attach(plugin.name, () => [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
permission: "__plugin_name__.page.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
];
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
]);
|
||||
//%if multi_context
|
||||
|
||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
}
|
||||
//%if controller// Subscribing requires web content and the controller
|
||||
control.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
control.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -8,71 +8,62 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "./messages";
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages.js";
|
||||
//%endif
|
||||
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
export default async function loadControllerPlugin(context: ControllerPluginContext) {
|
||||
const { controller, logger, plugin } = context;
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
exampleDatabase!: Map<string, ExampleSubscribableValue>;
|
||||
storageDirty = false;
|
||||
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
async init() {
|
||||
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
|
||||
}
|
||||
//%if instance
|
||||
controller.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onSaveData() {
|
||||
this.logger.info("controller::onSaveData");
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("controller::onShutdown");
|
||||
}
|
||||
|
||||
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
controller.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request: lib.SubscriptionRequest) {
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request: lib.SubscriptionRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
const values = [...exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return values.length ? new ExampleSubscribableUpdate(values) : null;
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
|
||||
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`controller::controllerConfigFieldChanged ${field}`);
|
||||
});
|
||||
//%if instance
|
||||
|
||||
controller.hooks.instanceConfigFieldChanged.attach(
|
||||
plugin.name, async (instance: InstanceRecord, field, curr, prev) => {
|
||||
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
);
|
||||
//%endif
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
logger.info("controller::save");
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("controller::shutdown");
|
||||
});
|
||||
|
||||
controller.hooks.playerEvent.attach(plugin.name, async (instance: InstanceRecord, event: lib.PlayerEvent) => {
|
||||
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
|
||||
import type { Ctl, CtlPluginContext } from "@clusterio/ctl";
|
||||
import { CommandTree, Command } from "@clusterio/lib";
|
||||
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const pluginCommands = new CommandTree({
|
||||
@@ -12,16 +12,16 @@ pluginCommands.add(new Command({
|
||||
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
|
||||
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { foo: string }, control: Control) {
|
||||
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
handler: async function(args: { foo: string }, ctl: Ctl) {
|
||||
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
console.log(response);
|
||||
console.log(args);
|
||||
},
|
||||
}));
|
||||
|
||||
/* eslint-enable no-console */
|
||||
export class CtlPlugin extends BaseCtlPlugin {
|
||||
async addCommands(rootCommand: CommandTree) {
|
||||
export default async function loadCtlPlugin(context: CtlPluginContext) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
rootCommand.add(pluginCommands);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,36 +1,30 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseHostPlugin } from "@clusterio/host";
|
||||
import type { HostPluginContext } from "@clusterio/host";
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
//%endif
|
||||
|
||||
export class HostPlugin extends BaseHostPlugin {
|
||||
//%if multi_context
|
||||
async init() {
|
||||
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("host::onShutdown");
|
||||
}
|
||||
export default async function loadHostPlugin(context: HostPluginContext) {
|
||||
const { host, logger, plugin } = context;
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
host.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
host.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
|
||||
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`host::hostConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
host.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("host::shutdown");
|
||||
});
|
||||
}
|
||||
@@ -1,36 +1,8 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
import * as Messages from "./messages";
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.ts",
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.ts",
|
||||
});
|
||||
import * as Messages from "./messages.js";
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
//%if config
|
||||
//%if config | multi_context | web
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
//%endif
|
||||
@@ -50,11 +22,27 @@ declare module "@clusterio/lib" {
|
||||
}
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
export interface ControlConfigFields {
|
||||
"__plugin_name__.myControlField": string;
|
||||
export interface CtlConfigFields {
|
||||
"__plugin_name__.myCtlField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if config
|
||||
//%if multi_context | web
|
||||
export interface Permissions {
|
||||
//%endif
|
||||
//%if multi_context
|
||||
"__plugin_name__.example.permission.event": never;
|
||||
"__plugin_name__.example.permission.request": never;
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
"__plugin_name__.example.permission.subscribe": never;
|
||||
//%endif
|
||||
//%if web
|
||||
"__plugin_name__.page.view": never;
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
}
|
||||
//%endif
|
||||
//%if config | multi_context | web
|
||||
}
|
||||
//%endif
|
||||
|
||||
@@ -67,7 +55,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
@@ -83,7 +71,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
|
||||
//%endif
|
||||
//%if host
|
||||
hostEntrypoint: "./dist/node/host",
|
||||
hostEntrypoint: "./dist/node/host.js",
|
||||
//%endif
|
||||
//%if host & config
|
||||
hostConfigFields: {
|
||||
@@ -99,7 +87,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
|
||||
//%endif
|
||||
//%if instance | module // Modules load an empty instance plugin
|
||||
instanceEntrypoint: "./dist/node/instance",
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
//%endif
|
||||
//%if instance & config
|
||||
instanceConfigFields: {
|
||||
@@ -115,12 +103,12 @@ export const plugin: lib.PluginDeclaration = {
|
||||
|
||||
//%endif
|
||||
//%if ctl
|
||||
ctlEntrypoint: "./dist/node/ctl",
|
||||
ctlEntrypoint: "./dist/node/ctl.js",
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
controlConfigFields: {
|
||||
"__plugin_name__.myControlField": {
|
||||
title: "My Control Field",
|
||||
ctlConfigFields: {
|
||||
"__plugin_name__.myCtlField": {
|
||||
title: "My Ctl Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
@@ -139,6 +127,43 @@ export const plugin: lib.PluginDeclaration = {
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
],
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
|
||||
permissions: [
|
||||
//%endif
|
||||
//%if multi_context
|
||||
{
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
{
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if web
|
||||
{
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
],
|
||||
//%endif
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
|
||||
@@ -1,69 +1,64 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
type PuginExampleIPC = {
|
||||
type PluginExampleIPC = {
|
||||
tick: number,
|
||||
player_name: string,
|
||||
};
|
||||
//%endif
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
//%if multi_context | module
|
||||
async init() {
|
||||
//%endif
|
||||
//%if multi_context
|
||||
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if module
|
||||
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
|
||||
//%endif
|
||||
//%if multi_context | module
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
export default async function loadInstancePlugin(context: InstancePluginContext) {
|
||||
const { instance, logger, plugin } = context;
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
instance.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
instance.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
async handlePluginExampleIPC(event: PuginExampleIPC) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event: PluginExampleIPC) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
//%endif
|
||||
|
||||
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`instance::instanceConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
// Called once rcon becomes available
|
||||
logger.info("instance::start");
|
||||
});
|
||||
|
||||
instance.hooks.stop.attach(plugin.name, async () => {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
logger.info("instance::stop");
|
||||
});
|
||||
|
||||
instance.hooks.exit.attach(plugin.name, () => {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
logger.info("instance::exit");
|
||||
});
|
||||
|
||||
instance.hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => {
|
||||
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (instance.status === "running") {
|
||||
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
|
||||
}
|
||||
//%endif
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
// This class is empty because an instance plugin must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
}
|
||||
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
export default async function loadInstancePlugin(context: InstancePluginContext) { }
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"incremental": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.browser.json" },
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "es2023"],
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "es2022",
|
||||
"jsx": "react-jsx",
|
||||
"noEmitOnError": true,
|
||||
"outDir": "${configDir}/dist/browser",
|
||||
},
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||
],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2023"],
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"target": "es2022",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "${configDir}/dist/node"
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import React, {
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
type WebPluginContext,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,17 +21,27 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "../messages";
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages.js";
|
||||
//%endif
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
|
||||
// Created when the plugin is loaded, before any page is rendered
|
||||
let exampleSubscriber: lib.MapSubscriber<ExampleSubscribableUpdate>;
|
||||
|
||||
function useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback: () => void) => exampleSubscriber.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
|
||||
function MyTemplatePage() {
|
||||
const control = useContext(ControlContext);
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
|
||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
||||
const [subscribableData, synced] = useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
@@ -41,53 +52,39 @@ function MyTemplatePage() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
export default async function loadWebPlugin(context: WebPluginContext) {
|
||||
const { control, logger, plugin } = context;
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
||||
|
||||
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
|
||||
//%endif
|
||||
async init() {
|
||||
this.pages = [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
|
||||
control.hooks.pages.attach(plugin.name, () => [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
permission: "__plugin_name__.page.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
];
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
]);
|
||||
//%if multi_context
|
||||
|
||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
}
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
control.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
control.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
});
|
||||
//%endif
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
+31
-10
@@ -17,9 +17,12 @@ Common options:
|
||||
See the `plugin` command for managing this list.
|
||||
|
||||
* `--config <file>` JSON file to use for storing configuration for ctl.
|
||||
Defaults to `config-control.json` and will be created if it does not exist.
|
||||
Defaults to `config-ctl.json` and is required for operation.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
|
||||
Alternative to the `config create` command.
|
||||
|
||||
|
||||
### `plugin <command>`
|
||||
|
||||
@@ -27,17 +30,28 @@ Configure plugins available to be loaded by ctl.
|
||||
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
|
||||
|
||||
|
||||
#### `plugin install <package>`
|
||||
|
||||
Install a plugin from npm and add it to the plugin list.
|
||||
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
|
||||
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npx clusterioctl plugin install @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin add <path>`
|
||||
|
||||
Add plugin either by require path or relative/absolute path to the plugin directory.
|
||||
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
|
||||
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
For example, adding a plugin checked out in the external_plugins directory:
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
npx clusterioctl plugin add ./external_plugins/my_plugin
|
||||
|
||||
|
||||
#### `plugin remove <name>`
|
||||
@@ -59,13 +73,20 @@ Since the `plugin-list.json` is shared between controller, host and ctl you usua
|
||||
Lists the plugins set up to be available by name followed by path.
|
||||
|
||||
|
||||
### `config-control`
|
||||
### `config`
|
||||
|
||||
Manage the ctl configuration.
|
||||
This allows setting the url and token to the controller to manage.
|
||||
|
||||
|
||||
#### `config-control set <config-entry> [value]`
|
||||
#### `config create`
|
||||
|
||||
Create a new config file with the default options inserted into if it doesn't already exist.
|
||||
|
||||
See also the `--create-config` option.
|
||||
|
||||
|
||||
#### `config set <config-entry> [value]`
|
||||
|
||||
Set a config entry to the given value.
|
||||
If value is not provided the entry is set to null.
|
||||
@@ -74,12 +95,12 @@ If the config entry is of type object the value must be a valid JSON serializati
|
||||
See docs/configuration.md in the main repositiory for the available configuration.
|
||||
|
||||
|
||||
#### `config-control show <config-entry>`
|
||||
#### `config show <config-entry>`
|
||||
|
||||
Shows the value for a single config entry.
|
||||
|
||||
|
||||
#### `config-control list`
|
||||
#### `config list`
|
||||
|
||||
Lists up all configuration entries with their currently configured values.
|
||||
|
||||
|
||||
+92
-110
@@ -4,31 +4,30 @@
|
||||
* Command line interface for controlling a Clusterio cluster
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import yargs from "yargs";
|
||||
import fs from "node:fs/promises";
|
||||
import yargs, { type Argv } from "yargs";
|
||||
import path from "path";
|
||||
import { version } from "./package.json";
|
||||
import setBlocking from "set-blocking";
|
||||
import packageConfig from "@clusterio/ctl/package.json" with { type: "json" };
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
(global as any).lazySchemaCompilation = true;
|
||||
import "./src/set_lazy_schema_compliation.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import * as commands from "./src/commands";
|
||||
import BaseCtlPlugin from "./src/BaseCtlPlugin";
|
||||
import * as commands from "./src/commands.js";
|
||||
import { BaseCtlPlugin, CtlHooks } from "./src/BaseCtlPlugin.js";
|
||||
|
||||
|
||||
/**
|
||||
* Connector for control connection to controller
|
||||
* @private
|
||||
*/
|
||||
class ControlConnector extends lib.WebSocketClientConnector {
|
||||
class CtlConnector extends lib.WebSocketClientConnector {
|
||||
private _token: string;
|
||||
|
||||
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
|
||||
super(url, maxReconnectDelay, tlsCa);
|
||||
constructor(url: string, maxReconnectDelay: number, token: string) {
|
||||
super(url, maxReconnectDelay);
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
@@ -38,7 +37,7 @@ class ControlConnector extends lib.WebSocketClientConnector {
|
||||
new lib.MessageRegisterControl(
|
||||
new lib.RegisterControlData(
|
||||
this._token,
|
||||
version,
|
||||
packageConfig.version,
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -46,31 +45,23 @@ class ControlConnector extends lib.WebSocketClientConnector {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
* Handles running ctl
|
||||
*
|
||||
* Connects to the controller over WebSocket and sends commands to it.
|
||||
* @static
|
||||
*/
|
||||
export class Control extends lib.Link {
|
||||
/** Control config used for connecting to the controller. */
|
||||
config: lib.ControlConfig;
|
||||
/** Certificate authority used to validate TLS connections to the controller. */
|
||||
tlsCa?: string;
|
||||
/** Mapping of plugin names to their instance for loaded plugins. */
|
||||
plugins: Map<string, BaseCtlPlugin>;
|
||||
export class Ctl extends lib.Link {
|
||||
/** Ctl config used for connecting to the controller. */
|
||||
config: lib.CtlConfig;
|
||||
/** Keep the control connection alive after the command completes. */
|
||||
keepOpen = false;
|
||||
|
||||
constructor(
|
||||
connector: ControlConnector,
|
||||
controlConfig: lib.ControlConfig,
|
||||
tlsCa: string | undefined,
|
||||
ctlPlugins: Map<string, BaseCtlPlugin>
|
||||
connector: CtlConnector,
|
||||
ctlConfig: lib.CtlConfig,
|
||||
) {
|
||||
super(connector);
|
||||
this.config = controlConfig;
|
||||
this.tlsCa = tlsCa;
|
||||
this.plugins = ctlPlugins;
|
||||
this.config = ctlConfig;
|
||||
|
||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
||||
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
|
||||
@@ -101,7 +92,7 @@ export class Control extends lib.Link {
|
||||
|
||||
async shutdown() {
|
||||
try {
|
||||
await (this.connector as ControlConnector).disconnect();
|
||||
await (this.connector as CtlConnector).disconnect();
|
||||
} catch (err) {
|
||||
if (!(err instanceof lib.SessionLost)) {
|
||||
throw err;
|
||||
@@ -110,27 +101,23 @@ export class Control extends lib.Link {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlugins(pluginList: Map<string, string>) {
|
||||
async function loadPlugins(pluginList: Map<string, string>, hooks: CtlHooks) {
|
||||
let pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
|
||||
let ctlPlugins = new Map<string, BaseCtlPlugin>();
|
||||
const ctlPlugins = new Set<lib.PluginNodeEnvInfo>();
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
if (!pluginInfo.ctlEntrypoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let CtlPlugin = await lib.loadPluginClass(
|
||||
pluginInfo.name,
|
||||
path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint),
|
||||
"CtlPlugin",
|
||||
BaseCtlPlugin,
|
||||
);
|
||||
let ctlPlugin = new CtlPlugin(pluginInfo, logger);
|
||||
ctlPlugins.set(pluginInfo.name, ctlPlugin);
|
||||
await ctlPlugin.init();
|
||||
const context = { hooks, logger, plugin: pluginInfo };
|
||||
await lib.loadPlugin(pluginInfo, "ctl", context, "CtlPlugin", BaseCtlPlugin);
|
||||
ctlPlugins.add(pluginInfo);
|
||||
}
|
||||
|
||||
return ctlPlugins;
|
||||
}
|
||||
|
||||
@@ -141,22 +128,34 @@ interface CtlArguments {
|
||||
logLevel: keyof typeof levels,
|
||||
config: string,
|
||||
pluginList: string,
|
||||
bypassLockFile: boolean,
|
||||
createConfig: boolean,
|
||||
}
|
||||
|
||||
interface InitializeParameters {
|
||||
args: CtlArguments;
|
||||
shouldRun: boolean;
|
||||
ctlPlugins?: Map<string, BaseCtlPlugin>;
|
||||
ctlHooks: CtlHooks;
|
||||
rootCommands?: lib.CommandTree;
|
||||
controlConfig?: lib.ControlConfig;
|
||||
ctlConfig?: lib.CtlConfig;
|
||||
}
|
||||
|
||||
export async function initialize(
|
||||
argv: string | string[],
|
||||
ctlPlugins?: Map<string, BaseCtlPlugin>,
|
||||
ctlHooks: CtlHooks = new CtlHooks(logger),
|
||||
noLoggerTransport?: boolean,
|
||||
): Promise<InitializeParameters> {
|
||||
yargs
|
||||
// Build a fresh, isolated yargs parser each time this function is called.
|
||||
// If the currently loaded yargs object supports .reset() we use that (older versions),
|
||||
// otherwise we fall back to creating a brand-new parser instance by invoking the
|
||||
// yargs factory function directly with an empty argv array. This avoids Mocha’s
|
||||
// own CLI flags (or anything else that touched yargs earlier in the same process)
|
||||
// from leaking into subsequent parses inside the test runner.
|
||||
const parser: Argv = typeof (yargs as any).reset === "function"
|
||||
? (yargs as any).reset()
|
||||
: (yargs as any)([]);
|
||||
|
||||
parser
|
||||
.scriptName("clusterioctl")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
@@ -169,25 +168,32 @@ export async function initialize(
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "config file to get credentails from",
|
||||
default: "config-control.json",
|
||||
default: "config-ctl.json",
|
||||
defaultDescription: "auto",
|
||||
type: "string",
|
||||
})
|
||||
.option("create-config", {
|
||||
nargs: 0,
|
||||
describe: "Create config file if it does not exist",
|
||||
default: false,
|
||||
type: "boolean",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
default: "plugin-list.json",
|
||||
type: "string",
|
||||
})
|
||||
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
|
||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||
.command("control-config", "Manage Control config", lib.configCommand)
|
||||
.wrap(yargs.terminalWidth())
|
||||
.command("config", "Manage ctl config", lib.configCommand)
|
||||
.wrap(parser.terminalWidth())
|
||||
.help(false) // Disable help to avoid triggering it on the first parse.
|
||||
;
|
||||
|
||||
// Parse the args first to get the configured plugin list.
|
||||
// eslint-disable-next-line node/no-sync
|
||||
let args = yargs.parseSync(argv) as CtlArguments;
|
||||
let args = parser.parseSync(argv) as CtlArguments;
|
||||
|
||||
// Log stream for the ctl session. Skipped in testing.
|
||||
if (!noLoggerTransport) {
|
||||
@@ -201,57 +207,47 @@ export async function initialize(
|
||||
lib.handleUnhandledErrors();
|
||||
}
|
||||
|
||||
// Discover and load plugins. This check exists to allow tests to inject plugins.
|
||||
if (!ctlPlugins || args._[0] === "plugin") {
|
||||
// Discover and load plugins. ctlHooks.size check exists to allow tests to inject plugins.
|
||||
if (ctlHooks.addCommands.size === 0 || args._[0] === "plugin") {
|
||||
logger.verbose(`Loading available plugins from ${args.pluginList}`);
|
||||
const pluginList = await lib.loadPluginList(args.pluginList);
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (args._[0] === "plugin") {
|
||||
await lib.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return { args, shouldRun: false };
|
||||
return { args, ctlHooks, shouldRun: false };
|
||||
}
|
||||
|
||||
logger.verbose("Loading Plugins");
|
||||
ctlPlugins = await loadPlugins(pluginList);
|
||||
await loadPlugins(pluginList, ctlHooks);
|
||||
}
|
||||
|
||||
// Add all commands including from plugins and reparse with help and strict checking.
|
||||
const rootCommands = await commands.registerCommands(ctlPlugins, yargs);
|
||||
args = yargs
|
||||
const rootCommands = await commands.registerCommands(ctlHooks, parser);
|
||||
args = parser
|
||||
.help()
|
||||
.strict()
|
||||
.parse(argv) as CtlArguments
|
||||
;
|
||||
|
||||
let controlConfig;
|
||||
const controlConfigPath = args.config;
|
||||
logger.verbose(`Loading config from ${controlConfigPath}`);
|
||||
try {
|
||||
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.verbose("Config not found, initializing new config");
|
||||
controlConfig = new lib.ControlConfig("control", undefined, controlConfigPath);
|
||||
|
||||
} else {
|
||||
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
|
||||
}
|
||||
}
|
||||
const [ctlConfig, ctlConfigLock] = await lib.loadConfigFromArgs(
|
||||
args,
|
||||
lib.CtlConfig,
|
||||
"control",
|
||||
);
|
||||
|
||||
if (args._.length === 0) {
|
||||
yargs.showHelp();
|
||||
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
parser.showHelp();
|
||||
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
}
|
||||
|
||||
// Handle the control-config command before trying to connect.
|
||||
if (args._[0] === "control-config") {
|
||||
await lib.handleConfigCommand(args, controlConfig);
|
||||
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
|
||||
// Handle the config command before trying to connect.
|
||||
if (args._[0] === "config") {
|
||||
await lib.handleConfigCommand(args, ctlConfig, ctlConfigLock);
|
||||
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: false };
|
||||
}
|
||||
|
||||
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
|
||||
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: true };
|
||||
}
|
||||
|
||||
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
|
||||
@@ -264,38 +260,30 @@ export function selectTargetCommand(args: CtlArguments, rootCommands: lib.Comman
|
||||
return targetCommand;
|
||||
}
|
||||
|
||||
async function startControl() {
|
||||
async function startCtl() {
|
||||
const {
|
||||
args,
|
||||
shouldRun,
|
||||
ctlPlugins,
|
||||
rootCommands,
|
||||
controlConfig,
|
||||
ctlConfig,
|
||||
} = await initialize(process.argv.slice(2));
|
||||
if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
|
||||
if (!shouldRun || !rootCommands || !ctlConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
|
||||
if (!ctlConfig.get("ctl.controller_url") || !ctlConfig.get("ctl.controller_token")) {
|
||||
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let tlsCa: string | undefined;
|
||||
let tlsCaPath = controlConfig.get("control.tls_ca");
|
||||
if (tlsCaPath) {
|
||||
tlsCa = await fs.readFile(tlsCaPath, "utf8");
|
||||
}
|
||||
|
||||
let controlConnector = new ControlConnector(
|
||||
controlConfig.get("control.controller_url")!,
|
||||
controlConfig.get("control.max_reconnect_delay"),
|
||||
tlsCa,
|
||||
controlConfig.get("control.controller_token")!,
|
||||
let controlConnector = new CtlConnector(
|
||||
ctlConfig.get("ctl.controller_url")!,
|
||||
ctlConfig.get("ctl.max_reconnect_delay"),
|
||||
ctlConfig.get("ctl.controller_token")!,
|
||||
);
|
||||
|
||||
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
|
||||
let ctl = new Ctl(controlConnector, ctlConfig);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
} catch (err) {
|
||||
@@ -305,22 +293,16 @@ async function startControl() {
|
||||
throw err;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Caught interrupt signal, closing connection");
|
||||
control.shutdown().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.error(err.stack);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", ctl.shutdown.bind(ctl)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", ctl.shutdown.bind(ctl)));
|
||||
|
||||
try {
|
||||
const targetCommand = selectTargetCommand(args, rootCommands);
|
||||
await targetCommand.run(args, control);
|
||||
await targetCommand.run(args, ctl);
|
||||
|
||||
} catch (err) {
|
||||
control.keepOpen = false;
|
||||
ctl.keepOpen = false;
|
||||
if (err instanceof lib.CommandError) {
|
||||
logger.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
@@ -338,8 +320,8 @@ async function startControl() {
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (!control.keepOpen) {
|
||||
await control.shutdown();
|
||||
if (!ctl.keepOpen) {
|
||||
await ctl.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,16 +335,16 @@ I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startControl().catch(err => {
|
||||
startCtl().catch(err => {
|
||||
if (err.errors) {
|
||||
logger.fatal(JSON.stringify(err.errors, null, "\t"));
|
||||
}
|
||||
if (!(err instanceof lib.StartupError)) {
|
||||
logger.fatal(`
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured while starting control, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
+---------------------------------------------------------------+
|
||||
| Unexpected error occured while starting clussterioctl, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+---------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
} else {
|
||||
@@ -378,6 +360,6 @@ ${err.stack}`
|
||||
});
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { bootstrap } from "./ctl";
|
||||
export * from "./ctl";
|
||||
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
|
||||
import { bootstrap } from "./ctl.js";
|
||||
export * from "./ctl.js";
|
||||
export { BaseCtlPlugin, CtlHooks, type CtlPluginContext } from "./src/BaseCtlPlugin.js";
|
||||
|
||||
if (module === require.main) {
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
}
|
||||
+29
-21
@@ -1,9 +1,28 @@
|
||||
{
|
||||
"name": "@clusterio/ctl",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clusterio/clusterio"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
"type": "module",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -12,30 +31,19 @@
|
||||
"bin": {
|
||||
"clusterioctl": "./dist/node/ctl.js"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"as-table": "^1.0.55",
|
||||
"fs-extra": "^11.3.0",
|
||||
"phin": "^3.7.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.17.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"winston": "catalog:",
|
||||
"yargs": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "^5.7.3"
|
||||
"@types/node": "catalog:",
|
||||
"@types/yargs": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,18 @@
|
||||
import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export type CtlPluginContext = lib.PluginLoadContext<{
|
||||
hooks: CtlHooks
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Base class for clusterioctl plugins
|
||||
*
|
||||
* Ctl plugins are subclasses of this class which get instantiated by
|
||||
* clusterioctl in order to extend its functionallity. To be discovered the
|
||||
* class must be exported under the name `CtlPlugin` in the module
|
||||
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
|
||||
* Collection of clusterioctl plugin hooks
|
||||
*/
|
||||
export default class BaseCtlPlugin {
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
logger: Logger;
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The plugin's own info module
|
||||
*/
|
||||
public info: PluginNodeEnvInfo,
|
||||
logger: Logger,
|
||||
) {
|
||||
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
|
||||
export class CtlHooks extends lib.AsyncHookCollection {
|
||||
constructor(logger: lib.Logger) {
|
||||
super(logger);
|
||||
this.addCommands = this.newHook();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() { }
|
||||
|
||||
/**
|
||||
* Called to add commands to the command line interface.
|
||||
*
|
||||
@@ -43,5 +23,47 @@ export default class BaseCtlPlugin {
|
||||
* @param rootCommand -
|
||||
* Root of the clusterioctl command tree.
|
||||
*/
|
||||
async addCommands(rootCommand: CommandTree) { }
|
||||
readonly addCommands: lib.AsyncHook<[rootCommand: lib.CommandTree]>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface BaseCtlPlugin {
|
||||
addCommands?(rootCommand: lib.CommandTree): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for clusterioctl plugins
|
||||
*
|
||||
* Ctl plugins are subclasses of this class which get instantiated by
|
||||
* clusterioctl in order to extend its functionallity. To be discovered the
|
||||
* class must be exported under the name `CtlPlugin` in the module
|
||||
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class BaseCtlPlugin {
|
||||
constructor(
|
||||
/**
|
||||
* The plugin's own info module
|
||||
*/
|
||||
public info: lib.PluginNodeEnvInfo,
|
||||
public logger: lib.Logger,
|
||||
private hooks: CtlHooks,
|
||||
) {
|
||||
if (this.addCommands) {
|
||||
hooks.addCommands.attach(info.name, this.addCommands.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
static fromContext(context: CtlPluginContext) {
|
||||
return new this(context.plugin, context.logger, context.hooks);
|
||||
}
|
||||
|
||||
detachHooks() {
|
||||
this.hooks.detachAll(this.info.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function print(...content: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...content);
|
||||
}
|
||||
+14
-2151
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,210 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import os from "os";
|
||||
import child_process from "child_process";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" });
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["stop", "Stop controller"],
|
||||
handler: async function(_args: object, ctl: Ctl) {
|
||||
await ctl.send(new lib.ControllerStopRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["restart", "Restart controller"],
|
||||
handler: async function(_args: object, ctl: Ctl) {
|
||||
await ctl.send(new lib.ControllerRestartRequest());
|
||||
},
|
||||
}));
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["mark-restart-required", "Mark the controller as requiring a restart"],
|
||||
handler: async function(_args: object, ctl: Ctl) {
|
||||
await ctl.send(new lib.ControllerMarkRestartRequiredRequest());
|
||||
},
|
||||
}));
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["update", "Update the controller", (yargs) => {
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
|
||||
}],
|
||||
handler: async function(args: { restart: boolean, all: boolean }, ctl: Ctl) {
|
||||
if (args.all) {
|
||||
await ctl.send(new lib.UpdateAllRequest());
|
||||
} else {
|
||||
await ctl.send(new lib.ControllerUpdateRequest());
|
||||
}
|
||||
if (args.restart) {
|
||||
await ctl.send(new lib.ControllerRestartRequest());
|
||||
} else {
|
||||
print("Controller updated; a restart is required to apply the changes.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const controllerConfigCommands = new lib.CommandTree({
|
||||
name: "config", alias: ["c"], description: "controller config management",
|
||||
});
|
||||
|
||||
controllerConfigCommands.add(new lib.Command({
|
||||
definition: ["list", "List controller configuration"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let config = await ctl.send(new lib.ControllerConfigGetRequest());
|
||||
|
||||
for (let [name, value] of Object.entries(config)) {
|
||||
print(`${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
controllerConfigCommands.add(new lib.Command({
|
||||
definition: ["set <field> [value]", "Set field in controller config", (yargs) => {
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("value", { describe: "Value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { field: string, value?: string, stdin?: boolean }, ctl: Ctl) {
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
} else if (args.value === undefined) {
|
||||
args.value = "";
|
||||
}
|
||||
await ctl.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
|
||||
},
|
||||
}));
|
||||
|
||||
controllerConfigCommands.add(new lib.Command({
|
||||
definition: ["set-prop <field> <prop> [value]", "Set property of field in controller config", (yargs) => {
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("prop", { describe: "Property to set", type: "string" });
|
||||
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, ctl: Ctl) {
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
if (args.value !== undefined) {
|
||||
value = JSON.parse(args.value);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// See note for the instance version of set-prop
|
||||
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
|
||||
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
|
||||
}
|
||||
value = args.value;
|
||||
}
|
||||
await ctl.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
|
||||
},
|
||||
}));
|
||||
controllerConfigCommands.add(new lib.Command({
|
||||
definition: ["edit [editor]", "Edit controller configuration", (yargs) => {
|
||||
yargs.positional("editor", {
|
||||
describe: "Editor to use",
|
||||
type: "string",
|
||||
default: "",
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { editor: string }, ctl: Ctl) {
|
||||
let config = await ctl.send(new lib.ControllerConfigGetRequest());
|
||||
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === undefined) {
|
||||
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl controller config edit <editor of choice>"`);
|
||||
}
|
||||
let allConfigElements = serializedConfigToString(config, lib.ControllerConfig);
|
||||
await fs.writeFile(tmpFile, allConfigElements);
|
||||
let editorSpawn = child_process.spawn(editor, [tmpFile], {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
});
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
await events.once(editorSpawn, "exit");
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await ctl.send(new lib.ControllerConfigSetFieldRequest(
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
print(err);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
print("err: temporary file", tmpFile, "could not be deleted.");
|
||||
print("This is not fatal, but they may build up over time if the issue persists.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
controllerCommands.add(controllerConfigCommands);
|
||||
|
||||
|
||||
const controllerPluginCommands = new lib.CommandTree({
|
||||
name: "plugin", alias: ["p"], description: "controller plugin inspection",
|
||||
});
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["list", "List plugins on controller"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += "api/plugins";
|
||||
let response = await fetch(url);
|
||||
print(asTable(await response.json() as []));
|
||||
},
|
||||
}));
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
|
||||
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
await ctl.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
|
||||
if (args.restart) {
|
||||
await ctl.send(new lib.ControllerRestartRequest());
|
||||
} else {
|
||||
print("Plugin updated; a restart is required to apply the changes.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
|
||||
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
await ctl.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
|
||||
if (args.restart) {
|
||||
await ctl.send(new lib.ControllerRestartRequest());
|
||||
} else {
|
||||
print("Plugin installed; a restart is required to apply the changes.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
controllerCommands.add(controllerPluginCommands);
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
|
||||
export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" });
|
||||
debugCommands.add(new lib.Command({
|
||||
definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
await ctl.send(new lib.DebugDumpWsRequest());
|
||||
ctl.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
debugCommands.add(new lib.Command({
|
||||
definition: [
|
||||
"parse-exchange-string <exchange-string>",
|
||||
"Parse and print the contents of an exchange string",
|
||||
(yargs) => {
|
||||
yargs.positional("exchange-string", { describe: "String to parse", type: "string" });
|
||||
yargs.option("pretty", { alias: "p", type: "boolean", description: "Pretty output" });
|
||||
}],
|
||||
handler: async function(args: { exchangeString: string, pretty: boolean }, ctl: Ctl) {
|
||||
const result = lib.readMapExchangeString(args.exchangeString);
|
||||
if (args.pretty) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,310 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import os from "os";
|
||||
import child_process from "child_process";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const hostCommands = new lib.CommandTree({ name: "host", description: "Host management" });
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["stop <host>", "Stop the given host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to stop", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.HostStopRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["restart <host>", "Restart the given host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to restart", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["mark-restart-required <host>", "Mark the given host as requiring a restart", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to mark", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.HostMarkRestartRequiredRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List hosts connected to the controller"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let hosts = await ctl.send(new lib.HostListRequest());
|
||||
print(asTable(hosts));
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["generate-token", "Generate token for a host", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
|
||||
}],
|
||||
handler: async function(args: { id?: number }, ctl: Ctl) {
|
||||
let hostId = typeof args.id === "number" ? args.id : undefined;
|
||||
let response = await ctl.send(new lib.HostGenerateTokenRequest(hostId));
|
||||
print(response);
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.send(new lib.HostRevokeTokensRequest(hostId));
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["create-config", "Create host config", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
|
||||
yargs.option("name", { type: "string", nargs: 1, describe: "Host name" });
|
||||
yargs.option("generate-token", {
|
||||
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
|
||||
});
|
||||
yargs.option("output", {
|
||||
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-host.json",
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: { id?: number, name?: string, generateToken: boolean, output: string },
|
||||
ctl: Ctl
|
||||
) {
|
||||
let config = await ctl.send(
|
||||
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
|
||||
);
|
||||
|
||||
let content = JSON.stringify(config, null, "\t");
|
||||
if (args.output === "-") {
|
||||
print(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
try {
|
||||
await fs.writeFile(args.output, content, { flag: "wx", mode: 0o600 });
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
throw new lib.CommandError(`File ${args.output} already exists`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["update <host>", "Update the host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to update", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
|
||||
}],
|
||||
handler: async function(args: { host: string, restart: boolean, all: boolean }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
if (args.all) {
|
||||
await ctl.sendTo({ hostId }, new lib.UpdateAllRequest());
|
||||
} else {
|
||||
await ctl.sendTo({ hostId }, new lib.HostUpdateRequest());
|
||||
}
|
||||
if (args.restart) {
|
||||
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
|
||||
} else {
|
||||
print("Host updated; a restart is required to apply the changes.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const hostConfigCommands = new lib.CommandTree({
|
||||
name: "config", alias: ["c"], description: "Host config management",
|
||||
});
|
||||
hostConfigCommands.add(new lib.Command({
|
||||
definition: ["list <host>", "List configuration for a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to list config for", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
let config = await ctl.sendTo({ hostId }, new lib.HostConfigGetRequest());
|
||||
|
||||
for (let [name, value] of Object.entries(config)) {
|
||||
print(`${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
hostConfigCommands.add(new lib.Command({
|
||||
definition: ["set <host> <field> [value]", "Set field in host config", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to set config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("value", { describe: "Value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: { host: string, field: string, value?: string, stdin?: boolean },
|
||||
ctl: Ctl,
|
||||
) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
} else if (args.value === undefined) {
|
||||
args.value = "";
|
||||
}
|
||||
await ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
|
||||
},
|
||||
}));
|
||||
|
||||
hostConfigCommands.add(new lib.Command({
|
||||
definition: ["set-prop <host> <field> <prop> [value]", "Set property of field in host config", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to set config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("prop", { describe: "Property to set", type: "string" });
|
||||
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
|
||||
ctl: Ctl
|
||||
) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
if (args.value !== undefined) {
|
||||
value = JSON.parse(args.value);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// See note for the instance version of set-prop
|
||||
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
|
||||
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
|
||||
}
|
||||
value = args.value;
|
||||
}
|
||||
await ctl.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
|
||||
},
|
||||
}));
|
||||
|
||||
hostConfigCommands.add(new lib.Command({
|
||||
definition: ["edit <host> [editor]", "Edit host configuration", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to set config on", type: "string" });
|
||||
yargs.positional("editor", {
|
||||
describe: "Editor to use",
|
||||
type: "string",
|
||||
default: "",
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { host: string, editor: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
let config = await ctl.sendTo({ hostId }, new lib.HostConfigGetRequest());
|
||||
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === undefined) {
|
||||
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl host config edit <editor of choice>"`);
|
||||
}
|
||||
let allConfigElements = serializedConfigToString(config, lib.HostConfig);
|
||||
await fs.writeFile(tmpFile, allConfigElements);
|
||||
let editorSpawn = child_process.spawn(editor, [tmpFile], {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
});
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
await events.once(editorSpawn, "exit");
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
print(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
print("err: temporary file", tmpFile, "could not be deleted.");
|
||||
print("This is not fatal, but they may build up over time if the issue persists.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(hostConfigCommands);
|
||||
|
||||
|
||||
const hostPluginCommands = new lib.CommandTree({
|
||||
name: "plugin", alias: ["p"], description: "host plugin inspection",
|
||||
});
|
||||
hostPluginCommands.add(new lib.Command({
|
||||
definition: ["list <host>", "List plugins on a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to list for", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
const plugins = await ctl.sendTo({ hostId }, new lib.PluginListRequest());
|
||||
print(asTable(plugins.map(p => ({
|
||||
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
|
||||
}))));
|
||||
},
|
||||
}));
|
||||
hostPluginCommands.add(new lib.Command({
|
||||
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to update on", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
|
||||
if (args.restart) {
|
||||
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
|
||||
} else {
|
||||
print("Plugin updated; a restart is required to apply the changes.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
hostPluginCommands.add(new lib.Command({
|
||||
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to install on", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
|
||||
if (args.restart) {
|
||||
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
|
||||
} else {
|
||||
print("Plugin installed; a restart is required to apply the changes.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
hostCommands.add(hostPluginCommands);
|
||||
@@ -0,0 +1,661 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import child_process from "child_process";
|
||||
import stream from "stream";
|
||||
import util from "util";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
export const instanceCommands = new lib.CommandTree({
|
||||
name: "instance", alias: ["i"], description: "Instance management",
|
||||
});
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List instances known to the controller"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let list = await ctl.send(new lib.InstanceDetailsListRequest());
|
||||
print(asTable(list));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["create <name>", "Create an instance", (yargs) => {
|
||||
// XXX TODO: set any specific options?
|
||||
yargs.positional("name", { describe: "Instance name", type: "string" });
|
||||
yargs.options({
|
||||
"id": { type: "number", nargs: 1, describe: "Instance id" },
|
||||
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { name: string, id?: number, from?: number, }, ctl: Ctl) {
|
||||
let instanceConfig = new lib.InstanceConfig("control");
|
||||
if (args.id !== undefined) {
|
||||
instanceConfig.set("instance.id", args.id);
|
||||
}
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
await ctl.send(new lib.InstanceCreateRequest(
|
||||
instanceConfig.toRemote("controller", [
|
||||
"instance.id", "instance.name",
|
||||
]),
|
||||
args.from
|
||||
));
|
||||
},
|
||||
}));
|
||||
|
||||
const instanceConfigCommands = new lib.CommandTree({
|
||||
name: "config", alias: ["c"], description: "Instance config management",
|
||||
});
|
||||
instanceConfigCommands.add(new lib.Command({
|
||||
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
|
||||
|
||||
for (let [name, value] of Object.entries(config)) {
|
||||
print(`${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new lib.Command({
|
||||
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("value", { describe: "Value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: { instance: string, field: string, value?: string, stdin?: boolean },
|
||||
ctl: Ctl,
|
||||
) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
} else if (args.value === undefined) {
|
||||
args.value = "";
|
||||
}
|
||||
await ctl.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new lib.Command({
|
||||
definition: ["set-prop <instance> <field> <prop> [value]", "Set property of field in instance config", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to set", type: "string" });
|
||||
yargs.positional("prop", { describe: "Property to set", type: "string" });
|
||||
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
|
||||
yargs.options({
|
||||
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: { instance: string, field: string, prop: string, value?: string, stdin?: boolean },
|
||||
ctl: Ctl
|
||||
) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
if (args.value !== undefined) {
|
||||
value = JSON.parse(args.value);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// If this is from stdin or looks like an array, object or string
|
||||
// literal throw the parse error, otherwise assume this is a string.
|
||||
// The resoning behind this is that correctly quoting the string
|
||||
// with the all the layers of quote removal at play is difficult.
|
||||
// See the following table for how to pass "That's a \" quote" in
|
||||
// different environments:
|
||||
// ----------------------- WARNING -------------------------------
|
||||
// This table is most likely invalid due a change in yargs parsing
|
||||
// ---------------------------------------------------------------
|
||||
// cmd : """""That's a \\"" quote"""""
|
||||
// cmd + npx : """""""""""That's a \\\\"""" quote"""""""""""
|
||||
// PowerShell : '"""""That''s a \\"" quote"""""'
|
||||
// PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""'
|
||||
// bash : '""That'\''s a \" quote""'
|
||||
// bash + npx : '""That'\''s a \" quote""'
|
||||
// bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'"
|
||||
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
|
||||
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
|
||||
}
|
||||
value = args.value;
|
||||
}
|
||||
await ctl.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new lib.Command({
|
||||
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
yargs.positional("editor", {
|
||||
describe: "Editor to use",
|
||||
type: "string",
|
||||
default: "",
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { instance: string, editor: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
|
||||
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === undefined) {
|
||||
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl instance config edit <editor of choice>"`);
|
||||
}
|
||||
let allConfigElements = serializedConfigToString(config, lib.InstanceConfig);
|
||||
await fs.writeFile(tmpFile, allConfigElements);
|
||||
let editorSpawn = child_process.spawn(editor, [tmpFile], {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
});
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
await events.once(editorSpawn, "exit");
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await ctl.send(new lib.InstanceConfigSetFieldRequest(
|
||||
instanceId,
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
print(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
print("err: temporary file", tmpFile, "could not be deleted.");
|
||||
print("This is not fatal, but they may build up over time if the issue persists.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(instanceConfigCommands);
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["assign <instance> [host]", "Assign instance to a host", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
|
||||
yargs.positional("host", { describe: "Host to assign to or unassign if none", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string, host?: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let hostId = args.host ? await lib.resolveHost(ctl, args.host) : undefined;
|
||||
await ctl.send(new lib.InstanceAssignRequest(instanceId, hostId));
|
||||
},
|
||||
}));
|
||||
|
||||
async function loadMapSettings(
|
||||
args: { seed?: number, mapExchangeString?: string, mapGenSettings?: string, mapSettings?: string},
|
||||
) {
|
||||
let seed = args.seed;
|
||||
let mapGenSettings;
|
||||
let mapSettings;
|
||||
if (args.mapExchangeString) {
|
||||
let parsed = lib.readMapExchangeString(args.mapExchangeString);
|
||||
mapGenSettings = parsed.map_gen_settings;
|
||||
mapSettings = parsed.map_settings;
|
||||
}
|
||||
if (args.mapGenSettings) {
|
||||
mapGenSettings = JSON.parse((await fs.readFile(args.mapGenSettings)).toString());
|
||||
}
|
||||
if (args.mapSettings) {
|
||||
mapSettings = JSON.parse((await fs.readFile(args.mapSettings)).toString());
|
||||
}
|
||||
|
||||
return {
|
||||
seed,
|
||||
mapGenSettings,
|
||||
mapSettings,
|
||||
};
|
||||
}
|
||||
|
||||
const instanceSaveCommands = new lib.CommandTree({
|
||||
name: "save", alias: ["s"], description: "Instance save management",
|
||||
});
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: ["list <instance>", "list saves on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let saves = await ctl.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
|
||||
saves = saves.filter(save => save.instanceId === instanceId);
|
||||
print(asTable(saves.map(
|
||||
({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() })
|
||||
)));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: ["create <instance> [name]", "Create a new save on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
|
||||
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
|
||||
yargs.options({
|
||||
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
|
||||
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
|
||||
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
|
||||
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: {
|
||||
instance: string,
|
||||
name: string,
|
||||
seed?: number,
|
||||
mapExchangeString?: string,
|
||||
mapGenSettings?: string,
|
||||
mapSettings?: string,
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: ["rename <instance> <old-name> <new-name>", "Rename a save on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to rename save on", type: "string" });
|
||||
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
|
||||
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string, oldName: string, newName: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: ["copy <instance> <source> <destination>", "Copy a save on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to copy save on", type: "string" });
|
||||
yargs.positional("source", { describe: "Save to copy.", type: "string" });
|
||||
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string, source: string, destination: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: ["upload <instance> <filepath>", "Upload a save to an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
|
||||
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
|
||||
yargs.options({
|
||||
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { instance: string, filepath: string, name?: string }, ctl: Ctl) {
|
||||
let filename = args.name || path.basename(args.filepath);
|
||||
if (!filename.endsWith(".zip")) {
|
||||
throw new lib.CommandError("Save name must end with .zip");
|
||||
}
|
||||
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += "api/upload-save";
|
||||
url.searchParams.append("instance_id", String(instanceId));
|
||||
url.searchParams.append("filename", filename);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: (await fs.open(args.filepath)).createReadStream(),
|
||||
duplex: "half",
|
||||
headers: {
|
||||
"X-Access-Token": ctl.config.get("ctl.controller_token")!,
|
||||
"Content-Type": "application/zip",
|
||||
},
|
||||
});
|
||||
|
||||
type ResponseType = { errors?: string[], request_errors?: string[], saves?: string[] };
|
||||
const result = await response.json() as ResponseType;
|
||||
|
||||
for (let error of result.errors || []) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
for (let requestError of result.request_errors || []) {
|
||||
logger.error(requestError);
|
||||
}
|
||||
|
||||
if (result.saves && result.saves.length) {
|
||||
logger.info(`Successfully uploaded as ${result.saves[0]}`);
|
||||
}
|
||||
|
||||
if ((result.errors || []).length || (result.request_errors || []).length) {
|
||||
throw new lib.CommandError("Uploading save failed");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: [
|
||||
"transfer <source-instance> <source-save> <target-instance> [target-save]",
|
||||
"Transfer a save between instances",
|
||||
(yargs) => {
|
||||
yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" });
|
||||
yargs.positional("source-save", { describe: "Save to transfer.", type: "string" });
|
||||
yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" });
|
||||
yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" });
|
||||
yargs.options({
|
||||
"copy": { describe: "Copy instead of moving the save", type: "boolean", default: false },
|
||||
});
|
||||
},
|
||||
],
|
||||
handler: async function(
|
||||
args: {
|
||||
sourceInstance: string,
|
||||
sourceSave: string,
|
||||
targetInstance: string,
|
||||
targetSave: string,
|
||||
copy: boolean,
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
let sourceInstanceId = await lib.resolveInstance(ctl, args.sourceInstance);
|
||||
let targetInstanceId = await lib.resolveInstance(ctl, args.targetInstance);
|
||||
let storedName = await ctl.send(
|
||||
new lib.InstanceTransferSaveRequest(
|
||||
sourceInstanceId,
|
||||
args.sourceSave,
|
||||
targetInstanceId,
|
||||
args.targetSave || args.sourceSave,
|
||||
args.copy
|
||||
)
|
||||
);
|
||||
print(`Transferred as ${storedName} to ${args.targetInstance}.`);
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: ["download <instance> <save>", "Download a save from an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
|
||||
yargs.positional("save", { describe: "Save to download", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string, save: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let streamId = await ctl.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
|
||||
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
const filename = await lib.downloadFile(url, args.save, "rename");
|
||||
|
||||
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: ["delete <instance> <save>", "Delete a save from an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
|
||||
yargs.positional("save", { describe: "Save to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string, save: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save));
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(instanceSaveCommands);
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["save-game <instance>", "Save the running game of an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to save the game on", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceSaveGameRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["extract-players <instance>", "Extract players from running save into the cluster.", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["start <instance>", "Start instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to start", type: "string" });
|
||||
yargs.options({
|
||||
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, ctl: Ctl) {
|
||||
const instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
|
||||
ctl.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["restart <instance>", "Restart instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to restart", type: "string" });
|
||||
yargs.options({
|
||||
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, ctl: Ctl) {
|
||||
const instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
|
||||
ctl.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => {
|
||||
yargs.options({
|
||||
"force": {
|
||||
describe: "Start instances even if they have exclude_from_start_all set",
|
||||
nargs: 0,
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { force: boolean }, ctl: Ctl) {
|
||||
// Get all instances
|
||||
const instances = await ctl.send(new lib.InstanceDetailsListRequest());
|
||||
|
||||
// Filter instances that should be started
|
||||
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
|
||||
// Only start instances that are currently stopped
|
||||
if (instance.status !== "stopped") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Respect exclude_from_start_all setting unless --force is used
|
||||
if (!args.force && instance.excludeFromStartAll) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (instancesToStart.length === 0) {
|
||||
print("No instances to start.");
|
||||
return;
|
||||
}
|
||||
|
||||
print(`Starting ${instancesToStart.length} instance(s):`);
|
||||
for (const instance of instancesToStart) {
|
||||
print(` - ${instance.name} (ID: ${instance.id})`);
|
||||
}
|
||||
|
||||
// Start all filtered instances
|
||||
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
|
||||
try {
|
||||
await ctl.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
|
||||
print(`✓ Started ${instance.name}`);
|
||||
} catch (error) {
|
||||
print(`✗ Failed to start ${instance.name}: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(startPromises);
|
||||
print("Start all operation completed.");
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to start", type: "string" });
|
||||
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
|
||||
yargs.options({
|
||||
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
|
||||
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
|
||||
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
|
||||
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: {
|
||||
instance: string,
|
||||
scenario: string,
|
||||
seed?: number,
|
||||
mapExchangeString?: string,
|
||||
mapGenSettings?: string,
|
||||
mapSettings?: string,
|
||||
keepOpen: boolean,
|
||||
},
|
||||
ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
|
||||
await ctl.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings),
|
||||
);
|
||||
ctl.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["stop <instance>", "Stop instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceStopRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["stop-all", "Stop all running instances", () => {}],
|
||||
handler: async function(_args, ctl: Ctl) {
|
||||
// Get all instances
|
||||
const instances = await ctl.send(new lib.InstanceDetailsListRequest());
|
||||
|
||||
// Filter instances that should be stopped
|
||||
const instancesToStop = instances
|
||||
.filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status));
|
||||
|
||||
if (instancesToStop.length === 0) {
|
||||
print("No instances to stop.");
|
||||
return;
|
||||
}
|
||||
|
||||
print(`Stopping ${instancesToStop.length} instance(s):`);
|
||||
for (const instance of instancesToStop) {
|
||||
print(` - ${instance.name} (ID: ${instance.id})`);
|
||||
}
|
||||
|
||||
// Stop all filtered instances
|
||||
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
|
||||
try {
|
||||
await ctl.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
|
||||
print(`✓ Stopped ${instance.name}`);
|
||||
} catch (error) {
|
||||
print(`✗ Failed to stop ${instance.name}: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(stopPromises);
|
||||
print("Stop all operation completed.");
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["kill <instance>", "Kill instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceKillRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["delete <instance>", "Delete instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.send(new lib.InstanceDeleteRequest(instanceId));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["send-rcon <instance> <command>", "Send RCON command", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
|
||||
yargs.positional("command", { describe: "command to send", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string, command: string }, ctl: Ctl) {
|
||||
let result = await ctl.sendTo(
|
||||
{ instanceId: await lib.resolveInstance(ctl, args.instance) },
|
||||
new lib.InstanceSendRconRequest(args.command),
|
||||
);
|
||||
|
||||
// Factorio includes a newline in its response output.
|
||||
process.stdout.write(result);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,90 @@
|
||||
import winston from "winston";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
|
||||
export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
|
||||
logCommands.add(new lib.Command({
|
||||
definition: ["follow", "follow cluster log", (yargs) => {
|
||||
yargs.options({
|
||||
"all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false },
|
||||
"controller": { describe: "Follow log of the controller", nargs: 0, type: "boolean", default: false },
|
||||
"host": { describe: "Follow log of given host", nargs: 1, type: "string", default: null },
|
||||
"instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: { all: boolean, controller: boolean, host: string | null, instance: string | null },
|
||||
ctl: Ctl
|
||||
) {
|
||||
if (!args.all && !args.controller && !args.host && !args.instance) {
|
||||
logger.error("At least one of --all, --controller, --host and --instance must be passed");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let instanceIds = args.instance ? [await lib.resolveInstance(ctl, args.instance)] : [];
|
||||
let hostIds = args.host ? [await lib.resolveHost(ctl, args.host)] : [];
|
||||
await ctl.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
|
||||
ctl.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
logCommands.add(new lib.Command({
|
||||
definition: ["query", "Query cluster log", (yargs) => {
|
||||
yargs.options({
|
||||
"all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false },
|
||||
"controller": { describe: "Query log of the controller", nargs: 0, type: "boolean", default: false },
|
||||
"host": { describe: "Query log of given host", nargs: 1, type: "string", default: null },
|
||||
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
|
||||
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: undefined },
|
||||
"limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 },
|
||||
"start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: {
|
||||
all: boolean,
|
||||
controller: boolean,
|
||||
host: string | null,
|
||||
instance: string | null,
|
||||
maxLevel?: string,
|
||||
limit: number,
|
||||
start: boolean,
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
if (!args.all && !args.controller && !args.host && !args.instance) {
|
||||
logger.error("At least one of --all, --controller, --host and --instance must be passed");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let instanceIds = args.instance ? [await lib.resolveInstance(ctl, args.instance)] : [];
|
||||
let hostIds = args.host ? [await lib.resolveHost(ctl, args.host)] : [];
|
||||
let result = await ctl.send(new lib.LogQueryRequest(
|
||||
args.all,
|
||||
args.controller,
|
||||
hostIds,
|
||||
instanceIds,
|
||||
args.maxLevel as keyof typeof levels,
|
||||
args.limit,
|
||||
args.start ? "asc" : "desc",
|
||||
));
|
||||
|
||||
if (!args.start) {
|
||||
result.log.reverse();
|
||||
}
|
||||
|
||||
let stdoutLogger = winston.createLogger({
|
||||
level: "verbose",
|
||||
levels,
|
||||
format: new lib.TerminalFormat({ showTimestamp: true }),
|
||||
transports: [
|
||||
new ConsoleTransport({ errorLevels: [], warnLevels: [] }),
|
||||
],
|
||||
});
|
||||
for (let info of result.log) {
|
||||
stdoutLogger.log(info as any);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,219 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import path from "path";
|
||||
import stream from "stream";
|
||||
import util from "util";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
export const modCommands = new lib.CommandTree({ name: "mod", description: "Manage uploaded mods" });
|
||||
modCommands.add(new lib.Command({
|
||||
definition: ["show <name> <mod-version>", "Show details for a mod stored in the cluster", (yargs) => {
|
||||
yargs.positional("name", { describe: "Mod name to show details for", type: "string" });
|
||||
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let modInfo = await ctl.send(new lib.ModGetRequest(args.name, args.modVersion));
|
||||
for (let [field, value] of Object.entries(modInfo)) {
|
||||
if (value instanceof Array) {
|
||||
print(`${field}:`);
|
||||
for (let entry of value) {
|
||||
print(` ${entry}`);
|
||||
}
|
||||
} else {
|
||||
print(`${field}: ${value}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
modCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List mods stored in the cluster", (yargs) => {
|
||||
yargs.options({
|
||||
"fields": {
|
||||
describe: "Fields to show, supports 'all'.",
|
||||
array: true,
|
||||
type: "string",
|
||||
default: ["name", "version", "title", "factorio_version"],
|
||||
},
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { fields: string[] }, ctl: Ctl) {
|
||||
let mods = await ctl.send(new lib.ModListRequest());
|
||||
if (!args.fields.includes("all")) {
|
||||
for (let entry of mods) {
|
||||
for (let field of Object.keys(entry)) {
|
||||
if (!args.fields.includes(field)) {
|
||||
// @ts-expect-error terrible hack
|
||||
delete entry[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
print(asTable(mods));
|
||||
},
|
||||
}));
|
||||
|
||||
modCommands.add(new lib.Command({
|
||||
definition: ["search <factorio-version> [query]", "Search mods stored in the cluster", (yargs) => {
|
||||
yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" });
|
||||
yargs.positional("query", { describe: "Search query", type: "string", default: "" });
|
||||
yargs.options({
|
||||
"page": {
|
||||
describe: "Result page to show",
|
||||
type: "number",
|
||||
default: 1,
|
||||
},
|
||||
"page-size": {
|
||||
describe: "Results per page to show",
|
||||
type: "number",
|
||||
default: 10,
|
||||
},
|
||||
"sort": {
|
||||
describe: "sort results by given field",
|
||||
type: "string",
|
||||
},
|
||||
"sort-order": {
|
||||
describe: "order to sort results in (asc/desc)",
|
||||
type: "string",
|
||||
default: "asc",
|
||||
},
|
||||
"fields": {
|
||||
describe: "Fields to show, supports 'all'.",
|
||||
array: true,
|
||||
type: "string",
|
||||
default: ["name", "version", "title", "factorio_version"],
|
||||
},
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: {
|
||||
factorioVersion: string,
|
||||
query: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sort?: string,
|
||||
sortOrder: "asc" | "desc",
|
||||
fields: string[],
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
|
||||
if (searchVersion === undefined) {
|
||||
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
|
||||
}
|
||||
let response = await ctl.send(new lib.ModSearchRequest(
|
||||
args.query,
|
||||
lib.normaliseMajorMinorVersion(searchVersion),
|
||||
args.page,
|
||||
args.pageSize,
|
||||
args.sort,
|
||||
args.sortOrder
|
||||
));
|
||||
let results = response.results.flatMap(result => result.versions);
|
||||
if (!args.fields.includes("all")) {
|
||||
for (let entry of results) {
|
||||
for (let field of Object.keys(entry)) {
|
||||
if (!args.fields.includes(field)) {
|
||||
// @ts-expect-error terrible hack
|
||||
delete entry[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let issue of response.queryIssues) {
|
||||
print(issue);
|
||||
}
|
||||
print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`);
|
||||
print(asTable(results));
|
||||
},
|
||||
}));
|
||||
|
||||
modCommands.add(new lib.Command({
|
||||
definition: ["upload <file>", "Upload mod to the cluster", (yargs) => {
|
||||
yargs.positional("file", { describe: "File to upload", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { file: string }, ctl: Ctl) {
|
||||
let filename = path.basename(args.file);
|
||||
if (!filename.endsWith(".zip")) {
|
||||
throw new lib.CommandError("Mod filename must end with .zip");
|
||||
}
|
||||
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += "api/upload-mod";
|
||||
url.searchParams.append("filename", filename);
|
||||
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: (await fs.open(args.file)).createReadStream(),
|
||||
duplex: "half",
|
||||
headers: {
|
||||
"X-Access-Token": ctl.config.get("ctl.controller_token")!,
|
||||
"Content-Type": "application/zip",
|
||||
},
|
||||
});
|
||||
|
||||
type ResponseType = { errors?: [], request_errors?: [], mods: Parameters<typeof lib.ModInfo.fromJSON>[0][]};
|
||||
const result = await response.json() as ResponseType;
|
||||
|
||||
for (let error of result.errors || []) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
for (let requestError of result.request_errors || []) {
|
||||
logger.error(requestError);
|
||||
}
|
||||
|
||||
if (result.mods && result.mods.length) {
|
||||
const mod = lib.ModInfo.fromJSON(result.mods[0]);
|
||||
logger.info(`Successfully uploaded ${mod.filename}`);
|
||||
}
|
||||
|
||||
if ((result.errors || []).length || (result.request_errors || []).length) {
|
||||
throw new lib.CommandError("Uploading mod failed");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
modCommands.add(new lib.Command({
|
||||
definition: ["download <name> <mod-version>", "Download a mod from the cluster", (yargs) => {
|
||||
yargs.positional("name", { describe: "Internal name of mod to download", type: "string" });
|
||||
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let streamId = await ctl.send(new lib.ModDownloadRequest(args.name, args.modVersion));
|
||||
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
let filename = `${args.name}_${args.modVersion}.zip`;
|
||||
await lib.downloadFile(url, filename, "overwrite");
|
||||
|
||||
logger.info(`Downloaded ${filename}`);
|
||||
},
|
||||
}));
|
||||
|
||||
modCommands.add(new lib.Command({
|
||||
definition: ["delete <name> <mod-version>", "Delete a mod stored in the cluster", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of mod to delete", type: "string" });
|
||||
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
await ctl.send(new lib.ModDeleteRequest(args.name, args.modVersion));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,315 @@
|
||||
import asTableModule from "as-table";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const modPackCommands = new lib.CommandTree({ name: "mod-pack", description: "Mod Pack" });
|
||||
modPackCommands.add(new lib.Command({
|
||||
definition: ["show <mod-pack>", "Show details of mod pack", (yargs) => {
|
||||
yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { modPack: string }, ctl: Ctl) {
|
||||
let mods = new Map(
|
||||
(await ctl.send(new lib.ModListRequest())).map(m => [m.id, m])
|
||||
);
|
||||
let modPack = await ctl.send(
|
||||
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
|
||||
);
|
||||
|
||||
for (let [field, value] of Object.entries(modPack)) {
|
||||
if (field === "mods") {
|
||||
print(`${field}:`);
|
||||
for (let [name, entry] of value) {
|
||||
const mod = mods.get(`${name}_${entry.version}`);
|
||||
const missing = !mod && "? ";
|
||||
const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! ";
|
||||
const warn = missing || badChecksum || "";
|
||||
const enabled = entry.enabled ? "" : "(disabled) ";
|
||||
print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`);
|
||||
}
|
||||
} else if (field === "settings") {
|
||||
print(`${field}:`);
|
||||
for (let [scope, settings] of Object.entries(value)) {
|
||||
print(` ${scope}:`);
|
||||
for (let [setting, settingValue] of settings as any) {
|
||||
print(` ${setting}: ${JSON.stringify(settingValue.value)}`);
|
||||
}
|
||||
}
|
||||
} else if (field === "exportManifest") {
|
||||
print(`${field}:`);
|
||||
if (value && value.assets) {
|
||||
print(" assets:");
|
||||
for (let [name, fileName] of Object.entries(value.assets)) {
|
||||
print(` ${name}: ${fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
print(`${field}: ${value}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
modPackCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List mod packs in the cluster"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let modPacks = await ctl.send(new lib.ModPackListRequest());
|
||||
let fields = ["id", "name", "factorioVersion"];
|
||||
for (let entry of modPacks) {
|
||||
for (let field of Object.keys(entry)) {
|
||||
if (!fields.includes(field)) {
|
||||
// @ts-expect-error terrible hack
|
||||
delete entry[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
print(asTable(modPacks));
|
||||
},
|
||||
}));
|
||||
|
||||
function setModPackSettings(
|
||||
modPack: lib.ModPack,
|
||||
args: {
|
||||
boolSetting?: string[],
|
||||
intSetting?: string[],
|
||||
doubleSetting?: string[],
|
||||
stringSetting?: string[],
|
||||
colorSetting?: string[],
|
||||
},
|
||||
) {
|
||||
function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) {
|
||||
for (let i = 0; i + 2 < settings.length; i += 3) {
|
||||
const [scope, name, value] = settings.slice(i, i + 3);
|
||||
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
|
||||
throw new lib.CommandError(
|
||||
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
|
||||
);
|
||||
}
|
||||
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
|
||||
modPack.settings[checkedScope].set(name, { value: cast(value) });
|
||||
}
|
||||
}
|
||||
|
||||
doSettings(args.boolSetting || [], value => {
|
||||
if (!["true", "false"].includes(value)) {
|
||||
throw new lib.CommandError(`boolean value must be one of true or false, not ${value}`);
|
||||
}
|
||||
return value === "true";
|
||||
});
|
||||
doSettings(args.intSetting || [], value => {
|
||||
// eslint-disable-next-line radix
|
||||
let number = Number.parseInt(value);
|
||||
if (isNaN(number)) {
|
||||
throw new lib.CommandError(`int value must be an integer, not ${value}`);
|
||||
}
|
||||
return number;
|
||||
});
|
||||
doSettings(args.doubleSetting || [], value => {
|
||||
let number = Number.parseFloat(value);
|
||||
if (isNaN(number)) {
|
||||
throw new lib.CommandError(`double value must be a number, not ${value}`);
|
||||
}
|
||||
return number;
|
||||
});
|
||||
doSettings(args.stringSetting || [], value => value);
|
||||
doSettings(args.colorSetting || [], value => {
|
||||
let color = JSON.parse(value);
|
||||
if (typeof color !== "object" || color === null) {
|
||||
throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties");
|
||||
}
|
||||
return color;
|
||||
});
|
||||
}
|
||||
|
||||
function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
|
||||
for (let mod of mods || []) {
|
||||
const [name, version, sha1] = mod.split(":");
|
||||
if (!version) {
|
||||
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
|
||||
}
|
||||
if (!lib.isSourceVersion(version)) {
|
||||
throw new lib.CommandError("version must match the format digit.digit.digit");
|
||||
}
|
||||
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
|
||||
throw new lib.CommandError("sha1 must be a 40 digit lower case hex string");
|
||||
}
|
||||
modPack.mods.set(name, { name, enabled: true, version, sha1 });
|
||||
}
|
||||
}
|
||||
|
||||
function setModPackModsEnabled(modPack: lib.ModPack, mods: string[] | undefined, enabled: boolean) {
|
||||
for (let mod of mods || []) {
|
||||
if (!modPack.mods.has(mod)) {
|
||||
throw new lib.CommandError(`Mod named ${mod} does not exist in the mod pack`);
|
||||
}
|
||||
modPack.mods.get(mod)!.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
modPackCommands.add(new lib.Command({
|
||||
definition: ["create <name> <factorio-version>", "Create mod pack", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of mod pack to create", type: "string" });
|
||||
yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" });
|
||||
yargs.options({
|
||||
"description": { describe: "Description for mod pack", type: "string" },
|
||||
"mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" },
|
||||
"disabled-mods": { describe: "Mods that are in the pack but not enabled", array: true, type: "string" },
|
||||
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
|
||||
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
|
||||
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
|
||||
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
|
||||
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: {
|
||||
name: string,
|
||||
factorioVersion: string,
|
||||
description?: string,
|
||||
mods?: string[],
|
||||
disabledMods?: string[],
|
||||
boolSetting?: string[],
|
||||
intSetting?: string[],
|
||||
doubleSetting?: string[],
|
||||
stringSetting?: string[],
|
||||
colorSetting?: string[],
|
||||
},
|
||||
ctl: Ctl,
|
||||
) {
|
||||
const modPack = new lib.ModPack();
|
||||
modPack.name = args.name;
|
||||
if (args.description) { modPack.description = args.description; }
|
||||
if (args.factorioVersion) {
|
||||
if (!lib.isPartialVersion(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
|
||||
}
|
||||
modPack.factorioVersion = args.factorioVersion;
|
||||
}
|
||||
setModPackMods(modPack, args.mods);
|
||||
setModPackModsEnabled(modPack, args.disabledMods, false);
|
||||
setModPackSettings(modPack, args);
|
||||
await ctl.send(new lib.ModPackCreateRequest(modPack));
|
||||
print(`Created mod pack ${modPack.name} (${modPack.id})`);
|
||||
},
|
||||
}));
|
||||
|
||||
modPackCommands.add(new lib.Command({
|
||||
definition: ["import <string>", "Import mod pack string", (yargs) => {
|
||||
yargs.positional("string", { describe: "Mod pack string to import", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { string: string }, ctl: Ctl) {
|
||||
const modPack = lib.ModPack.fromModPackString(args.string);
|
||||
await ctl.send(new lib.ModPackCreateRequest(modPack));
|
||||
print(`Created mod pack ${modPack.name} (${modPack.id})`);
|
||||
},
|
||||
}));
|
||||
|
||||
modPackCommands.add(new lib.Command({
|
||||
definition: ["export <mod-pack>", "Export mod pack string", (yargs) => {
|
||||
yargs.positional("string", { describe: "Mod pack to export", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { modPack: string }, ctl: Ctl) {
|
||||
const modPack = await ctl.send(
|
||||
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
|
||||
);
|
||||
print(modPack.toModPackString());
|
||||
},
|
||||
}));
|
||||
|
||||
modPackCommands.add(new lib.Command({
|
||||
definition: ["edit <mod-pack>", "Edit mod pack", (yargs) => {
|
||||
yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" });
|
||||
yargs.options({
|
||||
"name": { describe: "New name for mod pack", type: "string" },
|
||||
"description": { describe: "New description for mod pack", type: "string" },
|
||||
"factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" },
|
||||
"add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" },
|
||||
"enable-mods": { describe: "Mods to set as enabled", array: true, type: "string" },
|
||||
"disable-mods": { describe: "Mods to set as disabled", array: true, type: "string" },
|
||||
"remove-mods": { describe: "Name of mods to remove", array: true, type: "string" },
|
||||
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
|
||||
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
|
||||
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
|
||||
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
|
||||
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
|
||||
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: {
|
||||
modPack: string,
|
||||
name?: string,
|
||||
description?: string,
|
||||
factorioVersion?: string,
|
||||
addMods?: string[],
|
||||
enableMods?: string[],
|
||||
disableMods?: string[],
|
||||
removeMods?: string[],
|
||||
boolSetting?: string[],
|
||||
intSetting?: string[],
|
||||
doubleSetting?: string[],
|
||||
stringSetting?: string[],
|
||||
colorSetting?: string[],
|
||||
removeSetting?: string[],
|
||||
},
|
||||
ctl: Ctl,
|
||||
) {
|
||||
const modPack = await ctl.send(
|
||||
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
|
||||
);
|
||||
|
||||
if (args.name) { modPack.name = args.name; }
|
||||
if (args.description) { modPack.description = args.description; }
|
||||
if (args.factorioVersion) {
|
||||
if (!lib.isPartialVersion(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
|
||||
}
|
||||
modPack.factorioVersion = args.factorioVersion;
|
||||
}
|
||||
|
||||
setModPackMods(modPack, args.addMods);
|
||||
for (let name of args.removeMods || []) {
|
||||
if (modPack.mods.has(name)) {
|
||||
modPack.mods.delete(name);
|
||||
} else {
|
||||
logger.warn(`Mod ${name} did not exist on ${modPack.name}`);
|
||||
}
|
||||
}
|
||||
setModPackModsEnabled(modPack, args.disableMods, false);
|
||||
setModPackModsEnabled(modPack, args.enableMods, true);
|
||||
|
||||
setModPackSettings(modPack, args);
|
||||
if (args.removeSetting) {
|
||||
for (let i = 0; i + 1 < args.removeSetting.length; i += 2) {
|
||||
const [scope, name] = args.removeSetting.slice(i, i + 2);
|
||||
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
|
||||
throw new lib.CommandError(
|
||||
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
|
||||
);
|
||||
}
|
||||
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
|
||||
if (modPack.settings[checkedScope].has(name)) {
|
||||
modPack.settings[checkedScope].delete(name);
|
||||
} else {
|
||||
logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await ctl.send(new lib.ModPackUpdateRequest(modPack));
|
||||
},
|
||||
}));
|
||||
|
||||
modPackCommands.add(new lib.Command({
|
||||
definition: ["delete <mod-pack>", "Delete mod pack", (yargs) => {
|
||||
yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { modPack: string }, ctl: Ctl) {
|
||||
const id = await lib.resolveModPack(ctl, args.modPack);
|
||||
await ctl.send(new lib.ModPackDeleteRequest(id));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,16 @@
|
||||
import asTableModule from "as-table";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" });
|
||||
permissionCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List permissions in the cluster"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let permissions = await ctl.send(new lib.PermissionListRequest());
|
||||
print(asTable(permissions));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,100 @@
|
||||
import asTableModule from "as-table";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" });
|
||||
roleCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List roles in the cluster"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let roles = await ctl.send(new lib.RoleListRequest());
|
||||
print(asTable(roles));
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new lib.Command({
|
||||
definition: ["create <name>", "Create a new role", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of role to create", type: "string" });
|
||||
yargs.options({
|
||||
"description": { describe: "Description for role", nargs: 1, type: "string", default: "" },
|
||||
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { name: string, description: string, permissions: string[] }, ctl: Ctl) {
|
||||
let id = await ctl.send(new lib.RoleCreateRequest(
|
||||
args.name,
|
||||
args.description,
|
||||
args.permissions,
|
||||
));
|
||||
logger.info(`Created role ID ${id}`);
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new lib.Command({
|
||||
definition: ["edit <role>", "Edit existing role", (yargs) => {
|
||||
yargs.positional("role", { describe: "Role to edit", type: "string" });
|
||||
yargs.options({
|
||||
"name": { describe: "New name for role", nargs: 1, type: "string" },
|
||||
"description": { describe: "New description for role", nargs: 1, type: "string" },
|
||||
"set-perms": { describe: "Set permissions for role", array: true, type: "string" },
|
||||
"add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" },
|
||||
"remove-perms": {
|
||||
describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms",
|
||||
},
|
||||
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: {
|
||||
role: string,
|
||||
name?: string,
|
||||
description?: string,
|
||||
setPerms?: string[],
|
||||
addPerms?: string[],
|
||||
removePerms?: string[],
|
||||
grantDefault?: boolean,
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
let role = await lib.retrieveRole(ctl, args.role);
|
||||
|
||||
if (args.name !== undefined) {
|
||||
role.name = args.name;
|
||||
}
|
||||
if (args.description !== undefined) {
|
||||
role.description = args.description;
|
||||
}
|
||||
if (args.addPerms) {
|
||||
for (const perm of args.addPerms) {
|
||||
role.permissions.add(perm);
|
||||
}
|
||||
}
|
||||
if (args.removePerms) {
|
||||
for (let perm of args.removePerms) {
|
||||
role.permissions.delete(perm);
|
||||
}
|
||||
}
|
||||
if (args.setPerms !== undefined) {
|
||||
role.permissions = new Set(args.setPerms);
|
||||
}
|
||||
await ctl.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
|
||||
|
||||
if (args.grantDefault) {
|
||||
await ctl.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
roleCommands.add(new lib.Command({
|
||||
definition: ["delete <role>", "Delete role", (yargs) => {
|
||||
yargs.positional("role", { describe: "Role to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { role: string }, ctl: Ctl) {
|
||||
let role = await lib.retrieveRole(ctl, args.role);
|
||||
await ctl.send(new lib.RoleDeleteRequest(role.id));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,304 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import path from "path";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const userCommands = new lib.CommandTree({ name: "user", alias: ["u"], description: "User management" });
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["show <name>", "Show details for one user", (yargs) => {
|
||||
yargs.positional("name", { decribe: "Name of user to show", type: "string" });
|
||||
yargs.options({
|
||||
"instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { name: string, instanceStats: boolean }, ctl: Ctl) {
|
||||
let user = await ctl.send(new lib.UserGetRequest(args.name));
|
||||
Object.assign(user, user.playerStats);
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.playerStats;
|
||||
let instanceStats = user.instanceStats;
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.instanceStats;
|
||||
print(asTable(Object.entries(user).map(([property, value]) => ({ property, value }))));
|
||||
|
||||
if (args.instanceStats) {
|
||||
let instances = await ctl.send(new lib.InstanceDetailsListRequest());
|
||||
function instanceName(id: number) {
|
||||
let instance = instances.find(i => i.id === id);
|
||||
if (instance) {
|
||||
return instance.name;
|
||||
}
|
||||
return "<deleted>";
|
||||
}
|
||||
for (let [id, playerInstanceStats] of instanceStats || []) {
|
||||
print();
|
||||
print(`Instance ${instanceName(id)} (${id}):`);
|
||||
print(asTable(Object.entries(playerInstanceStats).map(([property, value]) => ({ property, value }))));
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List user in the cluster", (yargs) => {
|
||||
yargs.options({
|
||||
"stats": { describe: "include user stats", nargs: 0, type: "boolean", default: false },
|
||||
"attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { stats: boolean, attributes: boolean }, ctl: Ctl) {
|
||||
let users = await ctl.send(new lib.UserListRequest());
|
||||
for (let user of users) {
|
||||
if (args.stats) {
|
||||
Object.assign(user, user.playerStats);
|
||||
}
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.playerStats;
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.isDeleted;
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.banReason;
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.instanceStats;
|
||||
if (!args.attributes) {
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.isAdmin;
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.isWhitelisted;
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.isBanned;
|
||||
}
|
||||
}
|
||||
print(asTable(users));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["create <name>", "Create a user", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of user to create", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string }, ctl: Ctl) {
|
||||
await ctl.send(new lib.UserCreateRequest(args.name));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["revoke-token <name>", "Revoke token for user", (yargs) => {
|
||||
yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string }, ctl: Ctl) {
|
||||
await ctl.send(new lib.UserRevokeTokenRequest(args.name));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
|
||||
yargs.options({
|
||||
"revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { user: string, revoke: boolean, create: boolean }, ctl: Ctl) {
|
||||
await ctl.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["set-whitelisted <user>", "Add or remove user from the whitelist", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" });
|
||||
yargs.options({
|
||||
"remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { user: string, remove: boolean, create: boolean }, ctl: Ctl) {
|
||||
await ctl.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["set-banned <user>", "Ban or pardon user from banlist", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" });
|
||||
yargs.options({
|
||||
"pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false },
|
||||
"reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" },
|
||||
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: {
|
||||
user: string,
|
||||
pardon: boolean,
|
||||
reason: string,
|
||||
create: boolean
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
await ctl.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["set-roles <user> [roles...]", "Replace user roles", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
|
||||
yargs.positional("roles", { describe: "roles to assign", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { user: string, roles: string[] }, ctl: Ctl) {
|
||||
let roles = await ctl.send(new lib.RoleListRequest());
|
||||
|
||||
let resolvedRoles = [];
|
||||
for (let roleName of args.roles) {
|
||||
if (/^-?\d+$/.test(roleName)) {
|
||||
let roleId = parseInt(roleName, 10);
|
||||
resolvedRoles.push(roleId);
|
||||
|
||||
} else {
|
||||
let found = false;
|
||||
for (let role of roles) {
|
||||
if (role.name === roleName) {
|
||||
resolvedRoles.push(role.id);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new lib.CommandError(`No role named ${roleName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctl.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["delete <user>", "Delete user", (yargs) => {
|
||||
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { user: string }, ctl: Ctl) {
|
||||
await ctl.send(new lib.UserDeleteRequest(args.user));
|
||||
},
|
||||
}));
|
||||
|
||||
async function handleImportOrRestore(args: {
|
||||
filepath: string,
|
||||
users: boolean,
|
||||
bans: boolean,
|
||||
admins: boolean,
|
||||
whitelist: boolean,
|
||||
noBackup?: boolean,
|
||||
}, ctl: Ctl, restore: boolean) {
|
||||
let importType: ConstructorParameters<typeof lib.UserBulkImportRequest>[0];
|
||||
const data = JSON.parse((await fs.readFile(args.filepath)).toString());
|
||||
const optionCount = [args.users, args.bans, args.admins, args.whitelist]
|
||||
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
|
||||
|
||||
if (optionCount > 1) {
|
||||
throw new lib.CommandError("Can not specify multiple options");
|
||||
}
|
||||
|
||||
// Assign based on options or attempt to guess based on filename
|
||||
const filename = path.basename(args.filepath);
|
||||
if (args.users || (optionCount === 0 && data.export_version)) {
|
||||
importType = "users";
|
||||
} else if (args.bans || (optionCount === 0 && filename.includes("ban"))) {
|
||||
importType = "bans";
|
||||
} else if (args.admins || (optionCount === 0 && filename.includes("admin"))) {
|
||||
importType = "admins";
|
||||
} else if (args.whitelist || (optionCount === 0 && filename.includes("whitelist"))) {
|
||||
importType = "whitelist";
|
||||
} else {
|
||||
throw new lib.CommandError("Unknown json file, please specify an option");
|
||||
}
|
||||
|
||||
const backup = await ctl.send(
|
||||
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
|
||||
);
|
||||
|
||||
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
|
||||
if (restore && backup && !args.noBackup) {
|
||||
await fs.writeFile(`${importType}-backup.json`, JSON.stringify(backup, undefined, "\t"));
|
||||
print(`Wrote backup to ${importType}-backup.json`);
|
||||
}
|
||||
}
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["import <filepath>", "Import user data from a file", (yargs) => {
|
||||
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
|
||||
yargs.options({
|
||||
"users": { describe: "Import users json", nargs: 0, type: "boolean", default: false },
|
||||
"bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false },
|
||||
"admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false },
|
||||
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: any, ctl: any) {
|
||||
await handleImportOrRestore(args, ctl, false);
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["restore <filepath>", "Restore user data from a file", (yargs) => {
|
||||
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
|
||||
yargs.options({
|
||||
"users": { describe: "Restore users json", nargs: 0, type: "boolean", default: false },
|
||||
"bans": { describe: "Restore banlist json", nargs: 0, type: "boolean", default: false },
|
||||
"admins": { describe: "Restore adminlist json", nargs: 0, type: "boolean", default: false },
|
||||
"whitelist": { describe: "Restore whitelist json", nargs: 0, type: "boolean", default: false },
|
||||
"no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: any, ctl: any) {
|
||||
await handleImportOrRestore(args, ctl, true);
|
||||
},
|
||||
}));
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["export <filepath>", "Export user data to a file", (yargs) => {
|
||||
yargs.positional("filepath", { describe: "Path to the file to save to", type: "string" });
|
||||
yargs.options({
|
||||
"users": { describe: "Export users json", nargs: 0, type: "boolean", default: false },
|
||||
"bans": { describe: "Export banlist json", nargs: 0, type: "boolean", default: false },
|
||||
"admins": { describe: "Export adminlist json", nargs: 0, type: "boolean", default: false },
|
||||
"whitelist": { describe: "Export whitelist json", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: {
|
||||
filepath: string,
|
||||
users: boolean,
|
||||
bans: boolean,
|
||||
admins: boolean,
|
||||
whitelist: boolean,
|
||||
}, ctl: Ctl) {
|
||||
let exportType = "users" as ConstructorParameters<typeof lib.UserBulkExportRequest>[0];
|
||||
const optionCount = [args.bans, args.admins, args.whitelist]
|
||||
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
|
||||
|
||||
if (optionCount > 1) {
|
||||
throw new lib.CommandError("Can not specify multiple options");
|
||||
}
|
||||
|
||||
// Assign based on options or attempt to guess based on filename
|
||||
if (args.users) {
|
||||
exportType = "users";
|
||||
} else if (args.bans) {
|
||||
exportType = "bans";
|
||||
} else if (args.admins) {
|
||||
exportType = "admins";
|
||||
} else if (args.whitelist) {
|
||||
exportType = "whitelist";
|
||||
}
|
||||
|
||||
await fs.writeFile(args.filepath, JSON.stringify(await ctl.send(
|
||||
new lib.UserBulkExportRequest(exportType)
|
||||
), undefined, "\t"));
|
||||
print(`Exported ${exportType} to ${args.filepath}`);
|
||||
},
|
||||
}));
|
||||
Loaded 100 of 469 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user