mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-06-25 02:47:55 +00:00
Compare commits
1230 Commits
v11.4.0
...
fix-window
Author | SHA1 | Date | |
---|---|---|---|
ae18df6710 | |||
8101ab38a6 | |||
0bae6546f2 | |||
40ab27df26 | |||
7d5a64f59a | |||
8115d156df | |||
08fc1a3924 | |||
950d173d27 | |||
ac49246141 | |||
0689074dd7 | |||
ee79c87723 | |||
9dc9556619 | |||
2f9212d622 | |||
2bf59530c4 | |||
a4fd7d6118 | |||
65f053dd6e | |||
8137b79078 | |||
e9b5773bcb | |||
4768f76385 | |||
d6b3249274 | |||
02a5466746 | |||
0831e5fa17 | |||
4681d901f8 | |||
6a55613199 | |||
893a39e891 | |||
fa4f91e08d | |||
54dc37dbd3 | |||
1b0c14feab | |||
20e0810d2a | |||
edc2e77ddd | |||
7da9a800cc | |||
2ba4405452 | |||
e7ebf1ad12 | |||
46249e319b | |||
fcd0932df8 | |||
34792ecce9 | |||
1e18096873 | |||
4da1ed3a56 | |||
92b8741288 | |||
6b4c28a026 | |||
849fc24158 | |||
16efb9748f | |||
9d177609f5 | |||
826b0659d6 | |||
46d7d1d068 | |||
47fcffe368 | |||
bb7cd7ac62 | |||
a83f6c95df | |||
7f000ee8c3 | |||
e5e7bb4757 | |||
37e6bd4b5c | |||
c48564e85a | |||
8460dac066 | |||
64ffcfdd91 | |||
077e25ebc4 | |||
709f009f9b | |||
116ab1fbc1 | |||
260a30532a | |||
7534042519 | |||
6b208ec2ab | |||
099d755900 | |||
3199f15662 | |||
4c8dc29946 | |||
2b22fb89f1 | |||
cf7d9246e5 | |||
0d3106af0e | |||
478b5dd363 | |||
0708608c7e | |||
c245dc70c2 | |||
4373ba7a5d | |||
2cc8d15c05 | |||
592efd0a2e | |||
31123d28f0 | |||
9b6ffecaba | |||
d0e4fa0e59 | |||
cf376316bc | |||
8f0f3bda29 | |||
c33409adb0 | |||
873eb1fc59 | |||
af70f16a9b | |||
e8d757ca28 | |||
63d3402924 | |||
8a506bc4c0 | |||
a14d89fe10 | |||
29ed0a232d | |||
8978221866 | |||
2974c203b5 | |||
c85acbd90b | |||
8a808e25d0 | |||
75687f51ac | |||
eddbdfe0dc | |||
d8acc3f814 | |||
fc8be3d8dc | |||
0ee02a4d73 | |||
568fcb9759 | |||
6133bb2096 | |||
48076464da | |||
1acf342fb0 | |||
340ca6577b | |||
0a8b3ce4e4 | |||
65c01ac172 | |||
4c9a22aba7 | |||
889fafcffc | |||
719cc2e4c9 | |||
e484701276 | |||
b1897a512d | |||
f98c25eaee | |||
b9c3b57b85 | |||
8aff330516 | |||
abdaf0043f | |||
960cb3098d | |||
e907f12445 | |||
799e0f9dea | |||
c389f41006 | |||
74ca5207ad | |||
3706db2436 | |||
6ec0b4a3bd | |||
e65caed64e | |||
b180eb7b73 | |||
9805854eab | |||
00c956394d | |||
b3510f205f | |||
e755d9f03f | |||
f9224b05af | |||
ece4d88bfd | |||
0dd7c33237 | |||
cd20f1765e | |||
0ca1faba09 | |||
9f8569e33f | |||
d7007721a7 | |||
f9f1863fdb | |||
93e18bea27 | |||
73f49765ec | |||
3a508dc397 | |||
bd5bf0135a | |||
e0c65bdef8 | |||
b9d90b9e38 | |||
d910319ba5 | |||
5e5a2c1c85 | |||
238c371ade | |||
504877c232 | |||
bdcf58471f | |||
46b9c586a6 | |||
273ea5ce4d | |||
d56fec6e36 | |||
cd81ff005f | |||
dee216eeaa | |||
d1539f405a | |||
d131fb4fa8 | |||
a0380848a0 | |||
ffa8e245ba | |||
8631e22686 | |||
f0bd3a38db | |||
88569066b5 | |||
c20bbe658b | |||
ac0ce8f702 | |||
42c6e1010f | |||
1f4554abe8 | |||
4e457da5a9 | |||
2e1570149d | |||
c647989054 | |||
44bd667648 | |||
2d042ee116 | |||
787966a0b6 | |||
a59d85e833 | |||
d0616acf1b | |||
d21a18f353 | |||
7d3dbc2c0b | |||
529b98552c | |||
99a478ee39 | |||
fb879d3020 | |||
4fb4cce842 | |||
f772957d29 | |||
fd9520224c | |||
c1afaa6cf3 | |||
8cb413c1c9 | |||
e96fca551e | |||
edb3ea53fb | |||
358a909214 | |||
eb74ca631a | |||
64ebebb121 | |||
abc62404ab | |||
af1c4b0d03 | |||
830e1f801d | |||
59c398fbf0 | |||
d7f49d2442 | |||
34597f629d | |||
3fa7eec8a9 | |||
1ee12b70bc | |||
ca7b1ae084 | |||
936d3cb62a | |||
230677e5e8 | |||
025f817eb6 | |||
54cceb688f | |||
648a73fd91 | |||
3691ae148e | |||
4496bc88f5 | |||
afded27692 | |||
c1a5718364 | |||
e021ad9af6 | |||
5c8a5165e0 | |||
71ff73c641 | |||
c35472e94d | |||
511bb05cb9 | |||
53b2b54b23 | |||
e7f753007f | |||
0afaf8502f | |||
3272b55dd9 | |||
4c664167f6 | |||
604c182e2c | |||
60593a77ac | |||
497c8cd49b | |||
d348d9f71f | |||
d6651fdd7e | |||
e1c42405a1 | |||
bf22d9eaa8 | |||
88523a2887 | |||
e8eb031253 | |||
120c82d657 | |||
cb2e60d5af | |||
62dfae371c | |||
eaf220b64f | |||
9804dd3c33 | |||
94f3825119 | |||
abde3cf48a | |||
efb488f81a | |||
6ca7c34e57 | |||
3f084366db | |||
9f98529e56 | |||
bab98df87b | |||
4d9affd030 | |||
15b536a3b2 | |||
505acc19db | |||
1d566a72ca | |||
0337e284a6 | |||
74c6f8a627 | |||
7aa1708f46 | |||
32a21684e8 | |||
a52a623fdf | |||
b63e31e255 | |||
fec01977c7 | |||
cf894d98a5 | |||
d18f25cb9c | |||
4cdff9694e | |||
304ade9772 | |||
0865633020 | |||
ddb87f403d | |||
8047779c0c | |||
9da7f03b2a | |||
9aacb7ec56 | |||
41e7ba12ff | |||
10decc785d | |||
47e9d39c6f | |||
0eb4e6d770 | |||
492b877d02 | |||
09b8cc495c | |||
94cc84e5ce | |||
cd3fa4521c | |||
5d7d687d6c | |||
5b39878d54 | |||
2c24d80492 | |||
0f058a4441 | |||
66f793e327 | |||
60917f641b | |||
56cc70cd50 | |||
fd4c740d29 | |||
644e643fab | |||
f6529eec26 | |||
401c116820 | |||
4e37ce14c6 | |||
3fc25cd745 | |||
598423acaa | |||
1685ce16f4 | |||
d4de25e545 | |||
d1f46a59f7 | |||
29854f8737 | |||
7eb398c6ef | |||
9ed3bb2f70 | |||
839de65df2 | |||
b2c680e824 | |||
b7c74598b2 | |||
7b78a777ac | |||
82026897c7 | |||
0534ab1cb4 | |||
a6a8dc822f | |||
b0dce6b477 | |||
b9734b7b09 | |||
24ba9da64b | |||
edaf76e329 | |||
d419ae9183 | |||
3833dc3adc | |||
5d3625f6ae | |||
1c17572db0 | |||
c65dafd2ff | |||
33fd24b981 | |||
41635c746b | |||
8c12510f17 | |||
50a658f2f6 | |||
4f831ef443 | |||
457eff1d43 | |||
9e7a13a992 | |||
d9cf95149a | |||
bcc9f9e766 | |||
1ef13665bc | |||
da6c048a3b | |||
3beaa76ec5 | |||
7b4a9a8bc6 | |||
7ebc94c1e5 | |||
92d37ffcb7 | |||
116dc3e623 | |||
17a9d71e59 | |||
6fe8fb7c6e | |||
7da7ff6cf0 | |||
48430fcaee | |||
477c48f831 | |||
fb9943a375 | |||
00c78dfcd8 | |||
c0ca1500eb | |||
88b7b9273a | |||
0c3790bc45 | |||
9069cd3d73 | |||
d6f1328238 | |||
463f3f40ca | |||
38fe9042e5 | |||
6f265b24aa | |||
e2c9bf86c7 | |||
73ba4a1283 | |||
eea8c83bff | |||
0bd27bd7ac | |||
b15f4de899 | |||
5242510481 | |||
6082771aad | |||
bf8f41d2b4 | |||
b1ab0337c0 | |||
a27536045d | |||
3b56b7b324 | |||
11afbf3da2 | |||
0da5f822e4 | |||
9c6b6b3718 | |||
3d7833a640 | |||
4b110fef8e | |||
89e2bb5945 | |||
f326994f8e | |||
e80697013e | |||
3a06701747 | |||
e20265518d | |||
d4c513fd41 | |||
dec92a3d9c | |||
4cdaf29348 | |||
7301b9c246 | |||
1accb885a9 | |||
497023965f | |||
95f9443c2a | |||
f677156fe0 | |||
edeea18b15 | |||
67d6b59cb9 | |||
9997b29ef7 | |||
224f721a72 | |||
467a923353 | |||
20dec8821f | |||
69e9222703 | |||
238e5cb9d2 | |||
f846ef0c19 | |||
8044bb6079 | |||
afc660762f | |||
5002017de6 | |||
2ba9f9dfcf | |||
40f643c07f | |||
402e48c8f8 | |||
6f6cb42725 | |||
a3c10ded82 | |||
178c3f9154 | |||
a74815c1bb | |||
9dbdf7db38 | |||
c35f701190 | |||
9e465217b2 | |||
d20beae926 | |||
2307a15b10 | |||
fe5e6a1dae | |||
33add41dc7 | |||
099ac8b233 | |||
f6ca5ef397 | |||
15ed67422d | |||
d6695d7278 | |||
9697081d73 | |||
35200697de | |||
4fc62017ae | |||
303c3af061 | |||
984d1a3fd6 | |||
ef9c2e9c18 | |||
d4f288394b | |||
41a8e1b2a4 | |||
2fc50eecf5 | |||
5e94f3f9c9 | |||
3d0a93692f | |||
7987e20775 | |||
209ea64b82 | |||
21c8a38c22 | |||
63e30a025e | |||
d0a4ff0d6b | |||
c95568b5a4 | |||
a252a550ff | |||
2a3479c7c6 | |||
7f2501439d | |||
137349b2f4 | |||
7ee59b56c7 | |||
c5628d0056 | |||
a76d49243c | |||
488d9b901c | |||
812b11db5f | |||
d681dacc6d | |||
3207a2922a | |||
45f131ec93 | |||
bd4d836a31 | |||
a039ec86dd | |||
f9a05fe257 | |||
6add63c6ac | |||
361e54b3bd | |||
0ce035f379 | |||
eaf61d801c | |||
4cc4a22af3 | |||
6403e29d76 | |||
1308b64c67 | |||
17089a35c3 | |||
e34dca0e36 | |||
512cfa3da1 | |||
674dccef21 | |||
5b95a16fe1 | |||
667358b23e | |||
f47a4ccb2b | |||
ece3c06786 | |||
b50fef8cb4 | |||
4f7ea7e32a | |||
01c12dd5bf | |||
b114697cab | |||
14ce4d73b6 | |||
051e1f1331 | |||
c054d55456 | |||
133daefe83 | |||
232e9c3fc1 | |||
03eed32f12 | |||
e1d51305b0 | |||
af071beef0 | |||
6dce5c1212 | |||
a26c3912d3 | |||
f3d86ab37c | |||
420a282bea | |||
cfbf00f543 | |||
83888210d6 | |||
2f7dd54e37 | |||
ab9dabaf77 | |||
9e98e7142c | |||
4656f8f11d | |||
5b5f258685 | |||
7d2e32777f | |||
10120bb97f | |||
98c9469151 | |||
98c0991fe3 | |||
f686d3a9bf | |||
8dda8d89d5 | |||
7794158062 | |||
24d6b7fa38 | |||
f5f0c7b4a7 | |||
11d1a3f5a0 | |||
2859d16b31 | |||
70bfe4ce8e | |||
6f4db3176a | |||
ebb0ec5287 | |||
4995e9b642 | |||
795057338f | |||
ab673f884a | |||
907508bce1 | |||
599b897721 | |||
146de39b83 | |||
a3cab32b4e | |||
d709e06f48 | |||
98f101643d | |||
c619bd4b99 | |||
19c3069b22 | |||
7e1d58546c | |||
2c01f8adee | |||
3ecf461d55 | |||
06ab84fd10 | |||
a7b78d2ccd | |||
432109060e | |||
b32ae4a667 | |||
36e4b3249c | |||
41e5fdbe27 | |||
6dc0fe10bc | |||
656591fde4 | |||
d967b942e0 | |||
7e34fdfeeb | |||
995f8a3338 | |||
ff282205d5 | |||
be144fafa2 | |||
683037cd2f | |||
555096db6b | |||
a85c482416 | |||
523d563b4e | |||
1569915fae | |||
b1552f8e9b | |||
f455602c73 | |||
3e97669b3c | |||
728c4f4296 | |||
bf073942f0 | |||
b9290f4859 | |||
626d328194 | |||
21dd959344 | |||
5c8d822aee | |||
2ab8ae1c10 | |||
fcc13f9476 | |||
a38b41f339 | |||
2fc0728a09 | |||
040c4987fc | |||
254d9c49a4 | |||
6e5e1c4f5f | |||
d7213e868f | |||
d82b019480 | |||
1693bd91c0 | |||
e4f605d6ac | |||
fd7e7f57eb | |||
1d073af31a | |||
fcaaec1fff | |||
ac3a688d46 | |||
979284b071 | |||
bc4aa6006e | |||
2cad44915b | |||
889c7b08cf | |||
56a196210d | |||
a23759a1ba | |||
ba0024645d | |||
3cb184c8af | |||
644d54a113 | |||
a6f905b71c | |||
3b426e4a53 | |||
d241523d93 | |||
1c354c800b | |||
e5861a708e | |||
6a019af25f | |||
8522363cd3 | |||
480228d8f4 | |||
175413af34 | |||
d1b4560b37 | |||
77f3fa4b6c | |||
a21d3fe2d2 | |||
df440f0580 | |||
92bfa574e3 | |||
d33b7ec585 | |||
08d5a77734 | |||
744122b1b8 | |||
c3a8bb3de6 | |||
e50d92727e | |||
3bb5e495a6 | |||
803a9070fd | |||
a84ab793a0 | |||
81269e92d5 | |||
11d5deef4c | |||
c98bc3280d | |||
8c2a40cb39 | |||
2fdd023a64 | |||
aff370e9c3 | |||
be21c8d43e | |||
5b33826309 | |||
052c8d138e | |||
d0228f20fd | |||
4577d72ead | |||
0dde84ec0b | |||
81b620f55e | |||
4b056b4d4c | |||
5723e69267 | |||
2d341cac48 | |||
4e50d08f7b | |||
aff5cd9b0d | |||
2bb0933a42 | |||
8d60cd1f92 | |||
9756efb539 | |||
61ed6ff69d | |||
127560fa65 | |||
2611ea22f9 | |||
d7021a556e | |||
9412a21d40 | |||
f63be0b4cd | |||
c2561938c1 | |||
98a2c0635d | |||
ee54d638ad | |||
d9b044c1b8 | |||
dd20a8b00f | |||
01147c31a4 | |||
2f6889cca1 | |||
83286e6729 | |||
97def08ec5 | |||
1a57385626 | |||
1301f62981 | |||
6ae337db8a | |||
b84cdd6230 | |||
2f24e591ef | |||
597b894917 | |||
3b53b75626 | |||
9b1c3c665b | |||
153cdf4bb0 | |||
1d9a397f71 | |||
6167c7b8b3 | |||
cbcd7694a9 | |||
52bece7f17 | |||
4f6550e7eb | |||
ec17ed6ef2 | |||
0df6368ab9 | |||
b5cac122cf | |||
ae75e1396e | |||
3b519f0258 | |||
275fa9c16b | |||
a00db0f5d8 | |||
2a8eb3a6ed | |||
bcd49e0292 | |||
d8e1cd6597 | |||
52c2b041da | |||
d5d0486c3f | |||
6f51807e8c | |||
ab526c9ed8 | |||
14c5b27cdd | |||
ce01ce73b1 | |||
0f6d160b2e | |||
6d7d1956ea | |||
692eddf43f | |||
eb5cfecfaf | |||
73d6d7b264 | |||
14ced9f384 | |||
4d8cd1cc46 | |||
dbe9a727d5 | |||
8ac65c3800 | |||
4ae91ef846 | |||
7311cfa755 | |||
a200bf268d | |||
d398e22c58 | |||
b51d2fffbb | |||
5fef98bdf8 | |||
203ccaf97b | |||
a348528ed3 | |||
04c4250fba | |||
a97398950e | |||
f55376df32 | |||
3f285cc26d | |||
6d95c5bad5 | |||
6b33f95661 | |||
9ab34c2deb | |||
db247307db | |||
ad0b667bc7 | |||
d98bc9fb06 | |||
74cdd80b51 | |||
5c39952002 | |||
2874a69d7d | |||
6ec05e8dcf | |||
6602845202 | |||
e8cd4153c7 | |||
0cfa1a0dfb | |||
00ce3ab751 | |||
5c1323d583 | |||
d9f42b888d | |||
0db8c85fc8 | |||
bc601d07e3 | |||
e1a91035ae | |||
8dced8afe2 | |||
ffded6736a | |||
1a851f552e | |||
68b64016ab | |||
edac54ccfe | |||
560b0abbe7 | |||
b48d238be6 | |||
a10d5b9abe | |||
23f2242e22 | |||
f8612fd748 | |||
36446ff488 | |||
a5ce0436c7 | |||
3302e2f639 | |||
c3c1c5fc41 | |||
9f59b6dde5 | |||
8be56ef092 | |||
e9f8cadb73 | |||
3e4f9f9572 | |||
f7d4a37060 | |||
d0e268815a | |||
c3454d3abb | |||
6b0f645094 | |||
ada7801a0d | |||
da5e26f37e | |||
9447195c26 | |||
cd59496f11 | |||
9fda165d34 | |||
fe0ad92b43 | |||
81c5a62380 | |||
ebdd04ec73 | |||
e6264ced7a | |||
247f31a3cc | |||
a2b761ec4b | |||
e3672bc655 | |||
028141c0b0 | |||
e3c42cf63e | |||
0ae138db03 | |||
9350af9ddf | |||
88e4009e88 | |||
cb7692690d | |||
82e17cea6a | |||
9ed363da9e | |||
8aa4bd6173 | |||
5f098e7410 | |||
2de33d185a | |||
66b9f5a337 | |||
bbcb3a702f | |||
57d0014e32 | |||
8d9133e6a6 | |||
be82bcfa63 | |||
7c9a23451b | |||
1319e0642b | |||
e3b6db25d8 | |||
655534469a | |||
a8b0573699 | |||
99963cbb89 | |||
92715c3182 | |||
264c8535b4 | |||
159ee44d7e | |||
7e4b62c28a | |||
52b2ba6a30 | |||
cc1ba3d84e | |||
01d05fb148 | |||
cff9e50a22 | |||
eba2e7e4fb | |||
9a9d56b419 | |||
320b4864d9 | |||
7f79451376 | |||
68fa831843 | |||
3aa72dde4c | |||
f72d78954d | |||
cf87ca95a0 | |||
a50ca78eef | |||
4fe5a10029 | |||
9812239862 | |||
bc3fe29624 | |||
7e2ee7ab93 | |||
f151a208e5 | |||
292ad89b7e | |||
c062e6e876 | |||
dcb1c11700 | |||
96e28f3d45 | |||
ff319d67f3 | |||
f14e44a2e8 | |||
9aa6b0bc57 | |||
cbe12d5be7 | |||
c177f222ba | |||
d2fd1ec80a | |||
77873cf919 | |||
07c09c5f89 | |||
159cb752d1 | |||
a74f0413df | |||
43b1c5c24f | |||
45e7e9cb32 | |||
1a71bad8bb | |||
2d55df4704 | |||
6c0b3a5e53 | |||
3e955f3a91 | |||
30738d93b0 | |||
be76b8adbd | |||
d6a065a230 | |||
7b8e86372b | |||
bc15ad6e05 | |||
fcad35402a | |||
49b00e18ae | |||
a6ccd87069 | |||
0c1904fbdb | |||
e5d2661c96 | |||
eca3e91512 | |||
eb5ad08649 | |||
b3b22d6399 | |||
217cba819a | |||
c8275b52c3 | |||
47e85da789 | |||
c8cade95da | |||
a4de7143b1 | |||
6574745a23 | |||
1ee74df67e | |||
3e1b10007a | |||
448211e49c | |||
8658104647 | |||
6ec8bcddaa | |||
d138c40ebd | |||
f24c4a036c | |||
dabe81c31b | |||
c2f0f9a894 | |||
46b695cf22 | |||
9b79f79bac | |||
dddfad9dec | |||
0690554a94 | |||
62ea7518bc | |||
f30e486562 | |||
809a5fae25 | |||
eccb1bd9ad | |||
f859d5025a | |||
18d3ca3413 | |||
a826f16469 | |||
505c3ec7d3 | |||
47fa2a6151 | |||
b4b19637f4 | |||
5f552cf9a8 | |||
e42650f433 | |||
731bd909d6 | |||
2860535c45 | |||
122b5a0655 | |||
ec66c82d3f | |||
09a59ab03f | |||
3d2e109e7f | |||
26803067f1 | |||
7dc3977e82 | |||
10cbf514a2 | |||
e2114f73d7 | |||
2f448951c9 | |||
385d3e107b | |||
d98b2fa72f | |||
c6baa7a908 | |||
daa34feeda | |||
f813dad4d9 | |||
d7633b5f08 | |||
f44c2b777f | |||
bcfba693a5 | |||
08f40c0566 | |||
5a80654305 | |||
d2df2c7b60 | |||
36d3d1256e | |||
b77cb56cd0 | |||
524397fc9b | |||
ec73ee270b | |||
b83431c2e0 | |||
40c559322a | |||
2c5cf9dab6 | |||
ca8272b477 | |||
d6e7359400 | |||
af8d7283a5 | |||
9470e804c0 | |||
00943463a4 | |||
3f6d770233 | |||
c4a6086e9c | |||
4e61c00255 | |||
1713988e94 | |||
fe4e1d09d7 | |||
766695ceef | |||
e50a3270ba | |||
235c13bea9 | |||
62e4930e5b | |||
fb321b8c5b | |||
98152c0b09 | |||
0ab0e417b8 | |||
3642943896 | |||
7c62e34455 | |||
86af954f3b | |||
0c7947e185 | |||
48b281d7c6 | |||
8598223b61 | |||
cdd67e25f0 | |||
eac6bb5e5c | |||
077d1db9b7 | |||
d86f213b68 | |||
cdfd1d124b | |||
28c00696b8 | |||
dec570a6e2 | |||
9067558d18 | |||
4abdd71ce7 | |||
36f2f491b3 | |||
5e750b33c3 | |||
03053e125f | |||
bdc7c0fa39 | |||
ad4981328f | |||
3f35d6fde6 | |||
f2be811e18 | |||
6439aa5552 | |||
977fadab69 | |||
95c93d24da | |||
278d7fd02c | |||
59b9429570 | |||
9e870b08a7 | |||
671dca8287 | |||
a15060e9fc | |||
0738dd1520 | |||
5dbace353d | |||
e773549297 | |||
a1c406a479 | |||
5e196b8f63 | |||
054e59c6af | |||
88a1e413a3 | |||
1a74dcf4cf | |||
d48672fa93 | |||
9a7fcfffe8 | |||
f9ece2ce7d | |||
9d04e616a8 | |||
b8c7f23443 | |||
2b04763ac0 | |||
bff845a0e4 | |||
5076ca7532 | |||
93ba5832d8 | |||
af86ac73e6 | |||
173a48eede | |||
a4b34c109d | |||
69714a646b | |||
a41ef3764e | |||
f1220c6377 | |||
cefb3acc1f | |||
277da3ea9c | |||
99f84c2f6a | |||
a9c0899c32 | |||
8d3fb8fef5 | |||
4de41ce3e0 | |||
4b8cec652a | |||
2dd8e71adc | |||
05d478b759 | |||
9a7a364776 | |||
2cb5e28258 | |||
02e8429155 | |||
467afb3de6 | |||
324a406e7f | |||
17bf061853 | |||
6d543b79ff | |||
85aaf77e44 | |||
83c5684491 | |||
6bc4fbb750 | |||
1da96a0eb0 | |||
be209f1626 | |||
654d1dcff8 | |||
0a03e79d9d | |||
3f84045127 | |||
544f8fb4bd | |||
76997c99dc | |||
f4525bc11e | |||
f732c5bf5d | |||
2bc3348aff | |||
895be0be5d | |||
0f17129c2e | |||
9005affe64 | |||
4502f2a203 | |||
da3c11533c | |||
6acff945ef | |||
b3948d538c | |||
f53a69feb1 | |||
405b92114d | |||
27e1f3f7d7 | |||
1417875110 | |||
f58a49d6c3 | |||
f9743b269a | |||
0f5f65e0d3 | |||
58e7880f1d | |||
041823189f | |||
38194e6175 | |||
c04e9665ad | |||
1e37c97ffb | |||
913f09924a | |||
ceb47e9969 | |||
305755549e | |||
77931b314a | |||
b38b5b0b61 | |||
5cf407b483 | |||
8f6902f4cb | |||
751f67e997 | |||
be1a260af6 | |||
9db6961a7e | |||
b978230f9e | |||
cc5fe60a15 | |||
bbea58a9c8 | |||
56e35f6e9f | |||
95b5ac1c7f | |||
df3e1f1886 | |||
5d34659991 | |||
aca794b267 | |||
cd6072ac73 | |||
bda696ad8c | |||
ef4ee54a00 | |||
a2ca8e8f73 | |||
620a0abf31 | |||
95561864a6 | |||
51adfeaa3b | |||
76447a2177 | |||
a6153869e5 | |||
3466be1992 | |||
95843dd816 | |||
edd755d41c | |||
290c06074a | |||
dd7d9d1570 | |||
c4829153fc | |||
615f24edd3 | |||
a94e6d550e | |||
75044030cf | |||
046743071d | |||
4e95cb0cca | |||
4666019c84 | |||
323c9191b6 | |||
024bf2996b | |||
5210d474a9 | |||
3cce8d822c | |||
65250e431e | |||
29cc75598f | |||
33552724a1 | |||
c88b317143 | |||
658b0a5233 | |||
7fd436cd91 | |||
7c1faa6de0 | |||
90e184ea1f | |||
38920a1c59 | |||
df58ac7673 | |||
630d53311a | |||
b1eda160e8 | |||
a63c766f04 | |||
53325b7c05 | |||
622c510d65 | |||
890bea549f | |||
bb19903826 | |||
33210b896b | |||
c2a0e457c0 | |||
f464597069 | |||
02dcff5b67 | |||
2f4539b4d1 | |||
6c3429eb0c | |||
a8bd5d332a | |||
0462574d8d | |||
1325fb8c9a | |||
cf42dca777 | |||
afd97bd304 | |||
45ce442cf2 | |||
e8c5c9e9ef | |||
caac6855da | |||
e379900526 | |||
0bc1e7057f | |||
d94a74dfee | |||
a25a52c21b | |||
268bc36843 | |||
8e7eaaae24 | |||
b69ba0b617 | |||
1bedf937f8 | |||
74a521a271 | |||
6376ac28d5 | |||
2222a90884 | |||
09f04be77d | |||
636ecaf4e0 | |||
07fa504c78 | |||
5f74023bb8 | |||
b31e253fa4 | |||
f75f00e4d0 | |||
13deb83517 | |||
62b7d2fd1a | |||
aa5062ea6f | |||
394f2e0999 | |||
4a967b126b | |||
5f780a0947 | |||
0671ee0bef | |||
55b2b5a467 | |||
f7a4160c3f | |||
294290908b | |||
8ed4f547e0 | |||
3393e797d0 | |||
7de059b95e | |||
c2b0091f86 | |||
d96ad93e1e | |||
e0373e6f19 | |||
f50f169ff0 | |||
eada1ab87e | |||
51d4ffc5d9 | |||
59a7b9d12b | |||
3bb5ca50b2 | |||
353ee4a576 | |||
eaffc2574f | |||
5142274e9d | |||
a05b592f00 | |||
e7c89cf77c | |||
09444f0cff | |||
f329acc2ec | |||
ee3a0cc630 | |||
b40498786a | |||
43f551dd2e | |||
1f4382bea8 | |||
96bc8a829e | |||
0cdf4d95e5 | |||
a6ac0ee17b | |||
1b943bdf7d | |||
f831bb4645 | |||
82c486b202 | |||
02b888f7c1 | |||
69c97fed09 | |||
05cb89725e | |||
43ae9b672c | |||
27270dd589 | |||
0c5ed7adfb | |||
4280a3cd4a | |||
b8944ba65c | |||
a8fcd85f1a | |||
cc45d872c7 | |||
e0e76a1aa8 | |||
d9b417e9e5 | |||
101a4bc209 | |||
449461afae | |||
1987206b94 | |||
7dd33adfd1 | |||
63fe3d41cc | |||
59e4eb5143 | |||
78e627a471 | |||
3adc06d11b | |||
85fc9daa8b | |||
d463a2f0e5 | |||
863eae42c5 | |||
4b373d5ed4 | |||
4077da1491 | |||
3718473f05 | |||
54bcbb5d91 | |||
408f739e2d | |||
f481d5edae | |||
cff81ae86d | |||
f9d6a0ee72 | |||
9ea999f15f | |||
eac229ab7c | |||
7a865b2e15 | |||
32c588db55 | |||
aa670ad6f1 | |||
c1ba73a2da | |||
606b6c88ab | |||
a6f329750c | |||
c07b28e694 | |||
b3bef9e556 | |||
2ff427fb90 | |||
3d89b0c7a1 | |||
90db52db47 | |||
87004621ce | |||
de8089b0bb | |||
ae691391b6 | |||
a64b36fdb9 | |||
db5c473952 | |||
2b0bff8f16 | |||
2e7f606667 | |||
73e9f801e2 | |||
9a40f20004 | |||
6631fb5a69 | |||
f76ca1804a | |||
d25ad328f3 | |||
dfbcdc2c1b | |||
d484c957bb | |||
70714b6feb | |||
c9db5fd856 | |||
d54a709e7c | |||
09f20ecc1c | |||
b8b0221ce6 | |||
596d1bdc21 | |||
fb1dce9dbb | |||
fedfb603f6 | |||
8478b95e45 | |||
7bb2741a5a | |||
693a438d42 | |||
2a8e68cec2 | |||
e3435c66df | |||
192b751e57 | |||
d9643eb59e | |||
19c3178062 | |||
516fa90a20 | |||
56aabad8ad | |||
4a80beac35 | |||
cdedc58ec1 | |||
ed084edc48 | |||
b435a0e7ac | |||
731db63e78 | |||
2ee2bc8b02 | |||
b4c99dc03a | |||
730c09989d | |||
9e0733a143 | |||
8dd1106a44 | |||
4d389bb6cc | |||
6d6b3cb1a3 | |||
e84482fbbe | |||
f1d9c29786 | |||
34f4c1f6cc | |||
2390ddc02d | |||
70561705e5 | |||
05d58d8248 | |||
13610ef814 | |||
9c49890399 | |||
b9884ec545 | |||
ebd8f348ca | |||
90c4cbf2ae | |||
987e16cad0 | |||
15dfdc2229 | |||
35110e0610 | |||
bf722f61c7 | |||
08e8151b1f | |||
9977451b07 | |||
9f610a521e | |||
67a4e88e44 | |||
f0bd2f197d | |||
5fe908bbbf | |||
f9fc8fd2e8 | |||
9ea5198bda | |||
35732515ac | |||
3ea905dc68 | |||
062fadfa49 | |||
58983670f1 | |||
18fadf5634 | |||
80917b7198 | |||
f21dc3e2f4 | |||
d92e076829 | |||
cd491a7935 | |||
90269525be | |||
225408c57d | |||
8cfacc9cbc | |||
9a270539c6 | |||
df22d42412 | |||
49a7eb30c0 | |||
e41ea6fb1a | |||
636ec2a8f2 | |||
9eae9dcee3 | |||
6c26e1235c | |||
bacca5383a | |||
32e72c832f | |||
05aaed07b2 | |||
7c750f9e43 | |||
55bf4dc0f0 | |||
0afbd6f17a | |||
66b997d98c | |||
d485fd00a0 | |||
3322faeeb2 | |||
c32d894e97 | |||
ad737b8e02 | |||
bcc86fbcb6 | |||
d5c7527f8d | |||
5df65f67c3 | |||
79e65025cb | |||
dff6dafe85 | |||
adcc862acb | |||
8bf884d425 | |||
a6b282598b | |||
77089e31e4 | |||
7c6bae491f |
12
.gitattributes
vendored
Normal file
12
.gitattributes
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Set all files to use line feed endings (since we can't match only ones without an extension)
|
||||||
|
* eol=lf
|
||||||
|
# And then reset all the files with extensions back to default
|
||||||
|
*.* -eol
|
||||||
|
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# lf for the docs as it's auto-generated and will otherwise trigger an uncommited error on windows
|
||||||
|
doc/cli.markdown text eol=lf
|
||||||
|
# crlf for the eol conversion test files
|
||||||
|
tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh eol=crlf
|
||||||
|
tests/test-data/projects/no-docker-compose/basic/src/windows-crlf.sh eol=crlf
|
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@ -1 +1 @@
|
|||||||
* @pdcastro @thgreasi @CameronDiver @hedss
|
* @balena-io/balena-cli
|
||||||
|
84
.github/ISSUE_TEMPLATE.md
vendored
84
.github/ISSUE_TEMPLATE.md
vendored
@ -1,16 +1,76 @@
|
|||||||
- **balena CLI version:** e.g. 11.2.1 (output of the `"balena version"` command)
|
|
||||||
- **Operating system version:** e.g. Windows 10, Ubuntu 18.04, macOS 10.14.5
|
# About this issue tracker
|
||||||
- **32/64 bit OS and processor:** e.g. 32-bit Windows on 64-bit Intel processor
|
|
||||||
- **Install method:** npm or zip or executable installer
|
*The balena CLI (Command Line Interface) is a tool used to interact with the balena platform.
|
||||||
- **If npm install, Node.js and npm version:** e.g. Node v8.16.0 and npm v6.4.1
|
This GitHub issue tracker is used for bug reports and feature requests regarding the CLI
|
||||||
|
tool. General and troubleshooting questions (such as setting up your project to work with a
|
||||||
|
balenalib base image) are encouraged to be posted to the [balena
|
||||||
|
forums](https://forums.balena.io), which are monitored by balena's support team and where the
|
||||||
|
community can both contribute and benefit from the answers.*
|
||||||
|
|
||||||
|
*Please also check that this issue is not a duplicate. If there is another issue describing
|
||||||
|
the same problem or feature please add comments to the existing issue.*
|
||||||
|
|
||||||
|
*Thank you for your time and effort creating the issue report, and helping us improve
|
||||||
|
the balena CLI!*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Please keep in mind that we try to use the issue tracker of this repository for specific bug
|
# Expected Behavior
|
||||||
reports & CLI feature requests. General & troubleshooting questions are encouraged to be posted to
|
|
||||||
the [balena forums](https://forums.balena.io), which are monitored by balena's support team and
|
|
||||||
where the community can both contribute and benefit from the answers.*
|
|
||||||
|
|
||||||
*Before submitting this issue please check that this issue is not a duplicate. If there is another
|
Please describe what you were expecting to happen. If applicable, please add links to
|
||||||
issue describing the same problem or feature please add your information to the existing issue's
|
documentation you were following, or to projects that you were trying to push/build.
|
||||||
comments.*
|
|
||||||
|
# Actual Behavior
|
||||||
|
|
||||||
|
Please describe what actually happened instead:
|
||||||
|
* Quoting logs and error message is useful. If possible, quote the **full** output of the
|
||||||
|
CLI, not just the error message.
|
||||||
|
* Please quote the **full command line** too. Sometimes users report that they were
|
||||||
|
"pushing" or "building" a project, but there are several ways to do so and several
|
||||||
|
possible "targets" such as balenaCloud, openBalena, local balenaOS device, etc.
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
balena push myApp
|
||||||
|
balena push 192.168.0.12
|
||||||
|
balena deploy myApp
|
||||||
|
balena deploy myApp --build
|
||||||
|
balena build . -a myApp
|
||||||
|
balena build . -A armv7hf -d raspberrypi3
|
||||||
|
```
|
||||||
|
|
||||||
|
Each of the above command lines executes different code behind the scenes, so quoting the
|
||||||
|
full command line is very helpful.
|
||||||
|
|
||||||
|
Running the CLI in debug mode (`--debug` flag or `DEBUG=1` environment variable) may reveal
|
||||||
|
additional information. The `--logs` option reveals additional information for the commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
balena build . --logs
|
||||||
|
balena deploy myApp --build --logs
|
||||||
|
```
|
||||||
|
|
||||||
|
# Steps to Reproduce the Problem
|
||||||
|
|
||||||
|
This is the most important and helpful part of a bug report. If we cannot reproduce the
|
||||||
|
problem, it is difficult to tell what the fix should be, or whether code changes have
|
||||||
|
fixed it.
|
||||||
|
|
||||||
|
1.
|
||||||
|
1.
|
||||||
|
1.
|
||||||
|
|
||||||
|
# Specifications
|
||||||
|
|
||||||
|
- **balena CLI version:** e.g. 1.2.3 (output of the `"balena version -a"` command)
|
||||||
|
- **Cloud backend: openBalena or balenaCloud?** If unsure, it will be balenaCloud
|
||||||
|
- **Operating system version:** e.g. Windows 10, Ubuntu 18.04, macOS 10.14.5
|
||||||
|
- **32/64 bit OS and processor:** e.g. 32-bit Windows on 64-bit Intel processor
|
||||||
|
- **Install method:** npm or zip package or executable installer
|
||||||
|
- **If npm install, Node.js and npm version:** e.g. Node v8.16.0 and npm v6.4.1
|
||||||
|
|
||||||
|
# Additional References
|
||||||
|
|
||||||
|
If applicable, please add additional links to GitHub projects, forums.balena.io threads,
|
||||||
|
gist.github.com, Google Drive attachments, etc.
|
||||||
|
31
.github/PULL_REQUEST_TEMPLATE.md
vendored
31
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,11 +1,26 @@
|
|||||||
<!-- You can remove tags that do not apply. -->
|
<!-- You can remove tags that do not apply. -->
|
||||||
Resolves: # <!-- Refer an issue of this repository that this PR fixes -->
|
Resolves: # <!-- Refer an issue of this repository that this PR fixes -->
|
||||||
See: <url> <!-- Refer to any external resource, like a PR, document or discussion -->
|
Change-type: major|minor|patch <!-- See https://semver.org/ -->
|
||||||
Depends-on: <url> <!-- This change depends on a PR to get merged/deployed first -->
|
Depends-on: <url> <!-- This change depends on a PR to get merged/deployed first -->
|
||||||
Change-type: major|minor|patch <!-- The change type of this PR -->
|
See: <url> <!-- Refer to any external resource, like a PR, document or discussion -->
|
||||||
|
|
||||||
---
|
---
|
||||||
##### Contributor checklist
|
Please check the CONTRIBUTING.md file for relevant information and some
|
||||||
<!-- For completed items, change [ ] to [x]. -->
|
guidance. Keep in mind that the CLI is a cross-platform application that runs
|
||||||
- [ ] Introduces security considerations
|
on Windows, macOS and Linux. Tests will be automatically run by balena CI on
|
||||||
- [ ] Affects the development, build or deployment processes of the component
|
all three operating systems, but this will only help if you have added test
|
||||||
|
code that exercises the modified or added feature code.
|
||||||
|
|
||||||
|
Note that each commit message (currently only the first line) will be
|
||||||
|
automatically copied to the CHANGELOG.md file, so try writing it in a way
|
||||||
|
that describes the feature or fix for CLI users.
|
||||||
|
|
||||||
|
If there isn't a linked issue or if the linked issue doesn't quite match the
|
||||||
|
PR, please add a PR description to explain its purpose or the features that it
|
||||||
|
implements. Adding PR comments to blocks of code that aren't self explanatory
|
||||||
|
usually helps with the review process.
|
||||||
|
|
||||||
|
If the PR introduces security considerations or affects the development, build
|
||||||
|
or release process, please be sure to highlight this in the PR description.
|
||||||
|
|
||||||
|
Thank you very much for your contribution!
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -12,6 +12,7 @@ lib-cov
|
|||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
# node-waf configuration
|
# node-waf configuration
|
||||||
.lock-wscript
|
.lock-wscript
|
||||||
@ -24,7 +25,6 @@ build/Release
|
|||||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
|
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
npm-shrinkwrap.json
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
.resinconf
|
.resinconf
|
||||||
.balenaconf
|
.balenaconf
|
||||||
@ -44,3 +44,4 @@ dist/
|
|||||||
|
|
||||||
# Ignore fast-boot cache file
|
# Ignore fast-boot cache file
|
||||||
**/.fast-boot.json
|
**/.fast-boot.json
|
||||||
|
/oclif.manifest.json
|
||||||
|
@ -1,5 +1,2 @@
|
|||||||
coffee_script:
|
|
||||||
config_file: coffeelint.json
|
|
||||||
|
|
||||||
javascript:
|
javascript:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
6
.mocharc-standalone.js
Normal file
6
.mocharc-standalone.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const commonConfig = require('./.mocharc.js');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...commonConfig,
|
||||||
|
spec: ['tests/auth/*.spec.ts', 'tests/commands/**/*.spec.ts'],
|
||||||
|
};
|
8
.mocharc.js
Normal file
8
.mocharc.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
spec: 'tests/commands/app/create.spec.ts',
|
||||||
|
reporter: 'spec',
|
||||||
|
require: 'ts-node/register/transpile-only',
|
||||||
|
file: './tests/config-tests',
|
||||||
|
timeout: 12000,
|
||||||
|
spec: 'tests/**/*.spec.ts',
|
||||||
|
};
|
26
.resinci.yml
26
.resinci.yml
@ -2,35 +2,19 @@
|
|||||||
npm:
|
npm:
|
||||||
platforms:
|
platforms:
|
||||||
- name: linux
|
- name: linux
|
||||||
os: alpine
|
os: ubuntu
|
||||||
architecture: x86_64
|
architecture: x86_64
|
||||||
node_versions:
|
node_versions:
|
||||||
- "8"
|
|
||||||
- "10"
|
- "10"
|
||||||
|
- "12"
|
||||||
|
- "14"
|
||||||
- name: linux
|
- name: linux
|
||||||
os: alpine
|
os: alpine
|
||||||
architecture: x86
|
|
||||||
node_versions:
|
|
||||||
- "8"
|
|
||||||
- "10"
|
|
||||||
- name: darwin
|
|
||||||
os: macos
|
|
||||||
architecture: x86_64
|
architecture: x86_64
|
||||||
node_versions:
|
node_versions:
|
||||||
- "8"
|
|
||||||
- "10"
|
|
||||||
- name: windows
|
|
||||||
os: windows
|
|
||||||
architecture: x86_64
|
|
||||||
node_versions:
|
|
||||||
- "8"
|
|
||||||
- "10"
|
|
||||||
- name: windows
|
|
||||||
os: windows
|
|
||||||
architecture: x86
|
|
||||||
node_versions:
|
|
||||||
- "8"
|
|
||||||
- "10"
|
- "10"
|
||||||
|
- "12"
|
||||||
|
- "14"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
publish: false
|
publish: false
|
||||||
|
@ -4,12 +4,15 @@ os:
|
|||||||
- osx
|
- osx
|
||||||
node_js:
|
node_js:
|
||||||
- "10"
|
- "10"
|
||||||
|
matrix:
|
||||||
|
exclude:
|
||||||
|
node_js: "10"
|
||||||
script:
|
script:
|
||||||
- node --version
|
- node --version
|
||||||
- npm --version
|
- npm --version
|
||||||
- npm run ci
|
- npm run ci
|
||||||
- npm run build:standalone
|
# - npm run build:standalone
|
||||||
- npm run build:installer
|
# - npm run build:installer
|
||||||
notifications:
|
notifications:
|
||||||
email: false
|
email: false
|
||||||
deploy:
|
deploy:
|
||||||
|
File diff suppressed because it is too large
Load Diff
2553
CHANGELOG.md
2553
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
238
CONTRIBUTING.md
238
CONTRIBUTING.md
@ -2,27 +2,108 @@
|
|||||||
|
|
||||||
The balena CLI is an open source project and your contribution is welcome!
|
The balena CLI is an open source project and your contribution is welcome!
|
||||||
|
|
||||||
After cloning this repository and running `npm install`, the CLI can be built with `npm run build`
|
* Install the dependencies listed in the [NPM Installation
|
||||||
and executed with `./bin/run`. In order to ease development:
|
section](./INSTALL-ADVANCED.md#npm-installation) section of the installation instructions. Check
|
||||||
|
the section [Additional Dependencies](./INSTALL-ADVANCED.md#additional-dependencies) too.
|
||||||
|
* Clone the `balena-cli` repository (or a [forked
|
||||||
|
repo](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo),
|
||||||
|
if you are not in the balena team), `cd` to it and run `npm install`.
|
||||||
|
* Build the CLI with `npm run build` or `npm test`, and execute it with `./bin/balena`
|
||||||
|
(on a Windows command prompt, you may need to run `node .\bin\balena`).
|
||||||
|
|
||||||
|
In order to ease development:
|
||||||
|
|
||||||
* `npm run build:fast` skips some of the build steps for interactive testing, or
|
* `npm run build:fast` skips some of the build steps for interactive testing, or
|
||||||
* `./bin/balena-dev` uses `ts-node/register` and `coffeescript/register` to transpile on the fly.
|
* `npm run test:source` skips testing the standalone zip packages (which is rather slow)
|
||||||
|
* `./bin/balena-dev` uses `ts-node/register` to transpile on the fly.
|
||||||
|
|
||||||
Before opening a PR, please be sure to test your changes with `npm test`.
|
Before opening a PR, test your changes with `npm test`. Keep compatibility in mind, as the CLI is
|
||||||
|
meant to run on Linux, macOS and Windows. balena CI will run test code on all three platforms, but
|
||||||
|
this will only help if you add some test cases for your new code!
|
||||||
|
|
||||||
## Semantic versioning and commit messages
|
## Semantic versioning, commit messages and the ChangeLog
|
||||||
|
|
||||||
The CLI version numbering adheres to [Semantic Versioning](http://semver.org/). The following
|
When a pull request is merged, Balena's versionbot / Continuous Integration system takes care of
|
||||||
header/row is required in the body of a commit message, and will cause the CI build to fail if absent:
|
automatically creating a new CLI release on both the [npm
|
||||||
|
registry](https://www.npmjs.com/package/balena-cli) and the GitHub [releases
|
||||||
|
page](https://github.com/balena-io/balena-cli/releases). The release version numbering adheres to
|
||||||
|
the [Semantic Versioning's](http://semver.org/) concept of patch, minor and major releases.
|
||||||
|
Generally, bug fixes and documentation changes are classed as patch changes, while new features are
|
||||||
|
classed as minor changes. If a change breaks backwards compatibility, it is a major change.
|
||||||
|
|
||||||
|
A new version entry is also automatically added to the
|
||||||
|
[CHANGELOG.md](https://github.com/balena-io/balena-cli/blob/master/CHANGELOG.md) file when a pull
|
||||||
|
request is merged. Each pull request corresponds to a single version / release. Each commit in the
|
||||||
|
pull request becomes a bullet point entry in the Changelog. The Changelog file should not be
|
||||||
|
manually edited.
|
||||||
|
|
||||||
|
To support this automation, a commit message should be structured as follows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
The first line becomes a bullet point in the CHANGELOG file
|
||||||
|
|
||||||
|
Optionally, a more detailed description in one or more paragraphs.
|
||||||
|
The detailed description can be seen with `git log`, but it is not copied
|
||||||
|
to the CHANGELOG file.
|
||||||
|
|
||||||
```
|
|
||||||
Change-type: patch|minor|major
|
Change-type: patch|minor|major
|
||||||
```
|
```
|
||||||
|
|
||||||
Version numbers and commit messages are automatically added to the `CHANGELOG.md` file by the CI
|
Only the first line of the commit message is copied to the Changelog file. The `Change-type` footer
|
||||||
build flow, after a pull request is merged. It should not be manually edited.
|
must be preceded by a blank line, and indicates the commit's semver change type. When a PR consists
|
||||||
|
of multiple commits, the commits may have different change type values. As a whole, the PR will
|
||||||
|
produce a release of the "highest" change type. For example, two commits mixing patch and minor
|
||||||
|
change types will produce a minor CLI release, while two commits mixing minor and major change
|
||||||
|
types will produce a major CLI release.
|
||||||
|
|
||||||
## Editing documentation files (CHANGELOG, README, website...)
|
The commit message is parsed / checked by versionbot with the
|
||||||
|
[resin-commit-lint](https://github.com/balena-io-modules/resin-commit-lint#resin-commit-lint)
|
||||||
|
package.
|
||||||
|
|
||||||
|
Because of the way that the Changelog file is automatically updated from commit messages, which
|
||||||
|
become the source of "what's new" for CLI end users, we advocate "meaningful commits" and
|
||||||
|
user-focused commit messages. A meaningful commit is one that, in isolation, introduces a fix or
|
||||||
|
feature (or part of a fix or feature) that makes sense at the Changelog level, and which leaves the
|
||||||
|
CLI in a non-broken state. Sometimes, in the course of preparing a single pull request, a developer
|
||||||
|
creates several commits as a way of saving their "work in progress", which may even fail to build
|
||||||
|
(e.g. `npm run build` fails), and which is then fixed or undone by further commits in the same PR.
|
||||||
|
In this situation, the recommendation is to "squash" or "fixup" the work-in-progress commits into
|
||||||
|
fewer, meaningful commits. Interactive rebase is a good tool to achieve this:
|
||||||
|
[blog](https://thoughtbot.com/blog/git-interactive-rebase-squash-amend-rewriting-history),
|
||||||
|
[docs](https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History).
|
||||||
|
|
||||||
|
Mixing multiple distinct features or bug fixes in a single commit is discouraged, because the
|
||||||
|
description will likely not fit in the single-line Changelog bullet point and also because it
|
||||||
|
makes it harder to review the pull request (especially a large one) and harder to isolate and
|
||||||
|
revert individual changes in case a bug is found later on. Create a separate commit for each
|
||||||
|
feature / bug fix, or even separate pull requests.
|
||||||
|
|
||||||
|
If you need to catch up with changes to the master branch while working on a pull request,
|
||||||
|
use rebase instead of merge: [docs](https://git-scm.com/book/en/v2/Git-Branching-Rebasing).
|
||||||
|
|
||||||
|
If `package.json` is updated for dependencies listed in the `repo.yml` file (like `balena-sdk`),
|
||||||
|
the commit message body should also include a line in the following format:
|
||||||
|
```
|
||||||
|
Update balena-sdk from 12.0.0 to 12.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows versionbot to produce nested Changelog entries (with expandable arrows), pulling in
|
||||||
|
commit messages from the upstream repositories. The following npm script can be used to
|
||||||
|
automatically produce a commit with a suitable commit message:
|
||||||
|
```
|
||||||
|
npm run update balena-sdk ^12.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will create a new branch (only if `master` is currently checked out), run `npm update`
|
||||||
|
with the given target version and commit the `package.json` and `npm-shrinkwrap.json` files. The
|
||||||
|
script by default will set the `Change-type` to `patch` or `minor`, depending on the semver change
|
||||||
|
of the updated dependency. A `major` change type can specified as an extra argument:
|
||||||
|
```
|
||||||
|
npm run update balena-sdk ^12.14.0 patch
|
||||||
|
npm run update balena-sdk ^13.0.0 major
|
||||||
|
```
|
||||||
|
|
||||||
|
## Editing documentation files (README, INSTALL, Reference website...)
|
||||||
|
|
||||||
The `doc/cli.markdown` file is automatically generated by running `npm run build:doc` (which also
|
The `doc/cli.markdown` file is automatically generated by running `npm run build:doc` (which also
|
||||||
runs as part of `npm run build`). That file is then pulled by scripts in the
|
runs as part of `npm run build`). That file is then pulled by scripts in the
|
||||||
@ -31,35 +112,128 @@ Documentation page](https://www.balena.io/docs/reference/cli/).
|
|||||||
|
|
||||||
The content sources for the auto generation of `doc/cli.markdown` are:
|
The content sources for the auto generation of `doc/cli.markdown` are:
|
||||||
|
|
||||||
* Selected sections of the README file.
|
* [Selected
|
||||||
* The CLI's command documentation in source code (both Capitano and oclif commands), for example:
|
sections](https://github.com/balena-io/balena-cli/blob/v12.23.0/automation/capitanodoc/capitanodoc.ts#L199-L204)
|
||||||
* `lib/actions/build.coffee`
|
of the README file.
|
||||||
* `lib/actions-oclif/env/add.ts`
|
* The CLI's command documentation in source code (`lib/commands/` folder), for example:
|
||||||
|
* `lib/commands/push.ts`
|
||||||
|
* `lib/commands/env/add.ts`
|
||||||
|
|
||||||
The README file is manually edited, but subsections are automatically extracted for inclusion in
|
The README file is manually edited, but subsections are automatically extracted for inclusion in
|
||||||
`doc/cli.markdown` by the `getCapitanoDoc()` function in
|
`doc/cli.markdown` by the `getCapitanoDoc()` function in
|
||||||
[`automation/capitanodoc/capitanodoc.ts`](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts).
|
[`automation/capitanodoc/capitanodoc.ts`](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts).
|
||||||
|
|
||||||
The `INSTALL.md` and `TROUBLESHOOTING.md` files are also manually edited.
|
The `INSTALL*.md` and `TROUBLESHOOTING.md` files are also manually edited.
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
Please note that `npm run build:installer` (which generates the `.exe` executable installer on
|
The `npm run build:installer` script (which generates the `.exe` executable installer on Windows)
|
||||||
Windows) requires [MSYS2](https://www.msys2.org/) to be installed. Other than that, the standard
|
specifically requires [MSYS2](https://www.msys2.org/) to be installed. Other than that, the
|
||||||
Command Prompt or PowerShell can be used.
|
standard Command Prompt or PowerShell can be used (though MSYS2 is still handy, as it provides
|
||||||
|
'git' and a number of common unix utilities). If changes are made to npm scripts in `package.json`,
|
||||||
|
check that they also run on a standard Windows Command Prompt.
|
||||||
|
|
||||||
## TypeScript vs CoffeeScript, and Capitano vs oclif
|
## Updating the 'npm-shrinkwrap.json' file
|
||||||
|
|
||||||
The CLI was originally written in [CoffeeScript](https://coffeescript.org), but we decided to
|
The `npm-shrinkwrap.json` file is used to control package dependencies, as documented at
|
||||||
migrate to [TypeScript](https://www.typescriptlang.org/) in order to take advantage of static
|
https://docs.npmjs.com/files/shrinkwrap.json.
|
||||||
typing and formal programming interfaces. The migration is taking place gradually, as part of
|
|
||||||
maintenance work or the implementation of new features.
|
|
||||||
|
|
||||||
Similarly, [Capitano](https://github.com/balena-io/capitano) was originally adopted as the CLI's
|
Changes to `npm-shrinkwrap.json` can be automatically merged by git during operations like
|
||||||
framework, but we recently decided to take advantage of [oclif](https://oclif.io/)'s features such
|
`rebase`, `pull` and `cherry-pick`, but in some cases this results in suboptimal dependency
|
||||||
as native installers for Windows, macOS and Linux, and support for custom flag parsing (for
|
resolution (the `node_modules` folder may end up larger than necessary, with consequences to CLI
|
||||||
example, we're still battling with Capitano's behavior of dropping leading zeros of arguments that
|
load time too). For this reason, the recommended way to update `npm-shrinkwrap.json` is to run
|
||||||
look like integers such as some abbreviated UUIDs, and migrating to oclif is a solution). Again the
|
`npm install`, possibly alongside `npm dedupe` as well. The following commands can be used to
|
||||||
migration is taking place gradually, with some CLI commands parsed by oclif and others by Capitano
|
fix shrinkwrap issues and optimize the dependencies:
|
||||||
(a simple command line pre-parsing takes place in `app.ts` to decide whether to route full parsing
|
|
||||||
to Capitano or oclif).
|
```sh
|
||||||
|
git checkout master -- npm-shrinkwrap.json
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install # update npm-shrinkwrap.json to satisfy changes to package.json
|
||||||
|
npm dedupe # deduplicate dependencies from npm-shrinkwrap.json
|
||||||
|
npm install # re-add optional dependencies removed by dedupe
|
||||||
|
git add npm-shrinkwrap.json # add it for committing (solve merge errors)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that `npm dedupe` should always be followed by `npm install`, as shown above, even if
|
||||||
|
`npm install` had already been executed before `npm dedupe`.
|
||||||
|
|
||||||
|
Optionally, these steps may be automated by installing the
|
||||||
|
[npm-merge-driver](https://www.npmjs.com/package/npm-merge-driver):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx npm-merge-driver install -g
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript and oclif
|
||||||
|
|
||||||
|
The CLI currently contains a mix of plain JavaScript and
|
||||||
|
[TypeScript](https://www.typescriptlang.org/) code. The goal is to have all code written in
|
||||||
|
Typescript, in order to take advantage of static typing and formal programming interfaces.
|
||||||
|
The migration towards Typescript is taking place gradually, as part of maintenance work or
|
||||||
|
the implementation of new features.
|
||||||
|
|
||||||
|
Of historical interest, the CLI was originally written in [CoffeeScript](https://coffeescript.org)
|
||||||
|
and used the [Capitano](https://github.com/balena-io/capitano) framework. All CoffeeScript code was
|
||||||
|
migrated to either Javascript or Typescript, and Capitano was replaced with oclif. A few file or
|
||||||
|
variable names still refer to this legacy, for example `automation/capitanodoc/capitanodoc.ts`.
|
||||||
|
|
||||||
|
## Programming style
|
||||||
|
|
||||||
|
`npm run build` also runs [balena-lint](https://www.npmjs.com/package/@balena/lint), which automatically
|
||||||
|
reformats the code. Beyond that, we have a preference for Javascript promises over callbacks, and for
|
||||||
|
`async/await` over `.then()`.
|
||||||
|
|
||||||
|
## Common gotchas
|
||||||
|
|
||||||
|
One thing that most CLI bugs have in common is the absence of test cases exercising the broken
|
||||||
|
code, so writing some test code is a great idea. Having said that, there are also some common
|
||||||
|
gotchas to bear in mind:
|
||||||
|
|
||||||
|
* Forward slashes ('/') _vs._ backslashes ('\') in file paths. The Node.js
|
||||||
|
[path.sep](https://nodejs.org/docs/latest-v12.x/api/path.html#path_path_sep) variable stores a
|
||||||
|
platform-specific path separator character: the backslash on Windows and the forward slash on
|
||||||
|
Linux and macOS. The
|
||||||
|
[path.join](https://nodejs.org/docs/latest-v12.x/api/path.html#path_path_join_paths) function
|
||||||
|
builds paths using such platform-specific path separator. However:
|
||||||
|
* Note that Windows (kernel, cmd.exe, PowerShell, many applications) accepts ***both*** forward
|
||||||
|
slashes and backslashes as path separators (including mixing them in a path string), so code
|
||||||
|
like `mypath.split(path.sep)` may fail on Windows if `mypath` contains forward slashes. The
|
||||||
|
[path.parse](https://nodejs.org/docs/latest-v12.x/api/path.html#path_path_parse_path) function
|
||||||
|
understands both forward slashes and backslashes on Windows, and the
|
||||||
|
[path.normalize](https://nodejs.org/docs/latest-v12.x/api/path.html#path_path_normalize_path)
|
||||||
|
function will _replace_ forward slashes with backslashes.
|
||||||
|
* In [tar](https://en.wikipedia.org/wiki/Tar_(computing)#File_format) streams sent to the Docker
|
||||||
|
daemon and to balenaCloud, the forward slash is the only acceptable path separator, regardless
|
||||||
|
of the OS where the CLI is running. Therefore, `path.sep` and `path.join` should never be used
|
||||||
|
when handling paths in tar streams! `path.posix.join` may be used instead of `path.join`.
|
||||||
|
|
||||||
|
* Avoid using the system shell to execute external commands, for example:
|
||||||
|
`child_process.exec('ssh "arg1" "arg2"');`
|
||||||
|
`child_process.spawn('ssh "arg1" "arg2"', { shell: true });`
|
||||||
|
Besides the usual security concerns of unsanitized strings, another problem is to get argument
|
||||||
|
escaping right because of the differences between the Windows 'cmd.exe' shell and the Unix
|
||||||
|
'/bin/sh'. For example, 'cmd.exe' doesn't recognize single quotes like '/bin/sh', and uses the
|
||||||
|
caret (^) instead of the backslash as the escape character. Bug territory! Most of the time,
|
||||||
|
it is possible to avoid relying on the shell altogether by providing a Javascript array of
|
||||||
|
arguments:
|
||||||
|
`spawn('ssh', ['arg1', 'arg2'], { shell: false});`
|
||||||
|
To allow for logging and debugging, the [which](https://www.npmjs.com/package/which) package may
|
||||||
|
be used to get the full path of a command before executing it, without relying on any shell:
|
||||||
|
`const fullPath = await which('ssh');`
|
||||||
|
`console.log(fullPath); # 'C:\WINDOWS\System32\OpenSSH\ssh.EXE'`
|
||||||
|
`spawn(fullPath, ['arg1', 'arg2'], { shell: false });`
|
||||||
|
|
||||||
|
* Avoid the `instanceof` operator when testing against classes/types from external packages
|
||||||
|
(including base classes), because `npm install` may result in multiple versions of the same
|
||||||
|
package being installed (to satisfy declared dependencies) and a false negative may result when
|
||||||
|
comparing an object instance from one package version with a class of another package version
|
||||||
|
(even if the implementations are identical in both packages). For example, once we fixed a bug
|
||||||
|
where the test:
|
||||||
|
`error instanceof BalenaApplicationNotFound`
|
||||||
|
changed from true to false because `npm install` added an additional copy of the `balena-errors`
|
||||||
|
package to satisfy a minor `balena-sdk` version update:
|
||||||
|
`$ find node_modules -name balena-errors`
|
||||||
|
`node_modules/balena-errors`
|
||||||
|
`node_modules/balena-sdk/node_modules/balena-errors`
|
||||||
|
In the case of subclasses of `TypedError`, a string comparison may be used instead:
|
||||||
|
`error.name === 'BalenaApplicationNotFound'`
|
||||||
|
150
INSTALL-ADVANCED.md
Normal file
150
INSTALL-ADVANCED.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# balena CLI Advanced Installation Options
|
||||||
|
|
||||||
|
**These are alternative, advanced installation options. Most users would prefer the [recommended,
|
||||||
|
streamlined installation
|
||||||
|
instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).**
|
||||||
|
|
||||||
|
There are 3 options to choose from to install balena's CLI:
|
||||||
|
|
||||||
|
* [Executable Installer](#executable-installer): the easiest method on Windows and macOS, using the
|
||||||
|
traditional graphical desktop application installers.
|
||||||
|
* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI
|
||||||
|
executable in them: extract and run. Available for all platforms: Linux, Windows, macOS.
|
||||||
|
Recommended also for scripted installation in CI (continuous integration) environments.
|
||||||
|
* [NPM Installation](#npm-installation): recommended for Node.js developers who may be interested
|
||||||
|
in integrating the balena CLI in their existing projects or workflow.
|
||||||
|
|
||||||
|
Some specific CLI commands have a few extra installation steps: see section [Additional
|
||||||
|
Dependencies](#additional-dependencies).
|
||||||
|
|
||||||
|
## Executable Installer
|
||||||
|
|
||||||
|
This is the recommended installation option on macOS and Windows. Follow the specific OS
|
||||||
|
instructions:
|
||||||
|
|
||||||
|
* [Windows](./INSTALL-WINDOWS.md)
|
||||||
|
* [macOS](./INSTALL-MAC.md)
|
||||||
|
|
||||||
|
> Note regarding WSL ([Windows Subsystem for
|
||||||
|
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about))
|
||||||
|
> If you would like to use WSL, follow the [installations instructions for
|
||||||
|
> Linux](./INSTALL-LINUX.md) rather than Windows, as WSL consists of a Linux environment.
|
||||||
|
|
||||||
|
If you had previously installed the CLI using a standalone zip package, it may be a good idea to
|
||||||
|
check your system's `PATH` environment variable for duplicate entries, as the terminal will use the
|
||||||
|
entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package) instructions
|
||||||
|
for how to modify the PATH variable.
|
||||||
|
|
||||||
|
By default, the CLI is installed to the following folders:
|
||||||
|
|
||||||
|
OS | Folders
|
||||||
|
--- | ---
|
||||||
|
Windows: | `C:\Program Files\balena-cli\`
|
||||||
|
macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
|
||||||
|
|
||||||
|
## Standalone Zip Package
|
||||||
|
|
||||||
|
1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases).
|
||||||
|
Look for a file name that ends with the word "standalone", for example:
|
||||||
|
`balena-cli-vX.Y.Z-linux-x64-standalone.zip` ← _also for the Windows Subsystem for Linux_
|
||||||
|
`balena-cli-vX.Y.Z-macOS-x64-standalone.zip`
|
||||||
|
`balena-cli-vX.Y.Z-windows-x64-standalone.zip`
|
||||||
|
|
||||||
|
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
|
||||||
|
`balena-cli` folder.
|
||||||
|
|
||||||
|
3. Add the `balena-cli` folder to the system's `PATH` environment variable.
|
||||||
|
See instructions for:
|
||||||
|
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
|
||||||
|
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
|
||||||
|
[Windows](https://www.computerhope.com/issues/ch000549.htm)
|
||||||
|
|
||||||
|
> * If you are using macOS 10.15 or later (Catalina, Big Sur), [check this known issue and
|
||||||
|
> workaround](https://github.com/balena-io/balena-cli/issues/1479).
|
||||||
|
> * **Linux Alpine** and **Busybox:** the standalone zip package is not currently compatible with
|
||||||
|
> these "compact" Linux distributions, because of the alternative C libraries they ship with.
|
||||||
|
> For these, consider the [NPM Installation](#npm-installation) option.
|
||||||
|
> * Note that moving the `balena` executable out of the extracted `balena-cli` folder on its own
|
||||||
|
> (e.g. moving it to `/usr/local/bin/balena`) will **not** work, as it depends on the other
|
||||||
|
> folders and files also present in the `balena-cli` folder.
|
||||||
|
|
||||||
|
To update the CLI to a new version, download a new release zip file and replace the previous
|
||||||
|
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
|
||||||
|
as described above.
|
||||||
|
|
||||||
|
## NPM Installation
|
||||||
|
|
||||||
|
If you are a Node.js developer, you may wish to install the balena CLI via [npm](https://www.npmjs.com).
|
||||||
|
The npm installation involves building native (platform-specific) binary modules, which require
|
||||||
|
some additional development tools to be installed first:
|
||||||
|
|
||||||
|
* [Node.js](https://nodejs.org/) version 10 (min **10.20.0**) or 12 (version 14 is not yet fully supported)
|
||||||
|
* **Linux, macOS** and **Windows Subsystem for Linux (WSL):**
|
||||||
|
Installing Node via [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md) is recommended.
|
||||||
|
When the "system" or "default" Node.js and npm packages are installed with "apt-get" in Linux
|
||||||
|
distributions like Ubuntu, users often report permission or compilation errors when running
|
||||||
|
"npm install". This [sample
|
||||||
|
Dockerfile](https://gist.github.com/pdcastro/5d4d96652181e7da685a32caf629dd44) shows the CLI
|
||||||
|
installation steps on an Ubuntu 18.04 base image.
|
||||||
|
* [Python 2.7](https://www.python.org/), [git](https://git-scm.com/), [make](https://www.gnu.org/software/make/), [g++](https://gcc.gnu.org/)
|
||||||
|
* **Linux** and **Windows Subsystem for Linux (WSL):**
|
||||||
|
`sudo apt-get install -y python git make g++`
|
||||||
|
* **macOS:** install Apple's Command Line Tools by running on a Terminal window:
|
||||||
|
`xcode-select --install`
|
||||||
|
|
||||||
|
On **Windows (not WSL),** the dependencies above and additional ones can be met by installing:
|
||||||
|
|
||||||
|
* Node.js from the [Nodejs.org download page](https://nodejs.org/en/download/).
|
||||||
|
* The [MSYS2 shell](https://www.msys2.org/), which provides `git`, `make`, `g++`, `ssh`, `rsync`
|
||||||
|
and more:
|
||||||
|
* `pacman -S git openssh rsync gcc make`
|
||||||
|
* [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit`
|
||||||
|
* Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based
|
||||||
|
interactive CLI menus to misbehave. [Check this Github issue for a
|
||||||
|
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
|
||||||
|
* The Windows Driver Kit (WDK), which is needed to compile some native Node modules. It is **not**
|
||||||
|
necessary to install Visual Studio, only the WDK, which is "step 2" in the following guides:
|
||||||
|
* [WDK for Windows 10](https://docs.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk#download-icon-step-2-install-wdk-for-windows-10-version-1903)
|
||||||
|
* [WDK for earlier versions of Windows](https://docs.microsoft.com/en-us/windows-hardware/drivers/other-wdk-downloads#step-2-install-the-wdk)
|
||||||
|
* The [windows-build-tools](https://www.npmjs.com/package/windows-build-tools) npm package (which
|
||||||
|
provides Python 2.7 and more), by running the following command on an [administrator
|
||||||
|
console](https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/):
|
||||||
|
|
||||||
|
`npm install -g --production windows-build-tools`
|
||||||
|
|
||||||
|
With these dependencies in place, the balena CLI installation command is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ npm install balena-cli -g --production --unsafe-perm
|
||||||
|
```
|
||||||
|
|
||||||
|
`--unsafe-perm` is required when `npm install` is executed as the root user, or on systems where
|
||||||
|
the global install directory is not user-writable. It allows npm install steps to download and save
|
||||||
|
prebuilt native binaries, and also allows the execution of npm scripts like `postinstall` that are
|
||||||
|
used to patch dependencies. It is usually possible to omit `--unsafe-perm` if installing under a
|
||||||
|
regular (non-root) user account, especially if using a user-managed node installation such as
|
||||||
|
[nvm](https://github.com/creationix/nvm).
|
||||||
|
|
||||||
|
## Additional Dependencies
|
||||||
|
|
||||||
|
The `balena ssh`, `scan`, `build`, `deploy`, `preload` and `os configure` commands may require
|
||||||
|
additional software to be installed. Check the Additional Dependencies sections for each operating
|
||||||
|
system:
|
||||||
|
|
||||||
|
* [Windows](./INSTALL-WINDOWS.md#additional-dependencies)
|
||||||
|
* [macOS](./INSTALL-MAC.md#additional-dependencies)
|
||||||
|
* [Linux](./INSTALL-LINUX.md#additional-dependencies)
|
||||||
|
|
||||||
|
The `build` and `deploy` commands are also capable of using Docker or balenaEngine on a remote
|
||||||
|
server, or on a balenaOS device running a [balenaOS development
|
||||||
|
image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images)). Reasons why this
|
||||||
|
may be desirable include:
|
||||||
|
|
||||||
|
* To avoid having to install Docker on the development machine / laptop.
|
||||||
|
* To take advantage of a more powerful server (CPU, memory).
|
||||||
|
* To build or run images "natively" on an ARM device, avoiding the need for QEMU emulation.
|
||||||
|
|
||||||
|
To use a remote Docker Engine (daemon) or balenaEngine, specify the remote machine's IP address and
|
||||||
|
port number with the `--dockerHost` and `--dockerPort` command-line options. For more details,
|
||||||
|
check `balena help build` or the [online
|
||||||
|
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).
|
66
INSTALL-LINUX.md
Normal file
66
INSTALL-LINUX.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# balena CLI Installation Instructions for Linux
|
||||||
|
|
||||||
|
These instructions are for the recommended installation option. They are suitable for most Linux
|
||||||
|
distributions, except notably for **Linux Alpine** or **Busybox**. For these distros, see [advanced
|
||||||
|
installation options](./INSTALL-ADVANCED.md).
|
||||||
|
|
||||||
|
Selected operating system: **Linux**
|
||||||
|
|
||||||
|
1. Download the latest zip file from the [latest release
|
||||||
|
page](https://github.com/balena-io/balena-cli/releases/latest). Look for a file name that ends
|
||||||
|
with "-standalone.zip", for example:
|
||||||
|
`balena-cli-vX.Y.Z-linux-x64-standalone.zip`
|
||||||
|
|
||||||
|
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
|
||||||
|
`balena-cli` folder.
|
||||||
|
|
||||||
|
3. Add the `balena-cli` folder to the system's `PATH` environment variable. There are several
|
||||||
|
ways of achieving this on Linux: See this [StackOverflow post](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix). Close and reopen the terminal window
|
||||||
|
so that the changes to PATH can take effect.
|
||||||
|
|
||||||
|
4. Check that the installation was successful by running the following commands on a
|
||||||
|
command terminal:
|
||||||
|
* `balena version` - should print the CLI's version
|
||||||
|
* `balena help` - should print a list of available commands
|
||||||
|
|
||||||
|
No further steps are required to run most CLI commands. The `balena ssh`, `scan`, `build`,
|
||||||
|
`deploy` and `preload` commands may require additional software to be installed, as described
|
||||||
|
below.
|
||||||
|
|
||||||
|
To update the balena CLI to a new version, download a new release zip file and replace the previous
|
||||||
|
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
|
||||||
|
as described above.
|
||||||
|
|
||||||
|
## Additional Dependencies
|
||||||
|
|
||||||
|
### build, deploy
|
||||||
|
|
||||||
|
These commands require [Docker](https://docs.docker.com/install/overview/) or
|
||||||
|
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
|
||||||
|
users will simply follow [Docker's installation
|
||||||
|
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
|
||||||
|
machine) where the balena CLI is installed. The [advanced installation
|
||||||
|
options](./INSTALL-ADVANCED.md) document describes other possibilities.
|
||||||
|
|
||||||
|
### balena ssh
|
||||||
|
|
||||||
|
The `balena ssh` command requires the `ssh` command-line tool to be available. Most Linux
|
||||||
|
distributions will already have it installed. Otherwise, `sudo apt-get install openssh-client`
|
||||||
|
should do the trick on Debian or Ubuntu.
|
||||||
|
|
||||||
|
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
|
||||||
|
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
|
||||||
|
command set can also be used to list and manage SSH keys: see `balena help -v`.
|
||||||
|
|
||||||
|
### balena scan
|
||||||
|
|
||||||
|
The `balena scan` command requires a multicast DNS (mDNS) service like
|
||||||
|
[Avahi](https://en.wikipedia.org/wiki/Avahi_(software)), which is installed by default on most
|
||||||
|
desktop Linux distributions. Otherwise, on Debian or Ubuntu, the installation command would be
|
||||||
|
`sudo apt-get install avahi-daemon`.
|
||||||
|
|
||||||
|
### balena preload
|
||||||
|
|
||||||
|
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
|
||||||
|
restriction that Docker must be installed on the local machine (because Docker's bind mounting
|
||||||
|
feature is used).
|
68
INSTALL-MAC.md
Normal file
68
INSTALL-MAC.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# balena CLI Installation Instructions for macOS
|
||||||
|
|
||||||
|
These instructions are for the recommended installation option. Advanced users may also be
|
||||||
|
interested in [advanced installation options](./INSTALL-ADVANCED.md).
|
||||||
|
|
||||||
|
Selected operating system: **macOS**
|
||||||
|
|
||||||
|
1. Download the installer from the [latest release
|
||||||
|
page](https://github.com/balena-io/balena-cli/releases/latest).
|
||||||
|
Look for a file name that ends with "-installer.pkg":
|
||||||
|
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
|
||||||
|
|
||||||
|
2. Double click the downloaded file to run the installer. After the installation completes,
|
||||||
|
close and re-open any open [command
|
||||||
|
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
|
||||||
|
windows (so that the changes made by the installer to the PATH environment variable can take
|
||||||
|
effect).
|
||||||
|
|
||||||
|
3. Check that the installation was successful by running the following commands on a
|
||||||
|
command terminal:
|
||||||
|
* `balena version` - should print the CLI's version
|
||||||
|
* `balena help` - should print a list of available commands
|
||||||
|
|
||||||
|
No further steps are required to run most CLI commands. The `balena ssh`, `build`, `deploy`
|
||||||
|
and `preload` commands may require additional software to be installed, as described below.
|
||||||
|
|
||||||
|
## Additional Dependencies
|
||||||
|
|
||||||
|
### build and deploy
|
||||||
|
|
||||||
|
These commands require [Docker](https://docs.docker.com/install/overview/) or
|
||||||
|
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
|
||||||
|
users will simply follow [Docker's installation
|
||||||
|
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
|
||||||
|
machine) where the balena CLI is installed. The [advanced installation
|
||||||
|
options](./INSTALL-ADVANCED.md) document describes other possibilities.
|
||||||
|
|
||||||
|
### balena ssh
|
||||||
|
|
||||||
|
The `balena ssh` command requires the `ssh` command-line tool to be available. To check whether
|
||||||
|
it is already installed, run `ssh` on a Terminal window. If it is not yet installed, the options
|
||||||
|
include:
|
||||||
|
|
||||||
|
* Download the Xcode Command Line Tools from https://developer.apple.com/downloads
|
||||||
|
* Or, if you have Xcode installed, open Xcode, choose Preferences → General → Downloads →
|
||||||
|
Components → Command Line Tools → Install.
|
||||||
|
* Or, install [Homebrew](https://brew.sh/), then `brew install openssh`
|
||||||
|
|
||||||
|
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
|
||||||
|
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
|
||||||
|
command set can also be used to list and manage SSH keys: see `balena help -v`.
|
||||||
|
|
||||||
|
### balena preload
|
||||||
|
|
||||||
|
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
|
||||||
|
restriction that Docker must be installed on the local machine (because Docker's bind mounting
|
||||||
|
feature is used). Also, for some device types (such as the Raspberry Pi), the `preload` command
|
||||||
|
requires Docker to support the [AUFS storage
|
||||||
|
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop
|
||||||
|
for Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
|
||||||
|
present workaround is to either:
|
||||||
|
|
||||||
|
* Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for
|
||||||
|
Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29)
|
||||||
|
* Install the balena CLI on a Linux machine (as Docker for Linux still supports AUFS). A Linux
|
||||||
|
Virtual Machine also works, but a Docker container is _not_ recommended.
|
||||||
|
|
||||||
|
Long term, we are working on replacing AUFS with overlay2 for the affected device types.
|
82
INSTALL-WINDOWS.md
Normal file
82
INSTALL-WINDOWS.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# balena CLI Installation Instructions for Windows
|
||||||
|
|
||||||
|
These instructions are for the recommended installation option. Advanced users may also be
|
||||||
|
interested in [advanced installation options](./INSTALL-ADVANCED.md).
|
||||||
|
|
||||||
|
Selected operating system: **Windows**
|
||||||
|
|
||||||
|
1. Download the installer from the [latest release
|
||||||
|
page](https://github.com/balena-io/balena-cli/releases/latest).
|
||||||
|
Look for a file name that ends with "-installer.exe":
|
||||||
|
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
|
||||||
|
|
||||||
|
2. Double click the downloaded file to run the installer. After the installation completes,
|
||||||
|
close and re-open any open [command
|
||||||
|
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
|
||||||
|
windows (so that the changes made by the installer to the PATH environment variable can take
|
||||||
|
effect).
|
||||||
|
|
||||||
|
3. Check that the installation was successful by running the following commands on a
|
||||||
|
command terminal:
|
||||||
|
* `balena version` - should print the CLI's version
|
||||||
|
* `balena help` - should print a list of available commands
|
||||||
|
|
||||||
|
No further steps are required to run most CLI commands. The `balena ssh`, `scan`, `build`,
|
||||||
|
`deploy`, `preload` and `os configure` commands may require additional software to be installed, as
|
||||||
|
described below.
|
||||||
|
|
||||||
|
## Additional Dependencies
|
||||||
|
|
||||||
|
### build and deploy
|
||||||
|
|
||||||
|
These commands require [Docker](https://docs.docker.com/install/overview/) or
|
||||||
|
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
|
||||||
|
users will simply follow [Docker's installation
|
||||||
|
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
|
||||||
|
machine) where the balena CLI is installed. The [advanced installation
|
||||||
|
options](./INSTALL-ADVANCED.md) document describes other possibilities.
|
||||||
|
|
||||||
|
### balena ssh
|
||||||
|
|
||||||
|
The `balena ssh` command requires the `ssh` command-line tool to be available. Microsoft started
|
||||||
|
distributing an SSH client with Windows 10, which is automatically installed through Windows
|
||||||
|
Update. To check whether it is installed, run `ssh` on a Windows Command Prompt or PowerShell. It
|
||||||
|
can also be [manually
|
||||||
|
installed](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)
|
||||||
|
if needed. For older versions of Windows, there are several ssh/OpenSSH clients provided by 3rd
|
||||||
|
parties.
|
||||||
|
|
||||||
|
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
|
||||||
|
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
|
||||||
|
command set can also be used to list and manage SSH keys: see `balena help -v`.
|
||||||
|
|
||||||
|
### balena scan
|
||||||
|
|
||||||
|
The `balena scan` command requires a multicast DNS (mDNS) service like Apple's Bonjour.
|
||||||
|
Many Windows machines will already have this service installed, as it is bundled in popular
|
||||||
|
applications such as Skype (Wikipedia lists [several others](https://en.wikipedia.org/wiki/Bonjour_(software))).
|
||||||
|
Otherwise, Bonjour for Windows can be downloaded and installed from: https://support.apple.com/kb/DL999
|
||||||
|
|
||||||
|
### balena preload
|
||||||
|
|
||||||
|
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
|
||||||
|
restriction that Docker must be installed on the local machine (because Docker's bind mounting
|
||||||
|
feature is used). Also, for some device types (such as the Raspberry Pi), the `preload` command
|
||||||
|
requires Docker to support the [AUFS storage
|
||||||
|
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop
|
||||||
|
for Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
|
||||||
|
present workaround is to either:
|
||||||
|
|
||||||
|
* Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for
|
||||||
|
Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29)
|
||||||
|
* Install the balena CLI on a Linux machine (as Docker for Linux still supports AUFS). A Linux
|
||||||
|
Virtual Machine also works, but a Docker container is _not_ recommended.
|
||||||
|
|
||||||
|
Long term, we are working on replacing AUFS with overlay2 for the affected device types.
|
||||||
|
|
||||||
|
### balena os configure
|
||||||
|
|
||||||
|
* The `balena os configure` command is currently not supported on Windows natively, but works with
|
||||||
|
the [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL). When
|
||||||
|
using WSL, [install the balena CLI for
|
||||||
|
Linux](https://github.com/balena-io/balena-cli/blob/master/INSTALL-LINUX.md).
|
162
INSTALL.md
162
INSTALL.md
@ -1,158 +1,12 @@
|
|||||||
# balena CLI Installation Instructions
|
# balena CLI Installation Instructions
|
||||||
|
|
||||||
There are 3 options to choose from to install balena's CLI:
|
Please select your operating system:
|
||||||
|
|
||||||
* [Executable Installer](#executable-installer): the easiest method, using the traditional
|
* [Windows](./INSTALL-WINDOWS.md)
|
||||||
graphical desktop application installers for Windows and macOS (coming soon for Linux users too).
|
* [macOS](./INSTALL-MAC.md)
|
||||||
* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI
|
* [Linux](./INSTALL-LINUX.md)
|
||||||
executable in them. Recommended for scripted installation in CI (continuous integration)
|
|
||||||
environments.
|
|
||||||
* [NPM Installation](#npm-installation): recommended for developers who may be interested in
|
|
||||||
integrating the balena CLI in their existing Node.js projects or workflow.
|
|
||||||
|
|
||||||
Some specific CLI commands have a few extra installation steps: see section [Additional
|
> Note regarding WSL ([Windows Subsystem for
|
||||||
Dependencies](#additional-dependencies).
|
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about))
|
||||||
|
> If you would like to use WSL, follow the installations instructions for Linux
|
||||||
> **Windows users:** We now have a [YouTube video tutorial](https://www.youtube.com/watch?v=2LApclXFqsg)
|
> rather than Windows, as WSL consists of a Linux environment.
|
||||||
for installing and getting started with the balena CLI on Windows!
|
|
||||||
|
|
||||||
## Executable Installer
|
|
||||||
|
|
||||||
_Please note: the executable installers are in **beta** status (recently introduced)._
|
|
||||||
|
|
||||||
1. Download the latest installer from the [releases page](https://github.com/balena-io/balena-cli/releases).
|
|
||||||
Look for a file name that ends with "installer-BETA", for example:
|
|
||||||
`balena-cli-v10.13.6-windows-x64-installer-BETA.exe`
|
|
||||||
`balena-cli-v10.13.6-macOS-x64-installer-BETA.pkg`
|
|
||||||
2. Double click to run. Your system may raise a pop-up warning that the installer is from an
|
|
||||||
"unknown publisher" or "unidentified developer". Check the following instructions for how
|
|
||||||
to get through the warnings:
|
|
||||||
[Windows](https://github.com/balena-io/balena-cli/issues/1250) or
|
|
||||||
[macOS](https://github.com/balena-io/balena-cli/issues/1251).
|
|
||||||
(We are looking at how to get the installers digitally signed to avoid the warnings.)
|
|
||||||
|
|
||||||
After the installation completes, close and re-open any open command terminal windows so that the
|
|
||||||
changes made by the installer to the PATH environment variable can take effect. Check that the
|
|
||||||
installation was successful by running these commands:
|
|
||||||
|
|
||||||
* `balena` - should print the balena CLI help
|
|
||||||
* `balena version` - should print the installed CLI version
|
|
||||||
|
|
||||||
> Note: If you had previously installed the CLI using a standalone zip package, it may be a good
|
|
||||||
> idea to check your system's `PATH` environment variable for duplicate entries, as the terminal
|
|
||||||
> will use the entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package)
|
|
||||||
> instructions for how to modify the PATH variable.
|
|
||||||
|
|
||||||
By default, the CLI is installed to the following folders:
|
|
||||||
|
|
||||||
OS | Folders
|
|
||||||
--- | ---
|
|
||||||
Windows: | `C:\Program Files\balena-cli\`
|
|
||||||
macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
|
|
||||||
|
|
||||||
## Standalone Zip Package
|
|
||||||
|
|
||||||
1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases).
|
|
||||||
Look for a file name that ends with the word "standalone", for example:
|
|
||||||
`balena-cli-v10.13.6-linux-x64-standalone.zip`
|
|
||||||
`balena-cli-v10.13.6-macOS-x64-standalone.zip`
|
|
||||||
`balena-cli-v10.13.6-windows-x64-standalone.zip`
|
|
||||||
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
|
|
||||||
`balena-cli` folder.
|
|
||||||
3. Add the `balena-cli` folder to the system's `PATH` environment variable.
|
|
||||||
See instructions for:
|
|
||||||
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
|
|
||||||
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
|
|
||||||
[Windows](https://www.computerhope.com/issues/ch000549.htm)
|
|
||||||
|
|
||||||
To update the CLI to a new version, download a new release zip file and replace the previous
|
|
||||||
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
|
|
||||||
as described above.
|
|
||||||
|
|
||||||
## NPM Installation
|
|
||||||
|
|
||||||
If you are a Node.js developer, you may wish to install the balena CLI via [npm](https://www.npmjs.com).
|
|
||||||
The npm installation involves building native (platform-specific) binary modules, which require
|
|
||||||
some additional development tools to be installed first:
|
|
||||||
|
|
||||||
* Node.js version 8 or 10 (v12 has not been thoroughly tested yet)
|
|
||||||
* Python 2.7
|
|
||||||
* g++ compiler
|
|
||||||
* make
|
|
||||||
* git
|
|
||||||
* On Windows, the `windows-build-tools` npm package should be installed too, running the following
|
|
||||||
command in an administrator console (available as "Command Prompt (Admin)" or "Windows PowerShell
|
|
||||||
(Admin)" when typing Windows+X):
|
|
||||||
`npm install -g --production windows-build-tools`
|
|
||||||
|
|
||||||
With those in place, the CLI installation command is:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm install balena-cli -g --production --unsafe-perm
|
|
||||||
```
|
|
||||||
|
|
||||||
`--unsafe-perm` is only required on systems where the global install directory is not user-writable.
|
|
||||||
This allows npm install steps to download and save prebuilt native binaries. You may be able to omit it,
|
|
||||||
especially if you're using a user-managed node install such as [nvm](https://github.com/creationix/nvm).
|
|
||||||
|
|
||||||
On some Linux distributions like Ubuntu, users often report permission or otherwise mysterious
|
|
||||||
errors when using the system Node / npm packages installed via "apt-get". We suggest using
|
|
||||||
[nvm](https://github.com/creationix/nvm) instead. Check this sample Dockerfile for installing the
|
|
||||||
CLI on an Ubuntu Docker image: https://gist.github.com/pdcastro/5d4d96652181e7da685a32caf629dd44
|
|
||||||
|
|
||||||
## Additional Dependencies
|
|
||||||
|
|
||||||
* The `balena ssh` command requires a recent version of the `ssh` command-line tool to be available:
|
|
||||||
* macOS and Linux usually already have it installed. Otherwise, search for the available packages
|
|
||||||
on your specific Linux distribution, or for the Mac consider the [Xcode command-line
|
|
||||||
tools](https://developer.apple.com/xcode/features/) or [homebrew](https://brew.sh/).
|
|
||||||
|
|
||||||
* Microsoft started distributing an SSH client with Windows 10, which we understand is
|
|
||||||
automatically installed through Windows Update, but can be manually installed too
|
|
||||||
([more information](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)).
|
|
||||||
For other versions of Windows, there are several ssh/OpenSSH clients provided by 3rd parties.
|
|
||||||
|
|
||||||
* If you need SSH to work behind a proxy, you will also need to install
|
|
||||||
[`proxytunnel`](http://proxytunnel.sourceforge.net/) (available as a `proxytunnel` package
|
|
||||||
for Ubuntu, for example).
|
|
||||||
Check the [README](https://github.com/balena-io/balena-cli/blob/master/README.md) file
|
|
||||||
for proxy configuration instructions.
|
|
||||||
|
|
||||||
* The `balena sync` command (deprecated) currently requires `rsync` (>= 2.6.9) to be installed:
|
|
||||||
* Linux: `apt-get install rsync`
|
|
||||||
* macOS: [Xcode command-line tools](https://developer.apple.com/xcode/features/) or [homebrew](https://brew.sh/)
|
|
||||||
* Windows: One option is to use the [MinGW](http://www.mingw.org) shell and install the
|
|
||||||
`msys-rsync` package. Check the README file for other shell options under Windows.
|
|
||||||
|
|
||||||
## Configuring SSH keys
|
|
||||||
|
|
||||||
The `balena ssh` command requires an SSH key to be added to your balena account. If you had
|
|
||||||
already added a SSH key in order to [deploy with 'git push'](https://www.balena.io/docs/learn/getting-started/raspberrypi3/nodejs/#adding-an-ssh-key),
|
|
||||||
then you are probably done and may skip this section. You can check whether you already have
|
|
||||||
an SSH key in your balena account with the `balena keys` command, or by visiting the
|
|
||||||
[balena web dashboard](https://dashboard.balena-cloud.com/), clicking on your name -> Preferences
|
|
||||||
-> SSH Keys.
|
|
||||||
|
|
||||||
> Note: An "SSH key" actually consists of a public/private key pair. A typical name for the private
|
|
||||||
> key file is "id_rsa", and a typical name for the public key file is "id_rsa.pub". Both key files
|
|
||||||
> are saved to your computer (with the private key optionally protected by a password), but only
|
|
||||||
> the public key is saved to your balena account. This means that if you change computers or
|
|
||||||
> otherwise lose the private key, _you cannot recover the private key through your balena account._
|
|
||||||
> You can however add new keys, and delete the old ones.
|
|
||||||
|
|
||||||
If you don't have an SSH key in your balena account:
|
|
||||||
|
|
||||||
* If you have an existing SSH key in your computer that you would like to use, you can add it
|
|
||||||
to your balena account through the balena web dashboard (Preferences -> SSH Keys), or through
|
|
||||||
the CLI itself:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows 10 (cmd.exe prompt) example:
|
|
||||||
$ balena key add MyKey %userprofile%\.ssh\id_rsa.pub
|
|
||||||
# Linux / macOS example:
|
|
||||||
$ balena key add MyKey ~/.ssh/id_rsa.pub
|
|
||||||
```
|
|
||||||
|
|
||||||
* To generate a new key, you can follow [GitHub's documentation](https://help.github.com/en/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent),
|
|
||||||
skipping the step about adding the key to your GitHub account, and instead adding the key to
|
|
||||||
your balena account as described above.
|
|
||||||
|
161
README.md
161
README.md
@ -1,46 +1,58 @@
|
|||||||
# balena CLI
|
# balena CLI
|
||||||
|
|
||||||
The official balena CLI tool.
|
The official balena Command Line Interface.
|
||||||
|
|
||||||
[](http://badge.fury.io/js/balena-cli)
|
[](http://badge.fury.io/js/balena-cli)
|
||||||
[](https://david-dm.org/balena-io/balena-cli)
|
[](https://david-dm.org/balena-io/balena-cli)
|
||||||
[](https://gitter.im/balena-io/chat)
|
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
The balena CLI (Command-Line Interface) allows you to interact with the balenaCloud and the
|
The balena CLI is a Command Line Interface for [balenaCloud](https://www.balena.io/cloud/) or
|
||||||
[balena API](https://www.balena.io/docs/reference/api/overview/) through a terminal window
|
[openBalena](https://www.balena.io/open/). It is a software tool available for Windows, macOS and
|
||||||
on Linux, macOS or Windows. You can also write shell scripts around it, or import its Node.js
|
Linux, used through a command prompt / terminal window. It can be used interactively or invoked in
|
||||||
modules to use it programmatically.
|
scripts. The balena CLI builds on the [balena API](https://www.balena.io/docs/reference/api/overview/)
|
||||||
As an [open-source project on GitHub](https://github.com/balena-io/balena-cli/), your contribution
|
and the [balena SDK](https://www.balena.io/docs/reference/sdk/node-sdk/), and can also be directly
|
||||||
is also welcome!
|
imported in Node.js applications. The balena CLI is an [open-source project on
|
||||||
|
GitHub](https://github.com/balena-io/balena-cli/), and your contribution is also welcome!
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Check the [balena CLI installation instructions on GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
|
Check the [balena CLI installation instructions on
|
||||||
|
GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
|
||||||
|
|
||||||
## Getting Started
|
## Choosing a shell (command prompt/terminal)
|
||||||
|
|
||||||
### Choosing a shell (command prompt/terminal)
|
|
||||||
|
|
||||||
On **Windows,** the standard Command Prompt (`cmd.exe`) and
|
On **Windows,** the standard Command Prompt (`cmd.exe`) and
|
||||||
[PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell?view=powershell-6)
|
[PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell?view=powershell-6)
|
||||||
are supported. We are aware of users also having a good experience with alternative shells,
|
are supported. Alternative shells include:
|
||||||
including:
|
|
||||||
|
|
||||||
* Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about)
|
* [MSYS2](https://www.msys2.org/):
|
||||||
(a.k.a. Microsoft's "bash for Windows 10")
|
* Install additional packages with the command:
|
||||||
|
`pacman -S git openssh rsync`
|
||||||
|
* [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit`
|
||||||
|
* Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based interactive CLI
|
||||||
|
menus to break. [Check this Github issue for a
|
||||||
|
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
|
||||||
|
* [MSYS](http://www.mingw.org/wiki/MSYS): select the `msys-rsync` and `msys-openssh` packages too
|
||||||
* [Git for Windows](https://git-for-windows.github.io/)
|
* [Git for Windows](https://git-for-windows.github.io/)
|
||||||
* [MSYS](http://www.mingw.org/wiki/MSYS) and [MSYS2](https://www.msys2.org/) (install the
|
* During the installation, you will be prompted to choose between _"Use MinTTY"_ and _"Use
|
||||||
`msys-rsync` and `msys-openssh` packages too)
|
Windows' default console window"._ Choose the latter, because of the same [MSYS2
|
||||||
|
bug](https://github.com/msys2/MINGW-packages/issues/1633) mentioned above (Git for Windows
|
||||||
|
actually uses MSYS2). For a screenshot, check this
|
||||||
|
[comment](https://github.com/balena-io/balena-cli/issues/598#issuecomment-556513098).
|
||||||
|
* Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about)
|
||||||
|
(WSL). In this case, a Linux distribution like Ubuntu is installed via the Microsoft Store, and a
|
||||||
|
balena CLI release **for Linux** should be selected. See
|
||||||
|
[FAQ](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md) for using the
|
||||||
|
balena CLI with WSL and Docker Desktop for Windows.
|
||||||
|
|
||||||
On **macOS** and **Linux,** the standard terminal window is supported. _Optionally,_ `bash` command
|
On **macOS** and **Linux,** the standard terminal window is supported. Optionally, `bash` command
|
||||||
auto completion may be enabled by copying the
|
auto completion may be enabled by copying the
|
||||||
[balena-completion.bash](https://github.com/balena-io/balena-cli/blob/master/balena-completion.bash)
|
[balena-completion.bash](https://github.com/balena-io/balena-cli/blob/master/balena-completion.bash)
|
||||||
file to your system's `bash_completion` directory: check [Docker's command completion
|
file to your system's `bash_completion` directory: check [Docker's command completion
|
||||||
guide](https://docs.docker.com/compose/completion/) for system setup instructions.
|
guide](https://docs.docker.com/compose/completion/) for system setup instructions.
|
||||||
|
|
||||||
### Logging in
|
## Logging in
|
||||||
|
|
||||||
Several CLI commands require access to your balenaCloud account, for example in order to push a
|
Several CLI commands require access to your balenaCloud account, for example in order to push a
|
||||||
new release to your application. Those commands require creating a CLI login session by running:
|
new release to your application. Those commands require creating a CLI login session by running:
|
||||||
@ -49,35 +61,108 @@ new release to your application. Those commands require creating a CLI login ses
|
|||||||
$ balena login
|
$ balena login
|
||||||
```
|
```
|
||||||
|
|
||||||
### Proxy support
|
## Proxy support
|
||||||
|
|
||||||
HTTP(S) proxies can be configured through any of the following methods, in order of preference:
|
HTTP(S) proxies can be configured through any of the following methods, in precedence order
|
||||||
|
(from higher to lower):
|
||||||
|
|
||||||
* Set the `BALENARC_PROXY` environment variable in URL format (with protocol, host, port, and
|
* The `BALENARC_PROXY` environment variable in URL format, with protocol (`http` or `https`),
|
||||||
optionally basic auth).
|
host, port and optionally basic auth. Examples:
|
||||||
* Alternatively, use the [balena config file](https://www.npmjs.com/package/balena-settings-client#documentation)
|
* `export BALENARC_PROXY='https://bob:secret@proxy.company.com:12345'`
|
||||||
(project-specific or user-level) and set the `proxy` setting. It can be:
|
* `export BALENARC_PROXY='http://localhost:8000'`
|
||||||
* A string in URL format, or
|
|
||||||
* An object in the [global-tunnel-ng options format](https://www.npmjs.com/package/global-tunnel-ng#options) (which allows more control).
|
|
||||||
* Alternatively, set the conventional `https_proxy` / `HTTPS_PROXY` / `http_proxy` / `HTTP_PROXY`
|
|
||||||
environment variable (in the same standard URL format).
|
|
||||||
|
|
||||||
To get a proxy to work with the `balena ssh` command, check the
|
* The `proxy` setting in the [CLI config
|
||||||
[installation instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
|
file](https://www.npmjs.com/package/balena-settings-client#documentation). It may be:
|
||||||
|
* A string in URL format, e.g. `proxy: 'http://localhost:8000'`
|
||||||
|
* An object in the format:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
proxy:
|
||||||
|
protocol: 'http'
|
||||||
|
host: 'proxy.company.com'
|
||||||
|
port: 12345
|
||||||
|
proxyAuth: 'bob:secret'
|
||||||
|
```
|
||||||
|
|
||||||
|
* The `HTTPS_PROXY` and/or `HTTP_PROXY` environment variables, in the same URL format as
|
||||||
|
`BALENARC_PROXY`.
|
||||||
|
|
||||||
|
### Proxy setup for balena ssh
|
||||||
|
|
||||||
|
In order to work behind a proxy server, the `balena ssh` command requires the
|
||||||
|
[`proxytunnel`](http://proxytunnel.sourceforge.net/) package (command-line tool) to be installed.
|
||||||
|
`proxytunnel` is available for Linux distributions like Ubuntu/Debian (`apt install proxytunnel`),
|
||||||
|
and for macOS through [Homebrew](https://brew.sh/). Windows support is limited to the [Windows
|
||||||
|
Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (e.g., by installing
|
||||||
|
Ubuntu through the Microsoft App Store).
|
||||||
|
|
||||||
|
Ensure that the proxy server is configured to allow proxy requests to ssh port 22, using
|
||||||
|
SSL encryption. For example, in the case of the [Squid](http://www.squid-cache.org/) proxy
|
||||||
|
server, it should be configured with the following rules in the `squid.conf` file:
|
||||||
|
`acl SSL_ports port 22`
|
||||||
|
`acl Safe_ports port 22`
|
||||||
|
|
||||||
|
### Proxy exclusion
|
||||||
|
|
||||||
|
The `BALENARC_NO_PROXY` variable may be used to exclude specified destinations from proxying.
|
||||||
|
|
||||||
|
> * This feature requires CLI version 11.30.8 or later. In the case of the npm [installation
|
||||||
|
> option](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md), it also requires
|
||||||
|
> Node.js version 10.16.0 or later.
|
||||||
|
> * To exclude a `balena ssh` target from proxying (IP address or `.local` hostname), the
|
||||||
|
> `--noproxy` option should be specified in addition to the `BALENARC_NO_PROXY` variable.
|
||||||
|
|
||||||
|
By default (if `BALENARC_NO_PROXY` is not defined), all [private IPv4
|
||||||
|
addresses](https://en.wikipedia.org/wiki/Private_network) and `'*.local'` hostnames are excluded
|
||||||
|
from proxying. Other hostnames that resolve to private IPv4 addresses are **not** excluded by
|
||||||
|
default, because matching takes place before name resolution.
|
||||||
|
|
||||||
|
`localhost` and `127.0.0.1` are always excluded from proxying, regardless of the value of
|
||||||
|
BALENARC_NO_PROXY.
|
||||||
|
|
||||||
|
The format of the `BALENARC_NO_PROXY` environment variable is a comma-separated list of patterns
|
||||||
|
that are matched against hostnames or IP addresses. For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
export BALENARC_NO_PROXY='*.local,dev*.mycompany.com,192.168.*'
|
||||||
|
```
|
||||||
|
|
||||||
|
Matched patterns are excluded from proxying. Wildcard expressions are documented at
|
||||||
|
[matcher](https://www.npmjs.com/package/matcher#usage). Matching takes place _before_ name
|
||||||
|
resolution, so a pattern like `'192.168.*'` will **not** match a hostname that resolves to an IP
|
||||||
|
address like `192.168.1.2`.
|
||||||
|
|
||||||
## Command reference documentation
|
## Command reference documentation
|
||||||
|
|
||||||
The full CLI command reference is available [on the web](https://www.balena.io/docs/reference/cli/
|
The full CLI command reference is available [on the web](https://www.balena.io/docs/reference/cli/
|
||||||
) or by running `balena help` and `balena help --verbose`.
|
) or by running `balena help --verbose`.
|
||||||
|
|
||||||
## Support, FAQ and troubleshooting
|
## Support, FAQ and troubleshooting
|
||||||
|
|
||||||
If you come across any problems or would like to get in touch:
|
To learn more, troubleshoot issues, or to contact us for support:
|
||||||
|
|
||||||
* Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md).
|
* Check the [masterclass tutorials](https://www.balena.io/docs/learn/more/masterclasses/overview/)
|
||||||
* Ask us a question through the [balenaCloud forum](https://forums.balena.io/c/balena-cloud).
|
* Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md)
|
||||||
* For bug reports or feature requests,
|
* Ask us a question through the [balenaCloud forum](https://forums.balena.io/c/balena-cloud)
|
||||||
[have a look at the GitHub issues or create a new one](https://github.com/balena-io/balena-cli/issues/).
|
|
||||||
|
For CLI bug reports or feature requests, check the
|
||||||
|
[CLI GitHub issues](https://github.com/balena-io/balena-cli/issues/).
|
||||||
|
|
||||||
|
## Deprecation policy
|
||||||
|
|
||||||
|
The balena CLI uses [semver versioning](https://semver.org/), with the concepts
|
||||||
|
of major, minor and patch version releases.
|
||||||
|
|
||||||
|
The latest release of a major version of the balena CLI will remain compatible with
|
||||||
|
the balenaCloud backend services for at least one year from the date when the
|
||||||
|
following major version is released. For example, balena CLI v10.17.5, as the
|
||||||
|
latest v10 release, would remain compatible with the balenaCloud backend for one
|
||||||
|
year from the date when v11.0.0 is released.
|
||||||
|
|
||||||
|
At the end of this period, the older major version is considered deprecated and
|
||||||
|
some of the functionality that depends on balenaCloud services may stop working
|
||||||
|
at any time.
|
||||||
|
Users are encouraged to regularly update the balena CLI to the latest version.
|
||||||
|
|
||||||
## Contributing (including editing documentation files)
|
## Contributing (including editing documentation files)
|
||||||
|
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
# FAQ & Troubleshooting
|
# balena CLI FAQ & Troubleshooting
|
||||||
|
|
||||||
This document contains some common issues, questions and answers related to the balena CLI.
|
## Where is the balena CLI's configuration file located?
|
||||||
|
|
||||||
## Where is my configuration file?
|
|
||||||
|
|
||||||
The per-user configuration file lives in `$HOME/.balenarc.yml` or `%UserProfile%\_balenarc.yml`, in
|
The per-user configuration file lives in `$HOME/.balenarc.yml` or `%UserProfile%\_balenarc.yml`, in
|
||||||
Unix based operating systems and Windows respectively.
|
Unix based operating systems and Windows respectively.
|
||||||
@ -10,53 +8,43 @@ Unix based operating systems and Windows respectively.
|
|||||||
The balena CLI also attempts to read a `balenarc.yml` file in the current directory, which takes
|
The balena CLI also attempts to read a `balenarc.yml` file in the current directory, which takes
|
||||||
precedence over the per-user configuration file.
|
precedence over the per-user configuration file.
|
||||||
|
|
||||||
## How do I point the balena CLI to staging?
|
## How do I point the balena CLI to the staging environment?
|
||||||
|
|
||||||
The easiest way is to set the `BALENARC_BALENA_URL=balena-staging.com` environment variable.
|
Set the `BALENARC_BALENA_URL=balena-staging.com` environment variable, or add
|
||||||
|
`balenaUrl: balena-staging.com` to the balena CLI's configuration file.
|
||||||
Alternatively, you can edit your configuration file and set `balenaUrl: balena-staging.com` to
|
|
||||||
persist this setting.
|
|
||||||
|
|
||||||
## How do I make the balena CLI persist data in another directory?
|
## How do I make the balena CLI persist data in another directory?
|
||||||
|
|
||||||
The balena CLI persists your session token, as well as cached images in `$HOME/.balena` or
|
The balena CLI persists the session token, as well as cached assets, to `$HOME/.balena` or
|
||||||
`%UserProfile%\_balena`.
|
`%UserProfile%\_balena`. This directory can be changed by setting an environment variable,
|
||||||
|
`BALENARC_DATA_DIRECTORY=/opt/balena`, or by adding `dataDirectory: /opt/balena` to the CLI's
|
||||||
|
configuration file, replacing `/opt/balena` with the desired directory.
|
||||||
|
|
||||||
Pointing the balena CLI to persist data in another location is necessary in certain environments,
|
## After burning to an SD card, my device doesn't boot
|
||||||
like a server, where there is no home directory, or a device running balenaOS, which erases all
|
|
||||||
data after a restart.
|
|
||||||
|
|
||||||
You can accomplish this by setting `BALENARC_DATA_DIRECTORY=/opt/balena` or adding `dataDirectory:
|
Check whether the downloaded image is incomplete (download was interrupted) or corrupted.
|
||||||
/opt/balena` to your configuration file, replacing `/opt/balena` with your desired directory.
|
|
||||||
|
|
||||||
## After burning to an sdcard, my device doesn't boot
|
Try clearing the cache (`%HOME/.balena/cache` or `C:\Users\<user>\_balena\cache`) and running the
|
||||||
|
command again.
|
||||||
|
|
||||||
- The downloaded image is not complete (download was interrupted).
|
## I get a permission error when burning to an SD card
|
||||||
|
|
||||||
Please clean the cache (`%HOME/.balena/cache` or `C:\Users\<user>\_balena\cache`) and run the command again. In the future, the CLI will check that the image is not complete and clean the cache for you.
|
Check whether the SD card is locked (a physical switch on the side of the card).
|
||||||
|
|
||||||
## I get a permission error when burning to an sdcard
|
## I get EINVAL errors on Cygwin
|
||||||
|
|
||||||
- The SDCard is locked.
|
The errors may look something like this:
|
||||||
|
|
||||||
### I get EINVAL errors on Cygwin
|
|
||||||
|
|
||||||
The errors look something like this:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
net.js:156
|
net.js:156
|
||||||
this._handle.open(options.fd);
|
this._handle.open(options.fd);
|
||||||
^
|
^
|
||||||
Error: EINVAL, invalid argument
|
Error: EINVAL, invalid argument
|
||||||
at new Socket (net.js:156:18)
|
|
||||||
at process.stdin (node.js:664:19)
|
|
||||||
at Object.Interface.createInterface (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\node_modules\readline2\index.js:31:43)
|
|
||||||
at PromptUI.UI (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\ui\baseUI.js:23:40)
|
|
||||||
at new PromptUI (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\ui\prompt.js:26:8)
|
|
||||||
at Object.promptModule [as prompt] (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\inquirer.js:27:14)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Some interactive widgets don't work on `Cygwin`. If you're running Windows, it's preferrable that you use `cmd.exe`, as `Cygwin` is [not official supported by Node.js](https://github.com/chjj/blessed/issues/56#issuecomment-42671945).
|
Some interactive widgets don't work on `Cygwin`. On Windows, PowerShell or `cmd.exe` are better
|
||||||
|
supported. Alternative shells are [listed in the README
|
||||||
|
file](./README.md#choosing-a-shell-command-promptterminal).
|
||||||
|
|
||||||
## I get `Invalid MBR boot signature` when configuring a device
|
## I get `Invalid MBR boot signature` when configuring a device
|
||||||
|
|
||||||
@ -76,10 +64,63 @@ Or in Windows:
|
|||||||
|
|
||||||
## I get `EACCES: permission denied` when logging in
|
## I get `EACCES: permission denied` when logging in
|
||||||
|
|
||||||
The balena CLI stores the session token in `$HOME/.balena` or `C:\Users\<user>\_balena` in UNIX based operating systems and Windows respectively. This error usually indicates that the user doesn't have permissions over that directory, which can happen if you ran the balena CLI as `root`, and thus the directory got owned by him.
|
The balena CLI stores the session token in `$HOME/.balena` or `C:\Users\<user>\_balena` in UNIX based
|
||||||
|
operating systems and Windows respectively. This error usually indicates that the user doesn't have
|
||||||
|
permissions over that directory, which can happen if the CLI was executed as the `root` user.
|
||||||
|
|
||||||
Try resetting the ownership by running:
|
Try resetting the ownership by running:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ sudo chown -R <user> $HOME/.balena
|
$ sudo chown -R <user> $HOME/.balena
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Broken line wrapping / cursor behavior with `balena ssh`
|
||||||
|
|
||||||
|
Users sometimes come across broken line wrapping or cursor behavior in text terminals, for example
|
||||||
|
when long command lines are typed in a `balena ssh` session, or when using text editors like `vim`
|
||||||
|
or `nano`. This is not something specific to the balena CLI, being also a commonly reported issue
|
||||||
|
with standard remote terminal tools like `ssh` or `telnet`. It is often a remote shell
|
||||||
|
configuration issue (files like `/etc/profile`, `~/.bash_profile`, `~/.bash_login`, `~/.profile`
|
||||||
|
and the like on the remote machine), including UTF-8 misconfiguration, the use of unsupported ASCII
|
||||||
|
control characters in shell prompt formatting (e.g. the `$PS1` env var) or the output of tools or
|
||||||
|
log files that use colored text. The issue can sometimes be fixed by simply resizing the client
|
||||||
|
terminal window, or by running one or more of the following commands on the shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export TERMINAL=linux
|
||||||
|
stty sane
|
||||||
|
shopt -s checkwinsize
|
||||||
|
bind 'set horizontal-scroll-mode off'
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal multiplexer tools like GNU `screen` or `tmux` are sometimes reported to fix the issues, though at other times they are reported as the _cause_ of the problem. They have their own configuration files to take into account.
|
||||||
|
|
||||||
|
Further reference:
|
||||||
|
* https://stackoverflow.com/questions/1133031/shell-prompt-line-wrapping-issue
|
||||||
|
* https://superuser.com/questions/46948/any-way-to-fix-screens-mishandling-of-line-wrap-maybe-only-terminal-app
|
||||||
|
* https://unix.stackexchange.com/questions/105958/terminal-prompt-not-wrapping-correctly
|
||||||
|
* https://unix.stackexchange.com/questions/529377/terminal-long-line-wrapping
|
||||||
|
* https://github.com/microsoft/WSL/issues/1436
|
||||||
|
|
||||||
|
If nothing seems to help, consider also using a different client-side terminal application:
|
||||||
|
* Linux: xterm, KDE Konsole, GNOME Terminal
|
||||||
|
* Mac: Terminal, iTerm2
|
||||||
|
* Windows: PowerShell, PuTTY, WSL (Windows Subsystem for Linux)
|
||||||
|
|
||||||
|
## "Docker seems to be unavailable" error when using Windows Subsystem for Linux (WSL)
|
||||||
|
|
||||||
|
When running on WSL, the recommendation is to install a CLI release for Linux, like the standalone
|
||||||
|
zip package for Linux. However, commands like "balena build" will, by default, attempt to reach the
|
||||||
|
Docker daemon at the Unix socket path `/var/run/docker.sock`, while Docker Desktop for Windows uses
|
||||||
|
a Windows named pipe at `//./pipe/docker_engine` (which the Linux CLI on WSL cannot use). A
|
||||||
|
solution is:
|
||||||
|
|
||||||
|
- Open the Docker Desktop for Windows settings panel and tick the checkbox _"Expose daemon on tcp://localhost:2375 without TLS"._
|
||||||
|
- On the WSL command line, set an env var:
|
||||||
|
`export DOCKER_HOST=tcp://localhost:2375`
|
||||||
|
Alternatively, use the command-line options `-h 127.0.0.1 -p 2375` for commands like `balena build` and `balena deploy`.
|
||||||
|
|
||||||
|
Further reference:
|
||||||
|
|
||||||
|
- https://techcommunity.microsoft.com/t5/Containers/WSL-Interoperability-with-Docker/ba-p/382405
|
||||||
|
- https://forums.docker.com/t/wsl-and-docker-for-windows-cannot-connect-to-the-docker-daemon-at-tcp-localhost-2375-is-the-docker-daemon-running/63571/12
|
||||||
|
15
appveyor.yml
15
appveyor.yml
@ -22,19 +22,22 @@ install:
|
|||||||
- ps: Install-Product node $env:nodejs_version x64
|
- ps: Install-Product node $env:nodejs_version x64
|
||||||
- set PATH=%APPDATA%\npm;%PATH%
|
- set PATH=%APPDATA%\npm;%PATH%
|
||||||
- npm config set python 'C:\Python27\python.exe'
|
- npm config set python 'C:\Python27\python.exe'
|
||||||
- npm install
|
- npm --version
|
||||||
|
# - npm install
|
||||||
|
|
||||||
build: off
|
build: off
|
||||||
|
test: off
|
||||||
|
deploy: off
|
||||||
|
|
||||||
test_script:
|
test_script:
|
||||||
- node --version
|
- node --version
|
||||||
- npm --version
|
- npm --version
|
||||||
- npm test
|
# - npm test
|
||||||
|
|
||||||
deploy_script:
|
deploy_script:
|
||||||
- node --version
|
- node --version
|
||||||
- npm --version
|
- npm --version
|
||||||
- npm run build:standalone
|
# - npm run build:standalone
|
||||||
- npm run build:installer
|
# - npm run build:installer
|
||||||
- IF "%APPVEYOR_REPO_TAG%" == "true" (npm run release)
|
# - IF "%APPVEYOR_REPO_TAG%" == "true" (npm run release)
|
||||||
- IF NOT "%APPVEYOR_REPO_TAG%" == "true" (echo 'Not tagged, skipping deploy')
|
# - IF NOT "%APPVEYOR_REPO_TAG%" == "true" (echo 'Not tagged, skipping deploy')
|
||||||
|
@ -15,15 +15,157 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { JsonVersions } from '../lib/commands/version';
|
||||||
|
|
||||||
import { run as oclifRun } from '@oclif/dev-cli';
|
import { run as oclifRun } from '@oclif/dev-cli';
|
||||||
|
import * as archiver from 'archiver';
|
||||||
import * as Bluebird from 'bluebird';
|
import * as Bluebird from 'bluebird';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
import * as filehound from 'filehound';
|
import * as filehound from 'filehound';
|
||||||
import * as fs from 'fs-extra';
|
import * as fs from 'fs-extra';
|
||||||
|
import * as _ from 'lodash';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { exec as execPkg } from 'pkg';
|
|
||||||
import * as rimraf from 'rimraf';
|
import * as rimraf from 'rimraf';
|
||||||
|
import * as semver from 'semver';
|
||||||
|
import * as util from 'util';
|
||||||
|
|
||||||
export const ROOT = path.join(__dirname, '..');
|
import { stripIndent } from '../lib/utils/lazy';
|
||||||
|
import {
|
||||||
|
diffLines,
|
||||||
|
getSubprocessStdout,
|
||||||
|
loadPackageJson,
|
||||||
|
MSYS2_BASH,
|
||||||
|
ROOT,
|
||||||
|
StdOutTap,
|
||||||
|
whichSpawn,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
export const packageJSON = loadPackageJson();
|
||||||
|
export const version = 'v' + packageJSON.version;
|
||||||
|
const arch = process.arch;
|
||||||
|
|
||||||
|
function dPath(...paths: string[]) {
|
||||||
|
return path.join(ROOT, 'dist', ...paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PathByPlatform {
|
||||||
|
[platform: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const standaloneZips: PathByPlatform = {
|
||||||
|
linux: dPath(`balena-cli-${version}-linux-${arch}-standalone.zip`),
|
||||||
|
darwin: dPath(`balena-cli-${version}-macOS-${arch}-standalone.zip`),
|
||||||
|
win32: dPath(`balena-cli-${version}-windows-${arch}-standalone.zip`),
|
||||||
|
};
|
||||||
|
|
||||||
|
const oclifInstallers: PathByPlatform = {
|
||||||
|
darwin: dPath('macos', `balena-${version}.pkg`),
|
||||||
|
win32: dPath('win', `balena-${version}-${arch}.exe`),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renamedOclifInstallers: PathByPlatform = {
|
||||||
|
darwin: dPath(`balena-cli-${version}-macOS-${arch}-installer.pkg`),
|
||||||
|
win32: dPath(`balena-cli-${version}-windows-${arch}-installer.exe`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finalReleaseAssets: { [platform: string]: string[] } = {
|
||||||
|
win32: [standaloneZips['win32'], renamedOclifInstallers['win32']],
|
||||||
|
darwin: [standaloneZips['darwin'], renamedOclifInstallers['darwin']],
|
||||||
|
linux: [standaloneZips['linux']],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the output of `pkg` as a string (containing warning messages),
|
||||||
|
* diff it against previously saved output of known "safe" warnings.
|
||||||
|
* Throw an error if the diff is not empty.
|
||||||
|
*/
|
||||||
|
async function diffPkgOutput(pkgOut: string) {
|
||||||
|
const { monochrome } = await import('../tests/helpers');
|
||||||
|
const relSavedPath = path.join(
|
||||||
|
'tests',
|
||||||
|
'test-data',
|
||||||
|
'pkg',
|
||||||
|
`expected-warnings-${process.platform}.txt`,
|
||||||
|
);
|
||||||
|
const absSavedPath = path.join(ROOT, relSavedPath);
|
||||||
|
const ignoreStartsWith = [
|
||||||
|
'> pkg@',
|
||||||
|
'> Fetching base Node.js binaries',
|
||||||
|
' fetched-',
|
||||||
|
];
|
||||||
|
const modulesRE =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? /(?<=[ '])([A-Z]:)?\\.+?\\node_modules(?=\\)/
|
||||||
|
: /(?<=[ '])\/.+?\/node_modules(?=\/)/;
|
||||||
|
const buildRE =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? /(?<=[ '])([A-Z]:)?\\.+\\build(?=\\)/
|
||||||
|
: /(?<=[ '])\/.+\/build(?=\/)/;
|
||||||
|
|
||||||
|
const cleanLines = (chunks: string | string[]) => {
|
||||||
|
const lines = typeof chunks === 'string' ? chunks.split('\n') : chunks;
|
||||||
|
return lines
|
||||||
|
.map((line: string) => monochrome(line)) // remove ASCII colors
|
||||||
|
.filter((line: string) => !/^\s*$/.test(line)) // blank lines
|
||||||
|
.filter((line: string) =>
|
||||||
|
ignoreStartsWith.every((i) => !line.startsWith(i)),
|
||||||
|
)
|
||||||
|
.map((line: string) => {
|
||||||
|
// replace absolute paths with relative paths
|
||||||
|
let replaced = line.replace(modulesRE, 'node_modules');
|
||||||
|
if (replaced === line) {
|
||||||
|
replaced = line.replace(buildRE, 'build');
|
||||||
|
}
|
||||||
|
return replaced;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
pkgOut = cleanLines(pkgOut).join('\n');
|
||||||
|
const { readFile } = (await import('fs')).promises;
|
||||||
|
const expectedOut = cleanLines(await readFile(absSavedPath, 'utf8')).join(
|
||||||
|
'\n',
|
||||||
|
);
|
||||||
|
if (expectedOut !== pkgOut) {
|
||||||
|
const sep =
|
||||||
|
'================================================================================';
|
||||||
|
const diff = diffLines(expectedOut, pkgOut);
|
||||||
|
const msg = `pkg output does not match expected output from "${relSavedPath}"
|
||||||
|
Diff:
|
||||||
|
${sep}
|
||||||
|
${diff}
|
||||||
|
${sep}
|
||||||
|
Check whether the new or changed pkg warnings are safe to ignore, then update
|
||||||
|
"${relSavedPath}"
|
||||||
|
and share the result of your investigation as comments on the pull request.
|
||||||
|
Hint: the fix is often a matter of updating the 'pkg.scripts' or 'pkg.assets'
|
||||||
|
sections in the CLI's 'package.json' file, or a matter of updating the
|
||||||
|
'buildPkg' function in 'automation/build-bin.ts'. Sometimes it requires
|
||||||
|
patching dependencies: See for example 'patches/all/open+7.0.2.patch'.
|
||||||
|
${sep}
|
||||||
|
`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call `pkg.exec` to generate the standalone zip file, capturing its warning
|
||||||
|
* messages (stdout and stderr) in order to call diffPkgOutput().
|
||||||
|
*/
|
||||||
|
async function execPkg(...args: any[]) {
|
||||||
|
const { exec: pkgExec } = await import('pkg');
|
||||||
|
const outTap = new StdOutTap(true);
|
||||||
|
try {
|
||||||
|
outTap.tap();
|
||||||
|
await (pkgExec as any)(...args);
|
||||||
|
} catch (err) {
|
||||||
|
outTap.untap();
|
||||||
|
console.log(outTap.stdoutBuf.join(''));
|
||||||
|
console.error(outTap.stderrBuf.join(''));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
outTap.untap();
|
||||||
|
await diffPkgOutput(outTap.allBuf.join(''));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use the 'pkg' module to create a single large executable file with
|
* Use the 'pkg' module to create a single large executable file with
|
||||||
@ -34,30 +176,38 @@ export const ROOT = path.join(__dirname, '..');
|
|||||||
* because of a pkg limitation that does not allow binary executables
|
* because of a pkg limitation that does not allow binary executables
|
||||||
* to be directly executed from inside another binary executable.)
|
* to be directly executed from inside another binary executable.)
|
||||||
*/
|
*/
|
||||||
export async function buildPkg() {
|
async function buildPkg() {
|
||||||
console.log('Building package...\n');
|
const args = [
|
||||||
|
|
||||||
await execPkg([
|
|
||||||
'--target',
|
'--target',
|
||||||
'node10',
|
'host',
|
||||||
'--output',
|
'--output',
|
||||||
'build-bin/balena',
|
'build-bin/balena',
|
||||||
'package.json',
|
'package.json',
|
||||||
]);
|
|
||||||
const xpaths: Array<[string, string[]]> = [
|
|
||||||
// [platform, [path, to, file]]
|
|
||||||
['*', ['opn', 'xdg-open']],
|
|
||||||
['darwin', ['denymount', 'bin', 'denymount']],
|
|
||||||
];
|
];
|
||||||
await Bluebird.map(xpaths, ([platform, xpath]) => {
|
console.log('=======================================================');
|
||||||
if (platform === '*' || platform === process.platform) {
|
console.log(`execPkg ${args.join(' ')}`);
|
||||||
// eg copy from node_modules/opn/xdg-open to build-bin/xdg-open
|
console.log(`cwd="${process.cwd()}" ROOT="${ROOT}"`);
|
||||||
return fs.copy(
|
console.log('=======================================================');
|
||||||
path.join(ROOT, 'node_modules', ...xpath),
|
|
||||||
path.join(ROOT, 'build-bin', xpath.pop()!),
|
await execPkg(args);
|
||||||
);
|
|
||||||
}
|
const paths: Array<[string, string[], string[]]> = [
|
||||||
});
|
// [platform, [source path], [destination path]]
|
||||||
|
['*', ['open', 'xdg-open'], ['xdg-open']],
|
||||||
|
['*', ['opn', 'xdg-open'], ['xdg-open-402']],
|
||||||
|
['darwin', ['denymount', 'bin', 'denymount'], ['denymount']],
|
||||||
|
];
|
||||||
|
await Promise.all(
|
||||||
|
paths.map(([platform, source, dest]) => {
|
||||||
|
if (platform === '*' || platform === process.platform) {
|
||||||
|
// eg copy from node_modules/open/xdg-open to build-bin/xdg-open
|
||||||
|
return fs.copy(
|
||||||
|
path.join(ROOT, 'node_modules', ...source),
|
||||||
|
path.join(ROOT, 'build-bin', ...dest),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
const nativeExtensionPaths: string[] = await filehound
|
const nativeExtensionPaths: string[] = await filehound
|
||||||
.create()
|
.create()
|
||||||
.paths(path.join(ROOT, 'node_modules'))
|
.paths(path.join(ROOT, 'node_modules'))
|
||||||
@ -66,17 +216,133 @@ export async function buildPkg() {
|
|||||||
|
|
||||||
console.log(`\nCopying to build-bin:\n${nativeExtensionPaths.join('\n')}`);
|
console.log(`\nCopying to build-bin:\n${nativeExtensionPaths.join('\n')}`);
|
||||||
|
|
||||||
await Bluebird.map(nativeExtensionPaths, extPath =>
|
await Promise.all(
|
||||||
fs.copy(
|
nativeExtensionPaths.map((extPath) =>
|
||||||
extPath,
|
fs.copy(
|
||||||
extPath.replace(
|
extPath,
|
||||||
path.join(ROOT, 'node_modules'),
|
extPath.replace(
|
||||||
path.join(ROOT, 'build-bin'),
|
path.join(ROOT, 'node_modules'),
|
||||||
|
path.join(ROOT, 'build-bin'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run some basic tests on the built pkg executable.
|
||||||
|
* TODO: test more than just `balena version -j`; integrate with the
|
||||||
|
* existing mocha/chai CLI command testing.
|
||||||
|
*/
|
||||||
|
async function testPkg() {
|
||||||
|
const pkgBalenaPath = path.join(
|
||||||
|
ROOT,
|
||||||
|
'build-bin',
|
||||||
|
process.platform === 'win32' ? 'balena.exe' : 'balena',
|
||||||
|
);
|
||||||
|
console.log(`Testing standalone package "${pkgBalenaPath}"...`);
|
||||||
|
// Run `balena version -j`, parse its stdout as JSON, and check that the
|
||||||
|
// reported Node.js major version matches semver.major(process.version)
|
||||||
|
const stdout = await getSubprocessStdout(pkgBalenaPath, ['version', '-j']);
|
||||||
|
let pkgNodeVersion = '';
|
||||||
|
let pkgNodeMajorVersion = 0;
|
||||||
|
try {
|
||||||
|
const balenaVersions: JsonVersions = JSON.parse(stdout);
|
||||||
|
pkgNodeVersion = balenaVersions['Node.js'];
|
||||||
|
pkgNodeMajorVersion = semver.major(pkgNodeVersion);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(stripIndent`
|
||||||
|
Error parsing JSON output of "balena version -j": ${err}
|
||||||
|
Original output: "${stdout}"`);
|
||||||
|
}
|
||||||
|
if (semver.major(process.version) !== pkgNodeMajorVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${process.version}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('Success! (standalone package test successful)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the zip file for the standalone 'pkg' bundle previously created
|
||||||
|
* by the buildPkg() function in 'build-bin.ts'.
|
||||||
|
*/
|
||||||
|
async function zipPkg() {
|
||||||
|
const outputFile = standaloneZips[process.platform];
|
||||||
|
if (!outputFile) {
|
||||||
|
throw new Error(
|
||||||
|
`Standalone installer unavailable for platform "${process.platform}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fs.mkdirp(path.dirname(outputFile));
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
console.log(`Zipping standalone package to "${outputFile}"...`);
|
||||||
|
|
||||||
|
const archive = archiver('zip', {
|
||||||
|
zlib: { level: 7 },
|
||||||
|
});
|
||||||
|
archive.directory(path.join(ROOT, 'build-bin'), 'balena-cli');
|
||||||
|
|
||||||
|
const outputStream = fs.createWriteStream(outputFile);
|
||||||
|
|
||||||
|
outputStream.on('close', resolve);
|
||||||
|
outputStream.on('error', reject);
|
||||||
|
|
||||||
|
archive.on('error', reject);
|
||||||
|
archive.on('warning', console.warn);
|
||||||
|
|
||||||
|
archive.pipe(outputStream);
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildStandaloneZip() {
|
||||||
|
console.log(`Building standalone zip package for CLI ${version}`);
|
||||||
|
try {
|
||||||
|
await buildPkg();
|
||||||
|
await testPkg();
|
||||||
|
await zipPkg();
|
||||||
|
console.log(`Standalone zip package build completed`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating or testing standalone zip package`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameInstallerFiles() {
|
||||||
|
if (await fs.pathExists(oclifInstallers[process.platform])) {
|
||||||
|
await fs.rename(
|
||||||
|
oclifInstallers[process.platform],
|
||||||
|
renamedOclifInstallers[process.platform],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the CSC_LINK and CSC_KEY_PASSWORD env vars are set, digitally sign the
|
||||||
|
* executable installer by running the balena-io/scripts/shared/sign-exe.sh
|
||||||
|
* script (which must be in the PATH) using a MSYS2 bash shell.
|
||||||
|
*/
|
||||||
|
async function signWindowsInstaller() {
|
||||||
|
if (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD) {
|
||||||
|
const exeName = renamedOclifInstallers[process.platform];
|
||||||
|
const execFileAsync = util.promisify<string, string[], void>(execFile);
|
||||||
|
|
||||||
|
console.log(`Signing installer "${exeName}"`);
|
||||||
|
await execFileAsync(MSYS2_BASH, [
|
||||||
|
'sign-exe.sh',
|
||||||
|
'-f',
|
||||||
|
exeName,
|
||||||
|
'-d',
|
||||||
|
`balena-cli ${version}`,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'Skipping installer signing step because CSC_* env vars are not set',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the `oclif-dev pack:win` or `pack:macos` command (depending on the value
|
* Run the `oclif-dev pack:win` or `pack:macos` command (depending on the value
|
||||||
* of process.platform) to generate the native installers (which end up under
|
* of process.platform) to generate the native installers (which end up under
|
||||||
@ -84,7 +350,6 @@ export async function buildPkg() {
|
|||||||
* 64-bit binaries under Windows.
|
* 64-bit binaries under Windows.
|
||||||
*/
|
*/
|
||||||
export async function buildOclifInstaller() {
|
export async function buildOclifInstaller() {
|
||||||
console.log(`buildOclifInstaller cwd="${process.cwd()}" ROOT="${ROOT}"`);
|
|
||||||
let packOS = '';
|
let packOS = '';
|
||||||
let packOpts = ['-r', ROOT];
|
let packOpts = ['-r', ROOT];
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
@ -94,6 +359,7 @@ export async function buildOclifInstaller() {
|
|||||||
packOpts = packOpts.concat('-t', 'win32-x64');
|
packOpts = packOpts.concat('-t', 'win32-x64');
|
||||||
}
|
}
|
||||||
if (packOS) {
|
if (packOS) {
|
||||||
|
console.log(`Building oclif installer for CLI ${version}`);
|
||||||
const packCmd = `pack:${packOS}`;
|
const packCmd = `pack:${packOS}`;
|
||||||
const dirs = [path.join(ROOT, 'dist', packOS)];
|
const dirs = [path.join(ROOT, 'dist', packOS)];
|
||||||
if (packOS === 'win') {
|
if (packOS === 'win') {
|
||||||
@ -101,19 +367,54 @@ export async function buildOclifInstaller() {
|
|||||||
}
|
}
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
console.log(`rimraf(${dir})`);
|
console.log(`rimraf(${dir})`);
|
||||||
await Bluebird.fromCallback(cb => rimraf(dir, cb));
|
await Bluebird.fromCallback((cb) => rimraf(dir, cb));
|
||||||
}
|
}
|
||||||
console.log('=======================================================');
|
console.log('=======================================================');
|
||||||
console.log(`oclif-dev "${packCmd}" [${packOpts}]`);
|
console.log(`oclif-dev "${packCmd}" "${packOpts.join('" "')}"`);
|
||||||
|
console.log(`cwd="${process.cwd()}" ROOT="${ROOT}"`);
|
||||||
console.log('=======================================================');
|
console.log('=======================================================');
|
||||||
oclifRun([packCmd].concat(...packOpts));
|
await oclifRun([packCmd].concat(...packOpts));
|
||||||
|
await renameInstallerFiles();
|
||||||
|
// The Windows installer is explicitly signed here (oclif doesn't do it).
|
||||||
|
// The macOS installer is automatically signed by oclif (which runs the
|
||||||
|
// `pkgbuild` tool), using the certificate name given in package.json
|
||||||
|
// (`oclif.macos.sign` section).
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
await signWindowsInstaller();
|
||||||
|
}
|
||||||
|
console.log(`oclif installer build completed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert e.g. 'C:\myfolder' -> '/C/myfolder' so that the path can be given
|
* Wrapper around the npm `catch-uncommitted` package in order to run it
|
||||||
* as argument to "unix tools" like 'tar' under MSYS or MSYS2 on Windows.
|
* conditionally, only when:
|
||||||
|
* - A CI env var is set (CI=true), and
|
||||||
|
* - The OS is not Windows. (`catch-uncommitted` fails on Windows)
|
||||||
*/
|
*/
|
||||||
export function fixPathForMsys(p: string): string {
|
export async function catchUncommitted(): Promise<void> {
|
||||||
return p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1');
|
if (process.env.DEBUG) {
|
||||||
|
console.error(`[debug] CI=${process.env.CI} platform=${process.platform}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
process.env.CI &&
|
||||||
|
['true', 'yes', '1'].includes(process.env.CI.toLowerCase()) &&
|
||||||
|
process.platform !== 'win32'
|
||||||
|
) {
|
||||||
|
await whichSpawn('npx', [
|
||||||
|
'catch-uncommitted',
|
||||||
|
'--catch-no-git',
|
||||||
|
'--skip-node-versionbot-changes',
|
||||||
|
'--ignore-space-at-eol',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testShrinkwrap(): Promise<void> {
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(`[debug] platform=${process.platform}`);
|
||||||
|
}
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
await whichSpawn(path.resolve(__dirname, 'test-lock-deduplicated.sh'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { MarkdownFileParser } from './utils';
|
import { MarkdownFileParser } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,95 +26,153 @@ import { MarkdownFileParser } from './utils';
|
|||||||
* some content to this object.
|
* some content to this object.
|
||||||
*/
|
*/
|
||||||
const capitanoDoc = {
|
const capitanoDoc = {
|
||||||
title: 'Balena CLI Documentation',
|
title: 'balena CLI Documentation',
|
||||||
introduction: '',
|
introduction: '',
|
||||||
categories: [
|
categories: [
|
||||||
{
|
{
|
||||||
title: 'API keys',
|
title: 'API keys',
|
||||||
files: ['build/actions/api-key.js'],
|
files: ['build/commands/api-key/generate.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Application',
|
title: 'Application',
|
||||||
files: ['build/actions/app.js'],
|
files: [
|
||||||
|
'build/commands/apps.js',
|
||||||
|
'build/commands/app/index.js',
|
||||||
|
'build/commands/app/create.js',
|
||||||
|
'build/commands/app/purge.js',
|
||||||
|
'build/commands/app/rename.js',
|
||||||
|
'build/commands/app/restart.js',
|
||||||
|
'build/commands/app/rm.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Authentication',
|
title: 'Authentication',
|
||||||
files: ['build/actions/auth.js'],
|
files: [
|
||||||
|
'build/commands/login.js',
|
||||||
|
'build/commands/logout.js',
|
||||||
|
'build/commands/whoami.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Device',
|
title: 'Device',
|
||||||
files: ['build/actions/device.js'],
|
files: [
|
||||||
|
'build/commands/devices/index.js',
|
||||||
|
'build/commands/devices/supported.js',
|
||||||
|
'build/commands/device/index.js',
|
||||||
|
'build/commands/device/identify.js',
|
||||||
|
'build/commands/device/init.js',
|
||||||
|
'build/commands/device/move.js',
|
||||||
|
'build/commands/device/os-update.js',
|
||||||
|
'build/commands/device/public-url.js',
|
||||||
|
'build/commands/device/purge.js',
|
||||||
|
'build/commands/device/reboot.js',
|
||||||
|
'build/commands/device/register.js',
|
||||||
|
'build/commands/device/rename.js',
|
||||||
|
'build/commands/device/restart.js',
|
||||||
|
'build/commands/device/rm.js',
|
||||||
|
'build/commands/device/shutdown.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Environment Variables',
|
title: 'Environment Variables',
|
||||||
files: [
|
files: [
|
||||||
'build/actions/environment-variables.js',
|
'build/commands/envs.js',
|
||||||
'build/actions-oclif/env/add.js',
|
'build/commands/env/add.js',
|
||||||
|
'build/commands/env/rename.js',
|
||||||
|
'build/commands/env/rm.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Tags',
|
title: 'Tags',
|
||||||
files: ['build/actions/tags.js'],
|
files: [
|
||||||
|
'build/commands/tags.js',
|
||||||
|
'build/commands/tag/rm.js',
|
||||||
|
'build/commands/tag/set.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Help and Version',
|
title: 'Help and Version',
|
||||||
files: ['build/actions/help.js', 'build/actions-oclif/version.js'],
|
files: ['help', 'build/commands/version.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Keys',
|
title: 'Keys',
|
||||||
files: ['build/actions/keys.js'],
|
files: [
|
||||||
|
'build/commands/keys.js',
|
||||||
|
'build/commands/key/index.js',
|
||||||
|
'build/commands/key/add.js',
|
||||||
|
'build/commands/key/rm.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Logs',
|
title: 'Logs',
|
||||||
files: ['build/actions/logs.js'],
|
files: ['build/commands/logs.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Network',
|
title: 'Network',
|
||||||
files: [
|
files: [
|
||||||
'build/actions/scan.js',
|
'build/commands/scan.js',
|
||||||
'build/actions/ssh.js',
|
'build/commands/ssh.js',
|
||||||
'build/actions/tunnel.js',
|
'build/commands/tunnel.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Notes',
|
title: 'Notes',
|
||||||
files: ['build/actions/notes.js'],
|
files: ['build/commands/note.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'OS',
|
title: 'OS',
|
||||||
files: ['build/actions/os.js'],
|
files: [
|
||||||
|
'build/commands/os/build-config.js',
|
||||||
|
'build/commands/os/configure.js',
|
||||||
|
'build/commands/os/versions.js',
|
||||||
|
'build/commands/os/download.js',
|
||||||
|
'build/commands/os/initialize.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Config',
|
title: 'Config',
|
||||||
files: ['build/actions/config.js'],
|
files: [
|
||||||
|
'build/commands/config/generate.js',
|
||||||
|
'build/commands/config/inject.js',
|
||||||
|
'build/commands/config/read.js',
|
||||||
|
'build/commands/config/reconfigure.js',
|
||||||
|
'build/commands/config/write.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Preload',
|
title: 'Preload',
|
||||||
files: ['build/actions/preload.js'],
|
files: ['build/commands/preload.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Push',
|
title: 'Push',
|
||||||
files: ['build/actions/push.js'],
|
files: ['build/commands/push.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
files: ['build/actions/settings.js'],
|
files: ['build/commands/settings.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Local',
|
title: 'Local',
|
||||||
files: ['build/actions/local/index.js'],
|
files: [
|
||||||
|
'build/commands/local/configure.js',
|
||||||
|
'build/commands/local/flash.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Deploy',
|
title: 'Deploy',
|
||||||
files: ['build/actions/build.js', 'build/actions/deploy.js'],
|
files: ['build/commands/build.js', 'build/commands/deploy.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Platform',
|
title: 'Platform',
|
||||||
files: ['build/actions/join.js', 'build/actions/leave.js'],
|
files: ['build/commands/join.js', 'build/commands/leave.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Utilities',
|
title: 'Utilities',
|
||||||
files: ['build/actions/util.js'],
|
files: ['build/commands/util/available-drives.js'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
files: ['build/commands/support.js'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@ -142,8 +199,11 @@ export async function getCapitanoDoc(): Promise<typeof capitanoDoc> {
|
|||||||
return match && match[2];
|
return match && match[2];
|
||||||
}),
|
}),
|
||||||
mdParser.getSectionOfTitle('Installation'),
|
mdParser.getSectionOfTitle('Installation'),
|
||||||
mdParser.getSectionOfTitle('Getting Started'),
|
mdParser.getSectionOfTitle('Choosing a shell (command prompt/terminal)'),
|
||||||
|
mdParser.getSectionOfTitle('Logging in'),
|
||||||
|
mdParser.getSectionOfTitle('Proxy support'),
|
||||||
mdParser.getSectionOfTitle('Support, FAQ and troubleshooting'),
|
mdParser.getSectionOfTitle('Support, FAQ and troubleshooting'),
|
||||||
|
mdParser.getSectionOfTitle('Deprecation policy'),
|
||||||
]);
|
]);
|
||||||
capitanoDoc.introduction = sections.join('\n');
|
capitanoDoc.introduction = sections.join('\n');
|
||||||
return capitanoDoc;
|
return capitanoDoc;
|
||||||
|
5
automation/capitanodoc/doc-types.d.ts
vendored
5
automation/capitanodoc/doc-types.d.ts
vendored
@ -15,7 +15,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import { Command as OclifCommandClass } from '@oclif/command';
|
import { Command as OclifCommandClass } from '@oclif/command';
|
||||||
import { CommandDefinition as CapitanoCommand } from 'capitano';
|
|
||||||
|
|
||||||
type OclifCommand = typeof OclifCommandClass;
|
type OclifCommand = typeof OclifCommandClass;
|
||||||
|
|
||||||
@ -27,7 +26,7 @@ export interface Document {
|
|||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
title: string;
|
title: string;
|
||||||
commands: Array<CapitanoCommand | OclifCommand>;
|
commands: OclifCommand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CapitanoCommand, OclifCommand };
|
export { OclifCommand };
|
||||||
|
@ -14,12 +14,11 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import * as _ from 'lodash';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { getCapitanoDoc } from './capitanodoc';
|
import { getCapitanoDoc } from './capitanodoc';
|
||||||
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
|
import { Category, Document, OclifCommand } from './doc-types';
|
||||||
import * as markdown from './markdown';
|
import * as markdown from './markdown';
|
||||||
|
import { stripIndent } from '../../lib/utils/lazy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the markdown document (as a string) for the CLI documentation
|
* Generates the markdown document (as a string) for the CLI documentation
|
||||||
@ -40,11 +39,7 @@ export async function renderMarkdown(): Promise<string> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const jsFilename of commandCategory.files) {
|
for (const jsFilename of commandCategory.files) {
|
||||||
category.commands.push(
|
category.commands.push(...importOclifCommands(jsFilename));
|
||||||
...(jsFilename.includes('actions-oclif')
|
|
||||||
? importOclifCommands(jsFilename)
|
|
||||||
: importCapitanoCommands(jsFilename)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
result.categories.push(category);
|
result.categories.push(category);
|
||||||
}
|
}
|
||||||
@ -52,24 +47,48 @@ export async function renderMarkdown(): Promise<string> {
|
|||||||
return markdown.render(result);
|
return markdown.render(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function importCapitanoCommands(jsFilename: string): CapitanoCommand[] {
|
// Help is now managed via a plugin
|
||||||
const actions = require(path.join(process.cwd(), jsFilename));
|
// This fake command allows capitanodoc to include help in docs
|
||||||
const commands: CapitanoCommand[] = [];
|
class FakeHelpCommand {
|
||||||
|
description = stripIndent`
|
||||||
|
List balena commands, or get detailed help for a specific command.
|
||||||
|
|
||||||
if (actions.signature) {
|
List balena commands, or get detailed help for a specific command.
|
||||||
commands.push(_.omit(actions, 'action'));
|
`;
|
||||||
} else {
|
|
||||||
for (const actionName of Object.keys(actions)) {
|
examples = [
|
||||||
const actionCommand = actions[actionName];
|
'$ balena help',
|
||||||
commands.push(_.omit(actionCommand, 'action'));
|
'$ balena help apps',
|
||||||
}
|
'$ balena help os download',
|
||||||
}
|
];
|
||||||
return commands;
|
|
||||||
|
args = [
|
||||||
|
{
|
||||||
|
name: 'command',
|
||||||
|
description: 'command to show help for',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
usage = 'help [command]';
|
||||||
|
|
||||||
|
flags = {
|
||||||
|
verbose: {
|
||||||
|
description: 'show additional commands',
|
||||||
|
char: '-v',
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function importOclifCommands(jsFilename: string): OclifCommand[] {
|
function importOclifCommands(jsFilename: string): OclifCommand[] {
|
||||||
const command: OclifCommand = require(path.join(process.cwd(), jsFilename))
|
// TODO: Currently oclif commands with no `usage` overridden will cause
|
||||||
.default as OclifCommand;
|
// an error when parsed. This should be improved so that `usage` does not have
|
||||||
|
// to be overridden if not necessary.
|
||||||
|
|
||||||
|
const command: OclifCommand =
|
||||||
|
jsFilename === 'help'
|
||||||
|
? ((new FakeHelpCommand() as unknown) as OclifCommand)
|
||||||
|
: (require(path.join(process.cwd(), jsFilename)).default as OclifCommand);
|
||||||
|
|
||||||
return [command];
|
return [command];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,30 +19,11 @@ import * as ent from 'ent';
|
|||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
import { getManualSortCompareFunction } from '../../lib/utils/helpers';
|
import { getManualSortCompareFunction } from '../../lib/utils/helpers';
|
||||||
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
|
import { capitanoizeOclifUsage } from '../../lib/utils/oclif-utils';
|
||||||
import * as utils from './utils';
|
import { Category, Document, OclifCommand } from './doc-types';
|
||||||
|
|
||||||
function renderCapitanoCommand(command: CapitanoCommand): string[] {
|
|
||||||
const result = [`## ${ent.encode(command.signature)}`, command.help];
|
|
||||||
|
|
||||||
if (!_.isEmpty(command.options)) {
|
|
||||||
result.push('### Options');
|
|
||||||
|
|
||||||
for (const option of command.options!) {
|
|
||||||
if (option == null) {
|
|
||||||
throw new Error(`Undefined option in markdown generation!`);
|
|
||||||
}
|
|
||||||
result.push(
|
|
||||||
`#### ${utils.parseCapitanoOption(option)}`,
|
|
||||||
option.description,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOclifCommand(command: OclifCommand): string[] {
|
function renderOclifCommand(command: OclifCommand): string[] {
|
||||||
const result = [`## ${ent.encode(command.usage)}`];
|
const result = [`## ${ent.encode(command.usage || '')}`];
|
||||||
const description = (command.description || '')
|
const description = (command.description || '')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.slice(1) // remove the first line, which oclif uses as help header
|
.slice(1) // remove the first line, which oclif uses as help header
|
||||||
@ -51,7 +32,7 @@ function renderOclifCommand(command: OclifCommand): string[] {
|
|||||||
result.push(description);
|
result.push(description);
|
||||||
|
|
||||||
if (!_.isEmpty(command.examples)) {
|
if (!_.isEmpty(command.examples)) {
|
||||||
result.push('Examples:', command.examples!.map(v => `\t${v}`).join('\n'));
|
result.push('Examples:', command.examples!.map((v) => `\t${v}`).join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_.isEmpty(command.args)) {
|
if (!_.isEmpty(command.args)) {
|
||||||
@ -82,11 +63,7 @@ function renderOclifCommand(command: OclifCommand): string[] {
|
|||||||
function renderCategory(category: Category): string[] {
|
function renderCategory(category: Category): string[] {
|
||||||
const result = [`# ${category.title}`];
|
const result = [`# ${category.title}`];
|
||||||
for (const command of category.commands) {
|
for (const command of category.commands) {
|
||||||
result.push(
|
result.push(...renderOclifCommand(command));
|
||||||
...(typeof command === 'object'
|
|
||||||
? renderCapitanoCommand(command)
|
|
||||||
: renderOclifCommand(command)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -102,11 +79,8 @@ function renderToc(categories: Category[]): string[] {
|
|||||||
result.push(`- ${category.title}`);
|
result.push(`- ${category.title}`);
|
||||||
result.push(
|
result.push(
|
||||||
category.commands
|
category.commands
|
||||||
.map(command => {
|
.map((command) => {
|
||||||
const signature =
|
const signature = capitanoizeOclifUsage(command.usage);
|
||||||
typeof command === 'object'
|
|
||||||
? command.signature // Capitano
|
|
||||||
: utils.capitanoizeOclifUsage(command.usage); // oclif
|
|
||||||
return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`;
|
return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`;
|
||||||
})
|
})
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
@ -117,21 +91,23 @@ function renderToc(categories: Category[]): string[] {
|
|||||||
|
|
||||||
const manualCategorySorting: { [category: string]: string[] } = {
|
const manualCategorySorting: { [category: string]: string[] } = {
|
||||||
'Environment Variables': ['envs', 'env rm', 'env add', 'env rename'],
|
'Environment Variables': ['envs', 'env rm', 'env add', 'env rename'],
|
||||||
|
OS: [
|
||||||
|
'os versions',
|
||||||
|
'os download',
|
||||||
|
'os build config',
|
||||||
|
'os configure',
|
||||||
|
'os initialize',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function sortCommands(doc: Document): void {
|
function sortCommands(doc: Document): void {
|
||||||
for (const category of doc.categories) {
|
for (const category of doc.categories) {
|
||||||
if (category.title in manualCategorySorting) {
|
if (category.title in manualCategorySorting) {
|
||||||
category.commands = category.commands.sort(
|
category.commands = category.commands.sort(
|
||||||
getManualSortCompareFunction<CapitanoCommand | OclifCommand, string>(
|
getManualSortCompareFunction<OclifCommand, string>(
|
||||||
manualCategorySorting[category.title],
|
manualCategorySorting[category.title],
|
||||||
(cmd: CapitanoCommand | OclifCommand, x: string) =>
|
(cmd: OclifCommand, x: string) =>
|
||||||
typeof cmd === 'object' // Capitano vs oclif command
|
(cmd.usage || '').toString().replace(/\W+/g, ' ').includes(x),
|
||||||
? cmd.signature.replace(/\W+/g, ' ').includes(x)
|
|
||||||
: (cmd.usage || '')
|
|
||||||
.toString()
|
|
||||||
.replace(/\W+/g, ' ')
|
|
||||||
.includes(x),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -15,10 +15,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { OptionDefinition } from 'capitano';
|
import type { OptionDefinition } from 'capitano';
|
||||||
import * as ent from 'ent';
|
import * as ent from 'ent';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as _ from 'lodash';
|
|
||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
|
|
||||||
export function getOptionPrefix(signature: string) {
|
export function getOptionPrefix(signature: string) {
|
||||||
@ -36,11 +35,11 @@ export function getOptionSignature(signature: string) {
|
|||||||
export function parseCapitanoOption(option: OptionDefinition): string {
|
export function parseCapitanoOption(option: OptionDefinition): string {
|
||||||
let result = getOptionSignature(option.signature);
|
let result = getOptionSignature(option.signature);
|
||||||
|
|
||||||
if (_.isArray(option.alias)) {
|
if (Array.isArray(option.alias)) {
|
||||||
for (const alias of option.alias) {
|
for (const alias of option.alias) {
|
||||||
result += `, ${getOptionSignature(alias)}`;
|
result += `, ${getOptionSignature(alias)}`;
|
||||||
}
|
}
|
||||||
} else if (_.isString(option.alias)) {
|
} else if (typeof option.alias === 'string') {
|
||||||
result += `, ${getOptionSignature(option.alias)}`;
|
result += `, ${getOptionSignature(option.alias)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,16 +50,6 @@ export function parseCapitanoOption(option: OptionDefinition): string {
|
|||||||
return ent.encode(result);
|
return ent.encode(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert e.g. 'env add NAME [VALUE]' to 'env add <name> [value]' */
|
|
||||||
export function capitanoizeOclifUsage(
|
|
||||||
oclifUsage: string | string[] | undefined,
|
|
||||||
): string {
|
|
||||||
return (oclifUsage || '')
|
|
||||||
.toString()
|
|
||||||
.replace(/(?<=\s)[A-Z]+(?=(\s|$))/g, match => `<${match}>`)
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MarkdownFileParser {
|
export class MarkdownFileParser {
|
||||||
constructor(public mdFilePath: string) {}
|
constructor(public mdFilePath: string) {}
|
||||||
|
|
||||||
@ -104,7 +93,7 @@ export class MarkdownFileParser {
|
|||||||
crlfDelay: Infinity,
|
crlfDelay: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
rl.on('line', line => {
|
rl.on('line', (line) => {
|
||||||
// try to match a line like "## Getting Started", where the number
|
// try to match a line like "## Getting Started", where the number
|
||||||
// of '#' characters is the sectionLevel ('##' -> 2), and the
|
// of '#' characters is the sectionLevel ('##' -> 2), and the
|
||||||
// sectionTitle is "Getting Started"
|
// sectionTitle is "Getting Started"
|
||||||
@ -137,9 +126,7 @@ export class MarkdownFileParser {
|
|||||||
} else {
|
} else {
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
`Markdown section not found: title="${title}" file="${
|
`Markdown section not found: title="${title}" file="${this.mdFilePath}"`,
|
||||||
this.mdFilePath
|
|
||||||
}"`,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
84
automation/check-doc.js
Normal file
84
automation/check-doc.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stripIndent = require('common-tags/lib/stripIndent');
|
||||||
|
const _ = require('lodash');
|
||||||
|
const { promises: fs } = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const simplegit = require('simple-git/promise');
|
||||||
|
|
||||||
|
const ROOT = path.normalize(path.join(__dirname, '..'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare the timestamp of cli.markdown with the timestamp of staged files,
|
||||||
|
* issuing an error if cli.markdown is older.
|
||||||
|
* If cli.markdown does not require updating and the developer cannot run
|
||||||
|
* `npm run build` on their laptop, the error message suggests a workaround
|
||||||
|
* using `touch`.
|
||||||
|
*/
|
||||||
|
async function checkBuildTimestamps() {
|
||||||
|
const git = simplegit(ROOT);
|
||||||
|
const docFile = path.join(ROOT, 'doc', 'cli.markdown');
|
||||||
|
const [docStat, gitStatus] = await Promise.all([
|
||||||
|
fs.stat(docFile),
|
||||||
|
git.status(),
|
||||||
|
]);
|
||||||
|
const stagedFiles = _.uniq([
|
||||||
|
...gitStatus.created,
|
||||||
|
...gitStatus.staged,
|
||||||
|
...gitStatus.renamed.map((o) => o.to),
|
||||||
|
])
|
||||||
|
// select only staged files that start with lib/ or typings/
|
||||||
|
.filter((f) => f.match(/^(lib|typings)[/\\]/))
|
||||||
|
.map((f) => path.join(ROOT, f));
|
||||||
|
|
||||||
|
const fStats = await Promise.all(stagedFiles.map((f) => fs.stat(f)));
|
||||||
|
fStats.forEach((fStat, index) => {
|
||||||
|
if (fStat.mtimeMs > docStat.mtimeMs) {
|
||||||
|
const fPath = stagedFiles[index];
|
||||||
|
throw new Error(stripIndent`
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
ERROR: at least one staged file: "${fPath}"
|
||||||
|
has a more recent modification timestamp than the documentation file:
|
||||||
|
"${docFile}"
|
||||||
|
|
||||||
|
This probably means that \`npm run build\` or \`npm test\` have not been executed,
|
||||||
|
and this error can be fixed by doing so. Running \`npm run build\` or \`npm test\`
|
||||||
|
before commiting is required in order to update the CLI markdown documentation
|
||||||
|
(in case any command-line options were updated, added or removed) and also to
|
||||||
|
catch Typescript type check errors sooner and reduce overall waiting time, given
|
||||||
|
that the CI build/tests are currently rather lengthy.
|
||||||
|
|
||||||
|
If you need/wish to bypass this check without running \`npm run build\`, run:
|
||||||
|
npx touch -am "${docFile}"
|
||||||
|
and then try again.
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
await checkBuildTimestamps();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
57
automation/check-npm-version.js
Normal file
57
automation/check-npm-version.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that semver v1 is greater than or equal to semver v2.
|
||||||
|
*
|
||||||
|
* We don't `require('semver')` to allow this script to be run as a npm
|
||||||
|
* 'preinstall' hook, at which point no dependencies have been installed.
|
||||||
|
*/
|
||||||
|
function parseSemver(version) {
|
||||||
|
const match = /v?(\d+)\.(\d+).(\d+)/.exec(version);
|
||||||
|
if (match == null) {
|
||||||
|
throw new Error(`Invalid semver version: ${version}`);
|
||||||
|
}
|
||||||
|
const [, major, minor, patch] = match;
|
||||||
|
return [parseInt(major, 10), parseInt(minor, 10), parseInt(patch, 10)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function semverGte(v1, v2) {
|
||||||
|
let v1Array = parseSemver(v1);
|
||||||
|
let v2Array = parseSemver(v2);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (v1Array[i] < v2Array[i]) {
|
||||||
|
return false;
|
||||||
|
} else if (v1Array[i] > v2Array[i]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNpmVersion() {
|
||||||
|
const execSync = require('child_process').execSync;
|
||||||
|
const npmVersion = execSync('npm --version').toString().trim();
|
||||||
|
const requiredVersion = '6.9.0';
|
||||||
|
if (!semverGte(npmVersion, requiredVersion)) {
|
||||||
|
// In case you take issue with the error message below:
|
||||||
|
// "At this point, however, your 'npm-shrinkwrap.json' file has
|
||||||
|
// already been damaged"
|
||||||
|
// ... and think: "why not add the check to the 'preinstall' hook?",
|
||||||
|
// the reason is that it would unnecessarily prevent end users from
|
||||||
|
// using npm v6.4.1 that ships with Node 8. (It is OK for the
|
||||||
|
// shrinkwrap file to get damaged if it is not going to be reused.)
|
||||||
|
console.error(`\
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Error: npm version '${npmVersion}' detected. Please upgrade to npm v${requiredVersion} or later
|
||||||
|
because of a bug that causes the 'npm-shrinkwrap.json' file to be damaged.
|
||||||
|
At this point, however, your 'npm-shrinkwrap.json' file has already been
|
||||||
|
damaged. Please revert it to the master branch state with a command such as:
|
||||||
|
"git checkout master -- npm-shrinkwrap.json"
|
||||||
|
Then re-run "npm install" using npm version ${requiredVersion} or later.
|
||||||
|
-------------------------------------------------------------------------------`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkNpmVersion();
|
@ -14,86 +14,14 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import * as archiver from 'archiver';
|
|
||||||
import * as Bluebird from 'bluebird';
|
import * as Bluebird from 'bluebird';
|
||||||
import * as fs from 'fs-extra';
|
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import * as path from 'path';
|
import * as semver from 'semver';
|
||||||
import * as publishRelease from 'publish-release';
|
|
||||||
|
import { finalReleaseAssets, version } from './build-bin';
|
||||||
|
|
||||||
const { GITHUB_TOKEN } = process.env;
|
const { GITHUB_TOKEN } = process.env;
|
||||||
const ROOT = path.join(__dirname, '..');
|
|
||||||
// Note: the following 'tslint disable' line was only required to
|
|
||||||
// satisfy ts-node under Appveyor's MSYS2 on Windows -- oddly specific.
|
|
||||||
// Maybe something to do with '/' vs '\' in paths in some tslint file.
|
|
||||||
// tslint:disable-next-line:no-var-requires
|
|
||||||
const packageJSON = require(path.join(ROOT, 'package.json'));
|
|
||||||
const version = 'v' + packageJSON.version;
|
|
||||||
const arch = process.arch;
|
|
||||||
|
|
||||||
function dPath(...paths: string[]) {
|
|
||||||
return path.join(ROOT, 'dist', ...paths);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PathByPlatform {
|
|
||||||
[platform: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const standaloneZips: PathByPlatform = {
|
|
||||||
linux: dPath(`balena-cli-${version}-linux-${arch}-standalone.zip`),
|
|
||||||
darwin: dPath(`balena-cli-${version}-macOS-${arch}-standalone.zip`),
|
|
||||||
win32: dPath(`balena-cli-${version}-windows-${arch}-standalone.zip`),
|
|
||||||
};
|
|
||||||
|
|
||||||
const oclifInstallers: PathByPlatform = {
|
|
||||||
darwin: dPath('macos', `balena-${version}.pkg`),
|
|
||||||
win32: dPath('win', `balena-${version}-${arch}.exe`),
|
|
||||||
};
|
|
||||||
|
|
||||||
const renamedOclifInstallers: PathByPlatform = {
|
|
||||||
darwin: dPath(`balena-cli-${version}-macOS-${arch}-installer-BETA.pkg`),
|
|
||||||
win32: dPath(`balena-cli-${version}-windows-${arch}-installer-BETA.exe`),
|
|
||||||
};
|
|
||||||
|
|
||||||
const finalReleaseAssets: { [platform: string]: string[] } = {
|
|
||||||
win32: [standaloneZips['win32'], renamedOclifInstallers['win32']],
|
|
||||||
darwin: [standaloneZips['darwin'], renamedOclifInstallers['darwin']],
|
|
||||||
linux: [standaloneZips['linux']],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the zip file for the standalone 'pkg' bundle previously created
|
|
||||||
* by the buildPkg() function in 'build-bin.ts'.
|
|
||||||
*/
|
|
||||||
export async function zipStandaloneInstaller() {
|
|
||||||
const outputFile = standaloneZips[process.platform];
|
|
||||||
if (!outputFile) {
|
|
||||||
throw new Error(
|
|
||||||
`Standalone installer unavailable for platform "${process.platform}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await fs.mkdirp(path.dirname(outputFile));
|
|
||||||
await new Bluebird((resolve, reject) => {
|
|
||||||
console.log(`Zipping build to "${outputFile}"...`);
|
|
||||||
|
|
||||||
const archive = archiver('zip', {
|
|
||||||
zlib: { level: 7 },
|
|
||||||
});
|
|
||||||
archive.directory(path.join(ROOT, 'build-bin'), 'balena-cli');
|
|
||||||
|
|
||||||
const outputStream = fs.createWriteStream(outputFile);
|
|
||||||
|
|
||||||
outputStream.on('close', resolve);
|
|
||||||
outputStream.on('error', reject);
|
|
||||||
|
|
||||||
archive.on('error', reject);
|
|
||||||
archive.on('warning', console.warn);
|
|
||||||
|
|
||||||
archive.pipe(outputStream);
|
|
||||||
archive.finalize();
|
|
||||||
});
|
|
||||||
console.log('Build zipped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or update a release in GitHub's releases page, uploading the
|
* Create or update a release in GitHub's releases page, uploading the
|
||||||
@ -101,6 +29,7 @@ export async function zipStandaloneInstaller() {
|
|||||||
*/
|
*/
|
||||||
export async function createGitHubRelease() {
|
export async function createGitHubRelease() {
|
||||||
console.log(`Publishing release ${version} to GitHub`);
|
console.log(`Publishing release ${version} to GitHub`);
|
||||||
|
const publishRelease = await import('publish-release');
|
||||||
const ghRelease = await Bluebird.fromCallback(
|
const ghRelease = await Bluebird.fromCallback(
|
||||||
publishRelease.bind(null, {
|
publishRelease.bind(null, {
|
||||||
token: GITHUB_TOKEN || '',
|
token: GITHUB_TOKEN || '',
|
||||||
@ -122,21 +51,6 @@ export async function createGitHubRelease() {
|
|||||||
* the files.
|
* the files.
|
||||||
*/
|
*/
|
||||||
export async function release() {
|
export async function release() {
|
||||||
console.log(`Creating release assets for CLI ${version}`);
|
|
||||||
try {
|
|
||||||
await zipStandaloneInstaller();
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`Error creating standalone installer zip file: ${error}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (process.platform === 'win32' || process.platform === 'darwin') {
|
|
||||||
if (await fs.pathExists(oclifInstallers[process.platform])) {
|
|
||||||
await fs.rename(
|
|
||||||
oclifInstallers[process.platform],
|
|
||||||
renamedOclifInstallers[process.platform],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await createGitHubRelease();
|
await createGitHubRelease();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -145,3 +59,194 @@ export async function release() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return a cached Octokit instance, creating a new one as needed. */
|
||||||
|
const getOctokit = _.once(function () {
|
||||||
|
const Octokit = (require('@octokit/rest') as typeof import('@octokit/rest')).Octokit.plugin(
|
||||||
|
(require('@octokit/plugin-throttling') as typeof import('@octokit/plugin-throttling'))
|
||||||
|
.throttling,
|
||||||
|
);
|
||||||
|
return new Octokit({
|
||||||
|
auth: GITHUB_TOKEN,
|
||||||
|
throttle: {
|
||||||
|
onRateLimit: (retryAfter: number, options: any) => {
|
||||||
|
console.warn(
|
||||||
|
`Request quota exhausted for request ${options.method} ${options.url}`,
|
||||||
|
);
|
||||||
|
// retries 3 times
|
||||||
|
if (options.request.retryCount < 3) {
|
||||||
|
console.log(`Retrying after ${retryAfter} seconds!`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAbuseLimit: (_retryAfter: number, options: any) => {
|
||||||
|
// does not retry, only logs a warning
|
||||||
|
console.warn(
|
||||||
|
`Abuse detected for request ${options.method} ${options.url}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract pagination information (current page, total pages, ordinal number)
|
||||||
|
* from the 'link' response header (example below), using the parse-link-header
|
||||||
|
* npm package:
|
||||||
|
* "link": "<https://api.github.com/repositories/187370853/releases?per_page=2&page=2>; rel=\"next\",
|
||||||
|
* <https://api.github.com/repositories/187370853/releases?per_page=2&page=3>; rel=\"last\""
|
||||||
|
*
|
||||||
|
* @param response Octokit response object (including response.headers.link)
|
||||||
|
* @param perPageDefault Default per_page pagination value if missing in URL
|
||||||
|
* @return Object where 'page' is the current page number (1-based),
|
||||||
|
* 'pages' is the total number of pages, and 'ordinal' is the ordinal number
|
||||||
|
* (3rd, 4th, 5th...) of the first item in the current page.
|
||||||
|
*/
|
||||||
|
function getPageNumbers(
|
||||||
|
response: any,
|
||||||
|
perPageDefault: number,
|
||||||
|
): { page: number; pages: number; ordinal: number } {
|
||||||
|
const res = { page: 1, pages: 1, ordinal: 1 };
|
||||||
|
if (!response.headers.link) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
const parse = require('parse-link-header') as typeof import('parse-link-header');
|
||||||
|
const parsed = parse(response.headers.link);
|
||||||
|
if (parsed == null) {
|
||||||
|
throw new Error(`Failed to parse link header: '${response.headers.link}'`);
|
||||||
|
}
|
||||||
|
let perPage = perPageDefault;
|
||||||
|
if (parsed.next) {
|
||||||
|
if (parsed.next.per_page) {
|
||||||
|
perPage = parseInt(parsed.next.per_page, 10);
|
||||||
|
}
|
||||||
|
res.page = parseInt(parsed.next.page, 10) - 1;
|
||||||
|
res.pages = parseInt(parsed.last.page, 10);
|
||||||
|
} else {
|
||||||
|
if (parsed.prev.per_page) {
|
||||||
|
perPage = parseInt(parsed.prev.per_page, 10);
|
||||||
|
}
|
||||||
|
res.page = res.pages = parseInt(parsed.prev.page, 10) + 1;
|
||||||
|
}
|
||||||
|
res.ordinal = (res.page - 1) * perPage + 1;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate over every GitHub release in the given owner/repo, check whether
|
||||||
|
* its tag_name matches against the affectedVersions semver spec, and if so
|
||||||
|
* replace its release description (body) with the given newDescription value.
|
||||||
|
* @param owner GitHub repo owner, e.g. 'balena-io' or 'pdcastro'
|
||||||
|
* @param repo GitHub repo, e.g. 'balena-cli'
|
||||||
|
* @param affectedVersions Semver spec, e.g. '2.6.1 - 7.10.9 || 8.0.0'
|
||||||
|
* @param newDescription New release description (body)
|
||||||
|
* @param editID Short string present in newDescription, e.g. '[AA101]', that
|
||||||
|
* can be searched to determine whether that release has already been updated.
|
||||||
|
*/
|
||||||
|
async function updateGitHubReleaseDescriptions(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
affectedVersions: string,
|
||||||
|
newDescription: string,
|
||||||
|
editID: string,
|
||||||
|
) {
|
||||||
|
const perPage = 30;
|
||||||
|
const octokit = getOctokit();
|
||||||
|
const options = await octokit.repos.listReleases.endpoint.merge({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
per_page: perPage,
|
||||||
|
});
|
||||||
|
let errCount = 0;
|
||||||
|
for await (const response of octokit.paginate.iterator(options)) {
|
||||||
|
const { page: thisPage, pages: totalPages, ordinal } = getPageNumbers(
|
||||||
|
response,
|
||||||
|
perPage,
|
||||||
|
);
|
||||||
|
let i = 0;
|
||||||
|
for (const cliRelease of response.data) {
|
||||||
|
const prefix = `[#${ordinal + i++} pg ${thisPage}/${totalPages}]`;
|
||||||
|
if (!cliRelease.id) {
|
||||||
|
console.error(
|
||||||
|
`${prefix} Error: missing release ID (errCount=${++errCount})`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const skipMsg = `${prefix} skipping release "${cliRelease.tag_name}" (${cliRelease.id})`;
|
||||||
|
if (cliRelease.draft === true) {
|
||||||
|
console.info(`${skipMsg}: draft release`);
|
||||||
|
continue;
|
||||||
|
} else if (cliRelease.body && cliRelease.body.includes(editID)) {
|
||||||
|
console.info(`${skipMsg}: already updated`);
|
||||||
|
continue;
|
||||||
|
} else if (!semver.satisfies(cliRelease.tag_name, affectedVersions)) {
|
||||||
|
console.info(`${skipMsg}: outside version range`);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
const updatedRelease = {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
release_id: cliRelease.id,
|
||||||
|
body: newDescription,
|
||||||
|
};
|
||||||
|
let oldBodyPreview = cliRelease.body;
|
||||||
|
if (oldBodyPreview) {
|
||||||
|
oldBodyPreview = oldBodyPreview.replace(/\s+/g, ' ').trim();
|
||||||
|
if (oldBodyPreview.length > 12) {
|
||||||
|
oldBodyPreview = oldBodyPreview.substring(0, 9) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
`${prefix} updating release "${cliRelease.tag_name}" (${cliRelease.id}) old body="${oldBodyPreview}"`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await octokit.repos.updateRelease(updatedRelease);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`${skipMsg}: Error: ${err.message} (count=${++errCount})`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a warning description to CLI releases affected by a mixpanel tracking
|
||||||
|
* security issue (#1359). This function can be executed "manually" with the
|
||||||
|
* following command line:
|
||||||
|
*
|
||||||
|
* npx ts-node --type-check -P automation/tsconfig.json automation/run.ts fix1359
|
||||||
|
*/
|
||||||
|
export async function updateDescriptionOfReleasesAffectedByIssue1359() {
|
||||||
|
// Run only on Linux/Node10, instead of all platform/Node combinations.
|
||||||
|
// (It could have been any other platform, as long as it only runs once.)
|
||||||
|
if (process.platform !== 'linux' || semver.major(process.version) !== 10) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const owner = 'balena-io';
|
||||||
|
const repo = 'balena-cli';
|
||||||
|
const affectedVersions =
|
||||||
|
'2.6.1 - 7.10.9 || 8.0.0 - 8.1.0 || 9.0.0 - 9.15.6 || 10.0.0 - 10.17.5 || 11.0.0 - 11.7.2';
|
||||||
|
const editID = '[AA100]';
|
||||||
|
let newDescription = `
|
||||||
|
Please note: the "login" command in this release is affected by a
|
||||||
|
security issue fixed in versions
|
||||||
|
[7.10.10](https://github.com/balena-io/balena-cli/releases/tag/v7.10.10),
|
||||||
|
[8.1.1](https://github.com/balena-io/balena-cli/releases/tag/v8.1.1),
|
||||||
|
[9.15.7](https://github.com/balena-io/balena-cli/releases/tag/v9.15.7),
|
||||||
|
[10.17.6](https://github.com/balena-io/balena-cli/releases/tag/v10.17.6),
|
||||||
|
[11.7.3](https://github.com/balena-io/balena-cli/releases/tag/v11.7.3)
|
||||||
|
and later. If you need to use this version, avoid passing your password,
|
||||||
|
keys or tokens as command-line arguments. ${editID}`;
|
||||||
|
// remove line breaks and collapse white space
|
||||||
|
newDescription = newDescription.replace(/\s+/g, ' ').trim();
|
||||||
|
await updateGitHubReleaseDescriptions(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
affectedVersions,
|
||||||
|
newDescription,
|
||||||
|
editID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -15,43 +15,30 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import * as shellEscape from 'shell-escape';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildOclifInstaller,
|
buildOclifInstaller,
|
||||||
buildPkg,
|
buildStandaloneZip,
|
||||||
fixPathForMsys,
|
catchUncommitted,
|
||||||
ROOT,
|
testShrinkwrap,
|
||||||
} from './build-bin';
|
} from './build-bin';
|
||||||
import { release } from './deploy-bin';
|
import {
|
||||||
|
release,
|
||||||
|
updateDescriptionOfReleasesAffectedByIssue1359,
|
||||||
|
} from './deploy-bin';
|
||||||
|
import { fixPathForMsys, ROOT, runUnderMsys } from './utils';
|
||||||
|
|
||||||
/**
|
// DEBUG set to falsy for negative values else is truthy
|
||||||
* Run the MSYS2 bash.exe shell in a child process (child_process.spawn()).
|
process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
|
||||||
* The given argv arguments are escaped using the 'shell-escape' package,
|
process.env.DEBUG?.toLowerCase(),
|
||||||
* so that backslashes in Windows paths, and other bash-special characters,
|
)
|
||||||
* are preserved. If argv is not provided, defaults to process.argv, to the
|
? ''
|
||||||
* effect that this current (parent) process is re-executed under MSYS2 bash.
|
: '1';
|
||||||
* This is useful to change the default shell from cmd.exe to MSYS2 bash on
|
|
||||||
* Windows.
|
function exitWithError(error: Error | string): never {
|
||||||
* @param argv Arguments to be shell-escaped and given to MSYS2 bash.exe.
|
console.error(`Error: ${error}`);
|
||||||
*/
|
process.exit(1);
|
||||||
export async function runUnderMsys(argv?: string[]) {
|
|
||||||
const newArgv = argv || process.argv;
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const cmd = 'C:\\msys64\\usr\\bin\\bash.exe';
|
|
||||||
const args = ['-lc', shellEscape(newArgv)];
|
|
||||||
const child = spawn(cmd, args, { stdio: 'inherit' });
|
|
||||||
child.on('close', code => {
|
|
||||||
if (code) {
|
|
||||||
console.log(`runUnderMsys: child process exited with code ${code}`);
|
|
||||||
reject(code);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -71,17 +58,19 @@ export async function run(args?: string[]) {
|
|||||||
console.log(`automation/run.ts process.argv=[${process.argv}]\n`);
|
console.log(`automation/run.ts process.argv=[${process.argv}]\n`);
|
||||||
console.log(`automation/run.ts args=[${args}]`);
|
console.log(`automation/run.ts args=[${args}]`);
|
||||||
if (_.isEmpty(args)) {
|
if (_.isEmpty(args)) {
|
||||||
console.error('Error: missing args');
|
return exitWithError('missing command-line arguments');
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
const commands: { [cmd: string]: () => void } = {
|
const commands: { [cmd: string]: () => void | Promise<void> } = {
|
||||||
'build:installer': buildOclifInstaller,
|
'build:installer': buildOclifInstaller,
|
||||||
'build:standalone': buildPkg,
|
'build:standalone': buildStandaloneZip,
|
||||||
|
'catch-uncommitted': catchUncommitted,
|
||||||
|
'test-shrinkwrap': testShrinkwrap,
|
||||||
|
fix1359: updateDescriptionOfReleasesAffectedByIssue1359,
|
||||||
release,
|
release,
|
||||||
};
|
};
|
||||||
for (const arg of args) {
|
for (const arg of args) {
|
||||||
if (!commands.hasOwnProperty(arg)) {
|
if (!commands.hasOwnProperty(arg)) {
|
||||||
throw new Error(`Error: unknown build target: ${arg}`);
|
return exitWithError(`command unknown: ${arg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,23 +78,45 @@ export async function run(args?: string[]) {
|
|||||||
// the current working dir becomes the MSYS2 homedir, so we change back.
|
// the current working dir becomes the MSYS2 homedir, so we change back.
|
||||||
process.chdir(ROOT);
|
process.chdir(ROOT);
|
||||||
|
|
||||||
|
// The BUILD_TMP env var is used as an alternative location for oclif
|
||||||
|
// (patched) to copy/extract the CLI files, run npm install and then
|
||||||
|
// create the NSIS executable installer for Windows. This was necessary
|
||||||
|
// to avoid issues with a 260-char limit on Windows paths (possibly a
|
||||||
|
// limitation of some library used by NSIS), as the "current working dir"
|
||||||
|
// provided by balena CI is a rather long path to start with.
|
||||||
|
if (process.platform === 'win32' && !process.env.BUILD_TMP) {
|
||||||
|
const randID = (await import('crypto'))
|
||||||
|
.randomBytes(6)
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_'); // base64url (RFC 4648)
|
||||||
|
process.env.BUILD_TMP = `C:\\tmp\\${randID}`;
|
||||||
|
}
|
||||||
|
|
||||||
for (const arg of args) {
|
for (const arg of args) {
|
||||||
if (arg === 'build:installer' && process.platform === 'win32') {
|
try {
|
||||||
// ensure running under MSYS2
|
if (arg === 'build:installer' && process.platform === 'win32') {
|
||||||
if (!process.env.MSYSTEM) {
|
// ensure running under MSYS2
|
||||||
process.env.MSYS2_PATH_TYPE = 'inherit';
|
if (!process.env.MSYSTEM) {
|
||||||
await runUnderMsys([
|
process.env.MSYS2_PATH_TYPE = 'inherit';
|
||||||
fixPathForMsys(process.argv[0]),
|
await runUnderMsys([
|
||||||
fixPathForMsys(process.argv[1]),
|
fixPathForMsys(process.argv[0]),
|
||||||
arg,
|
fixPathForMsys(process.argv[1]),
|
||||||
]);
|
arg,
|
||||||
continue;
|
]);
|
||||||
}
|
continue;
|
||||||
if (process.env.MSYS2_PATH_TYPE !== 'inherit') {
|
}
|
||||||
throw new Error('the MSYS2_PATH_TYPE env var must be set to "inherit"');
|
if (process.env.MSYS2_PATH_TYPE !== 'inherit') {
|
||||||
|
throw new Error(
|
||||||
|
'the MSYS2_PATH_TYPE env var must be set to "inherit"',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const cmdFunc = commands[arg];
|
||||||
|
await cmdFunc();
|
||||||
|
} catch (err) {
|
||||||
|
return exitWithError(`"${arg}": ${err}`);
|
||||||
}
|
}
|
||||||
await commands[arg]();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
17
automation/test-lock-deduplicated.sh
Executable file
17
automation/test-lock-deduplicated.sh
Executable file
@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cp npm-shrinkwrap.json npm-shrinkwrap.json.old
|
||||||
|
npm i
|
||||||
|
npm dedupe
|
||||||
|
npm i
|
||||||
|
|
||||||
|
if ! diff -q npm-shrinkwrap.json npm-shrinkwrap.json.old > /dev/null; then
|
||||||
|
rm npm-shrinkwrap.json.old
|
||||||
|
echo "** npm-shrinkwrap.json was not deduplicated or not fully committed - FAIL **";
|
||||||
|
echo "** Please run 'npm ci', followed by 'npm dedupe' **";
|
||||||
|
exit 1;
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm npm-shrinkwrap.json.old
|
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "commonjs",
|
|
||||||
"target": "es2017",
|
|
||||||
"strict": true,
|
|
||||||
"strictPropertyInitialization": false,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"preserveConstEnums": true,
|
|
||||||
"removeComments": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"typeRoots" : [
|
|
||||||
"../node_modules/@types",
|
|
||||||
"../typings"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
140
automation/update-module.ts
Normal file
140
automation/update-module.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
import * as semver from 'semver';
|
||||||
|
|
||||||
|
const changeTypes = ['major', 'minor', 'patch'] as const;
|
||||||
|
|
||||||
|
const validateChangeType = (maybeChangeType: string = 'minor') => {
|
||||||
|
maybeChangeType = maybeChangeType.toLowerCase();
|
||||||
|
switch (maybeChangeType) {
|
||||||
|
case 'patch':
|
||||||
|
case 'minor':
|
||||||
|
case 'major':
|
||||||
|
return maybeChangeType;
|
||||||
|
default:
|
||||||
|
console.error(`Invalid change type: '${maybeChangeType}'`);
|
||||||
|
return process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareSemverChangeType = (oldVersion: string, newVersion: string) => {
|
||||||
|
const oldSemver = semver.parse(oldVersion)!;
|
||||||
|
const newSemver = semver.parse(newVersion)!;
|
||||||
|
|
||||||
|
for (const changeType of changeTypes) {
|
||||||
|
if (oldSemver[changeType] !== newSemver[changeType]) {
|
||||||
|
return changeType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async (cmd: string) => {
|
||||||
|
console.info(`Running '${cmd}'`);
|
||||||
|
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||||
|
const p = exec(cmd, { encoding: 'utf8' }, (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
});
|
||||||
|
p.stdout.pipe(process.stdout);
|
||||||
|
p.stderr.pipe(process.stderr);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVersion = async (module: string): Promise<string> => {
|
||||||
|
const { stdout } = await run(`npm ls --json --depth 0 ${module}`);
|
||||||
|
return JSON.parse(stdout).dependencies[module].version;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Upstream {
|
||||||
|
repo: string;
|
||||||
|
url: string;
|
||||||
|
module?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUpstreams = async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const repoYaml = fs.readFileSync(__dirname + '/../repo.yml', 'utf8');
|
||||||
|
|
||||||
|
const yaml = await import('js-yaml');
|
||||||
|
const { upstream } = yaml.safeLoad(repoYaml) as {
|
||||||
|
upstream: Upstream[];
|
||||||
|
};
|
||||||
|
|
||||||
|
return upstream;
|
||||||
|
};
|
||||||
|
|
||||||
|
const printUsage = (upstreams: Upstream[], upstreamName: string) => {
|
||||||
|
console.error(
|
||||||
|
`
|
||||||
|
Usage: npm run update ${upstreamName} $version [$changeType=minor]
|
||||||
|
|
||||||
|
Upstream names: ${upstreams.map(({ repo }) => repo).join(', ')}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
return process.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Drop the wrapper function once we move to TS 3.8,
|
||||||
|
// which will support top level await.
|
||||||
|
async function main() {
|
||||||
|
const upstreams = await getUpstreams();
|
||||||
|
|
||||||
|
if (process.argv.length < 3) {
|
||||||
|
return printUsage(upstreams, '$upstreamName');
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamName = process.argv[2];
|
||||||
|
|
||||||
|
const upstream = upstreams.find((v) => v.repo === upstreamName);
|
||||||
|
|
||||||
|
if (!upstream) {
|
||||||
|
console.error(
|
||||||
|
`Invalid upstream name '${upstreamName}', valid options: ${upstreams
|
||||||
|
.map(({ repo }) => repo)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
return process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv.length < 4) {
|
||||||
|
printUsage(upstreams, upstreamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageName = upstream.module || upstream.repo;
|
||||||
|
|
||||||
|
const oldVersion = await getVersion(packageName);
|
||||||
|
await run(`npm install ${packageName}@${process.argv[3]}`);
|
||||||
|
const newVersion = await getVersion(packageName);
|
||||||
|
if (newVersion === oldVersion) {
|
||||||
|
console.error(`Already on version '${newVersion}'`);
|
||||||
|
return process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updated ${upstreamName} from ${oldVersion} to ${newVersion}`);
|
||||||
|
const semverChangeType = compareSemverChangeType(oldVersion, newVersion);
|
||||||
|
|
||||||
|
const changeType = process.argv[4]
|
||||||
|
? // if the caller specified a change type, use that one
|
||||||
|
validateChangeType(process.argv[4])
|
||||||
|
: // use the same change type as in the dependency, but avoid major bumps
|
||||||
|
semverChangeType && semverChangeType !== 'major'
|
||||||
|
? semverChangeType
|
||||||
|
: 'minor';
|
||||||
|
console.log(`Using Change-type: ${changeType}`);
|
||||||
|
|
||||||
|
let { stdout: currentBranch } = await run('git rev-parse --abbrev-ref HEAD');
|
||||||
|
currentBranch = currentBranch.trim();
|
||||||
|
console.log(`Currenty on branch: '${currentBranch}'`);
|
||||||
|
if (currentBranch === 'master') {
|
||||||
|
await run(`git checkout -b "update-${upstreamName}-${newVersion}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(`git add package.json npm-shrinkwrap.json`);
|
||||||
|
await run(
|
||||||
|
`git commit --message "Update ${upstreamName} to ${newVersion}" --message "Update ${upstreamName} from ${oldVersion} to ${newVersion}" --message "Change-type: ${changeType}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
237
automation/utils.ts
Normal file
237
automation/utils.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2019-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as shellEscape from 'shell-escape';
|
||||||
|
|
||||||
|
export const MSYS2_BASH = 'C:\\msys64\\usr\\bin\\bash.exe';
|
||||||
|
export const ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
/** Tap and buffer this process' stdout and stderr */
|
||||||
|
export class StdOutTap {
|
||||||
|
public stdoutBuf: string[] = [];
|
||||||
|
public stderrBuf: string[] = [];
|
||||||
|
public allBuf: string[] = []; // both stdout and stderr
|
||||||
|
|
||||||
|
protected origStdoutWrite: typeof process.stdout.write;
|
||||||
|
protected origStderrWrite: typeof process.stdout.write;
|
||||||
|
|
||||||
|
constructor(protected printDots = false) {}
|
||||||
|
|
||||||
|
tap() {
|
||||||
|
this.origStdoutWrite = process.stdout.write;
|
||||||
|
this.origStderrWrite = process.stderr.write;
|
||||||
|
|
||||||
|
process.stdout.write = (chunk: string, ...args: any[]): boolean => {
|
||||||
|
this.stdoutBuf.push(chunk);
|
||||||
|
this.allBuf.push(chunk);
|
||||||
|
const str = this.printDots ? '.' : chunk;
|
||||||
|
return this.origStdoutWrite.call(process.stdout, str, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stderr.write = (chunk: string, ...args: any[]): boolean => {
|
||||||
|
this.stderrBuf.push(chunk);
|
||||||
|
this.allBuf.push(chunk);
|
||||||
|
const str = this.printDots ? '.' : chunk;
|
||||||
|
return this.origStderrWrite.call(process.stderr, str, ...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
untap() {
|
||||||
|
process.stdout.write = this.origStdoutWrite;
|
||||||
|
process.stderr.write = this.origStderrWrite;
|
||||||
|
if (this.printDots) {
|
||||||
|
console.error('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff strings by line, using the 'diff' npm package:
|
||||||
|
* https://www.npmjs.com/package/diff
|
||||||
|
*/
|
||||||
|
export function diffLines(str1: string, str2: string): string {
|
||||||
|
const { diffTrimmedLines } = require('diff');
|
||||||
|
const diffObjs = diffTrimmedLines(str1, str2);
|
||||||
|
const prefix = (chunk: string, char: string) =>
|
||||||
|
chunk
|
||||||
|
.split('\n')
|
||||||
|
.map((line: string) => `${char} ${line}`)
|
||||||
|
.join('\n');
|
||||||
|
const diffStr = diffObjs
|
||||||
|
.map((part: any) => {
|
||||||
|
return part.added
|
||||||
|
? prefix(part.value, '+')
|
||||||
|
: part.removed
|
||||||
|
? prefix(part.value, '-')
|
||||||
|
: prefix(part.value, ' ');
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return diffStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPackageJson() {
|
||||||
|
return require(path.join(ROOT, 'package.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert e.g. 'C:\myfolder' -> '/C/myfolder' so that the path can be given
|
||||||
|
* as argument to "unix tools" like 'tar' under MSYS or MSYS2 on Windows.
|
||||||
|
*/
|
||||||
|
export function fixPathForMsys(p: string): string {
|
||||||
|
return p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the MSYS2 bash.exe shell in a child process (child_process.spawn()).
|
||||||
|
* The given argv arguments are escaped using the 'shell-escape' package,
|
||||||
|
* so that backslashes in Windows paths, and other bash-special characters,
|
||||||
|
* are preserved. If argv is not provided, defaults to process.argv, to the
|
||||||
|
* effect that this current (parent) process is re-executed under MSYS2 bash.
|
||||||
|
* This is useful to change the default shell from cmd.exe to MSYS2 bash on
|
||||||
|
* Windows.
|
||||||
|
* @param argv Arguments to be shell-escaped and given to MSYS2 bash.exe.
|
||||||
|
*/
|
||||||
|
export async function runUnderMsys(argv?: string[]) {
|
||||||
|
const newArgv = argv || process.argv;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const args = ['-lc', shellEscape(newArgv)];
|
||||||
|
const child = spawn(MSYS2_BASH, args, { stdio: 'inherit' });
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code) {
|
||||||
|
console.log(`runUnderMsys: child process exited with code ${code}`);
|
||||||
|
reject(code);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the executable at execPath as a child process, and resolve a promise
|
||||||
|
* to the executable's stdout output as a string. Reject the promise if
|
||||||
|
* anything is printed to stderr, or if the child process exits with a
|
||||||
|
* non-zero exit code.
|
||||||
|
* @param execPath Executable path
|
||||||
|
* @param args Command-line argument for the executable
|
||||||
|
*/
|
||||||
|
export async function getSubprocessStdout(
|
||||||
|
execPath: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<string> {
|
||||||
|
const child = spawn(execPath, args);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let stdout = '';
|
||||||
|
child.stdout.on('error', reject);
|
||||||
|
child.stderr.on('error', reject);
|
||||||
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
|
try {
|
||||||
|
stdout = data.toString();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
|
try {
|
||||||
|
const stderr = data.toString();
|
||||||
|
|
||||||
|
// ignore any debug lines, but ensure that we parse
|
||||||
|
// every line provided to the stderr stream
|
||||||
|
const lines = _.filter(
|
||||||
|
stderr.trim().split(/\r?\n/),
|
||||||
|
(line) => !line.startsWith('[debug]'),
|
||||||
|
);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
reject(
|
||||||
|
new Error(`"${execPath}": non-empty stderr "${lines.join('\n')}"`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on('exit', (code: number) => {
|
||||||
|
if (code) {
|
||||||
|
reject(new Error(`"${execPath}": non-zero exit code "${code}"`));
|
||||||
|
} else {
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error handling wrapper around the npm `which` package:
|
||||||
|
* "Like the unix which utility. Finds the first instance of a specified
|
||||||
|
* executable in the PATH environment variable. Does not cache the results,
|
||||||
|
* so hash -r is not needed when the PATH changes."
|
||||||
|
*
|
||||||
|
* @param program Basename of a program, for example 'ssh'
|
||||||
|
* @returns The program's full path, e.g. 'C:\WINDOWS\System32\OpenSSH\ssh.EXE'
|
||||||
|
*/
|
||||||
|
export async function which(program: string): Promise<string> {
|
||||||
|
const whichMod = await import('which');
|
||||||
|
let programPath: string;
|
||||||
|
try {
|
||||||
|
programPath = await whichMod(program);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
throw new Error(`'${program}' program not found. Is it installed?`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return programPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call which(programName) and spawn() with the given arguments. Throw an error
|
||||||
|
* if the process exit code is not zero.
|
||||||
|
*/
|
||||||
|
export async function whichSpawn(
|
||||||
|
programName: string,
|
||||||
|
args?: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const program = await which(programName);
|
||||||
|
let error: Error | undefined;
|
||||||
|
let exitCode: number | undefined;
|
||||||
|
try {
|
||||||
|
exitCode = await new Promise<number>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
spawn(program, args, { stdio: 'inherit' })
|
||||||
|
.on('error', reject)
|
||||||
|
.on('close', resolve);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
if (error || exitCode) {
|
||||||
|
const msg = [
|
||||||
|
`${programName} failed with exit code ${exitCode}:`,
|
||||||
|
`"${program}" [${args}]`,
|
||||||
|
];
|
||||||
|
if (error) {
|
||||||
|
msg.push(`${error}`);
|
||||||
|
}
|
||||||
|
throw new Error(msg.join('\n'));
|
||||||
|
}
|
||||||
|
}
|
14
bin/balena
14
bin/balena
@ -1,12 +1,22 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// tslint:disable:no-var-requires
|
||||||
|
|
||||||
// We boost the threadpool size as ext2fs can deadlock with some
|
// We boost the threadpool size as ext2fs can deadlock with some
|
||||||
// operations otherwise, if the pool runs out.
|
// operations otherwise, if the pool runs out.
|
||||||
process.env.UV_THREADPOOL_SIZE = '64';
|
process.env.UV_THREADPOOL_SIZE = '64';
|
||||||
|
|
||||||
|
// Disable oclif registering ts-node
|
||||||
|
process.env.OCLIF_TS_NODE = 0;
|
||||||
|
|
||||||
// Use fast-boot to cache require lookups, speeding up startup
|
// Use fast-boot to cache require lookups, speeding up startup
|
||||||
require('fast-boot2').start({
|
require('fast-boot2').start({
|
||||||
cacheFile: __dirname + '/.fast-boot.json'
|
cacheScope: __dirname + '/..',
|
||||||
})
|
cacheFile: __dirname + '/.fast-boot.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the desired es version for downstream modules that support it
|
||||||
|
require('@balena/es-version').set('es2018');
|
||||||
|
|
||||||
// Run the CLI
|
// Run the CLI
|
||||||
require('../build/app').run();
|
require('../build/app').run();
|
||||||
|
@ -1,22 +1,40 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
// ****************************************************************************
|
// ****************************************************************************
|
||||||
// THIS IS FOR DEV PERROSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
|
// THIS IS FOR DEV PURPOSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
|
||||||
// Before opening a PR you should build and test your changes using bin/balena
|
// Before opening a PR you should build and test your changes using bin/balena
|
||||||
// ****************************************************************************
|
// ****************************************************************************
|
||||||
|
|
||||||
|
// tslint:disable:no-var-requires
|
||||||
|
|
||||||
// We boost the threadpool size as ext2fs can deadlock with some
|
// We boost the threadpool size as ext2fs can deadlock with some
|
||||||
// operations otherwise, if the pool runs out.
|
// operations otherwise, if the pool runs out.
|
||||||
process.env.UV_THREADPOOL_SIZE = '64';
|
process.env.UV_THREADPOOL_SIZE = '64';
|
||||||
|
|
||||||
// Use fast-boot to cache require lookups, speeding up startup
|
|
||||||
require('fast-boot2').start({
|
|
||||||
cacheFile: '.fast-boot.json',
|
|
||||||
});
|
|
||||||
require('coffeescript/register');
|
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const rootDir = path.join(__dirname, '..');
|
const rootDir = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
// Allow balena-dev to work with oclif by temporarily
|
||||||
|
// pointing oclif config options to lib/ instead of build/
|
||||||
|
modifyOclifPaths();
|
||||||
|
// Undo changes on exit
|
||||||
|
process.on('exit', function () {
|
||||||
|
modifyOclifPaths(true);
|
||||||
|
});
|
||||||
|
// Undo changes in case of ctrl-v
|
||||||
|
process.on('SIGINT', function () {
|
||||||
|
modifyOclifPaths(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use fast-boot to cache require lookups, speeding up startup
|
||||||
|
require('fast-boot2').start({
|
||||||
|
cacheScope: __dirname + '/..',
|
||||||
|
cacheFile: '.fast-boot.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the desired es version for downstream modules that support it
|
||||||
|
require('@balena/es-version').set('es2018');
|
||||||
|
|
||||||
// Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the
|
// Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the
|
||||||
// default option. We upgraded ts-node and found that adding 'transpile-only'
|
// default option. We upgraded ts-node and found that adding 'transpile-only'
|
||||||
// was necessary to avoid a mysterious 'null' error message. On the plus side,
|
// was necessary to avoid a mysterious 'null' error message. On the plus side,
|
||||||
@ -27,3 +45,30 @@ require('ts-node').register({
|
|||||||
transpileOnly: true,
|
transpileOnly: true,
|
||||||
});
|
});
|
||||||
require('../lib/app').run();
|
require('../lib/app').run();
|
||||||
|
|
||||||
|
// Modify package.json oclif paths from build/ -> lib/, or vice versa
|
||||||
|
function modifyOclifPaths(revert) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const packageJsonPath = path.join(rootDir, 'package.json');
|
||||||
|
|
||||||
|
const packageJson = fs.readFileSync(packageJsonPath, 'utf8');
|
||||||
|
const packageObj = JSON.parse(packageJson);
|
||||||
|
|
||||||
|
if (!packageObj.oclif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let oclifSectionText = JSON.stringify(packageObj.oclif);
|
||||||
|
if (!revert) {
|
||||||
|
oclifSectionText = oclifSectionText.replace(/\/build\//g, '/lib/');
|
||||||
|
} else {
|
||||||
|
oclifSectionText = oclifSectionText.replace(/\/lib\//g, '/build/');
|
||||||
|
}
|
||||||
|
|
||||||
|
packageObj.oclif = JSON.parse(oclifSectionText);
|
||||||
|
fs.writeFileSync(
|
||||||
|
packageJsonPath,
|
||||||
|
`${JSON.stringify(packageObj, null, 2)}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
127
coffeelint.json
127
coffeelint.json
@ -1,127 +0,0 @@
|
|||||||
{
|
|
||||||
"coffeescript_error": {
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"arrow_spacing": {
|
|
||||||
"name": "arrow_spacing",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"no_tabs": {
|
|
||||||
"name": "no_tabs",
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"no_trailing_whitespace": {
|
|
||||||
"name": "no_trailing_whitespace",
|
|
||||||
"level": "error",
|
|
||||||
"allowed_in_comments": false,
|
|
||||||
"allowed_in_empty_lines": false
|
|
||||||
},
|
|
||||||
"max_line_length": {
|
|
||||||
"name": "max_line_length",
|
|
||||||
"value": 120,
|
|
||||||
"level": "error",
|
|
||||||
"limitComments": true
|
|
||||||
},
|
|
||||||
"line_endings": {
|
|
||||||
"name": "line_endings",
|
|
||||||
"level": "ignore",
|
|
||||||
"value": "unix"
|
|
||||||
},
|
|
||||||
"no_trailing_semicolons": {
|
|
||||||
"name": "no_trailing_semicolons",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"indentation": {
|
|
||||||
"name": "indentation",
|
|
||||||
"value": 1,
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"camel_case_classes": {
|
|
||||||
"name": "camel_case_classes",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"colon_assignment_spacing": {
|
|
||||||
"name": "colon_assignment_spacing",
|
|
||||||
"level": "error",
|
|
||||||
"spacing": {
|
|
||||||
"left": 0,
|
|
||||||
"right": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"no_implicit_braces": {
|
|
||||||
"name": "no_implicit_braces",
|
|
||||||
"level": "ignore",
|
|
||||||
"strict": false
|
|
||||||
},
|
|
||||||
"no_plusplus": {
|
|
||||||
"name": "no_plusplus",
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"no_throwing_strings": {
|
|
||||||
"name": "no_throwing_strings",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"no_backticks": {
|
|
||||||
"name": "no_backticks",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"no_implicit_parens": {
|
|
||||||
"name": "no_implicit_parens",
|
|
||||||
"strict": false,
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"no_empty_param_list": {
|
|
||||||
"name": "no_empty_param_list",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"no_stand_alone_at": {
|
|
||||||
"name": "no_stand_alone_at",
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"space_operators": {
|
|
||||||
"name": "space_operators",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"duplicate_key": {
|
|
||||||
"name": "duplicate_key",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"empty_constructor_needs_parens": {
|
|
||||||
"name": "empty_constructor_needs_parens",
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"cyclomatic_complexity": {
|
|
||||||
"name": "cyclomatic_complexity",
|
|
||||||
"value": 10,
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"newlines_after_classes": {
|
|
||||||
"name": "newlines_after_classes",
|
|
||||||
"value": 3,
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"no_unnecessary_fat_arrows": {
|
|
||||||
"name": "no_unnecessary_fat_arrows",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"missing_fat_arrows": {
|
|
||||||
"name": "missing_fat_arrows",
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"non_empty_constructor_needs_parens": {
|
|
||||||
"name": "non_empty_constructor_needs_parens",
|
|
||||||
"level": "ignore"
|
|
||||||
},
|
|
||||||
"no_unnecessary_double_quotes": {
|
|
||||||
"name": "no_unnecessary_double_quotes",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"no_debugger": {
|
|
||||||
"name": "no_debugger",
|
|
||||||
"level": "warn"
|
|
||||||
},
|
|
||||||
"no_interpolation_in_single_quotes": {
|
|
||||||
"name": "no_interpolation_in_single_quotes",
|
|
||||||
"level": "error"
|
|
||||||
}
|
|
||||||
}
|
|
2600
doc/cli.markdown
2600
doc/cli.markdown
File diff suppressed because it is too large
Load Diff
@ -1,40 +0,0 @@
|
|||||||
path = require('path')
|
|
||||||
gulp = require('gulp')
|
|
||||||
coffee = require('gulp-coffee')
|
|
||||||
inlinesource = require('gulp-inline-source')
|
|
||||||
mocha = require('gulp-mocha')
|
|
||||||
shell = require('gulp-shell')
|
|
||||||
packageJSON = require('./package.json')
|
|
||||||
|
|
||||||
OPTIONS =
|
|
||||||
files:
|
|
||||||
coffee: [ 'lib/**/*.coffee', 'gulpfile.coffee' ]
|
|
||||||
app: 'lib/**/*.coffee'
|
|
||||||
tests: 'tests/**/*.spec.coffee'
|
|
||||||
pages: 'lib/auth/pages/*.ejs'
|
|
||||||
directories:
|
|
||||||
build: 'build/'
|
|
||||||
|
|
||||||
gulp.task 'pages', ->
|
|
||||||
gulp.src(OPTIONS.files.pages)
|
|
||||||
.pipe(inlinesource())
|
|
||||||
.pipe(gulp.dest('build/auth/pages'))
|
|
||||||
|
|
||||||
gulp.task 'coffee', ->
|
|
||||||
gulp.src(OPTIONS.files.app)
|
|
||||||
.pipe(coffee(bare: true, header: true))
|
|
||||||
.pipe(gulp.dest(OPTIONS.directories.build))
|
|
||||||
|
|
||||||
gulp.task 'test', ->
|
|
||||||
gulp.src(OPTIONS.files.tests, read: false)
|
|
||||||
.pipe(mocha({
|
|
||||||
reporter: 'spec'
|
|
||||||
}))
|
|
||||||
|
|
||||||
gulp.task 'build', gulp.series [
|
|
||||||
'coffee',
|
|
||||||
'pages'
|
|
||||||
]
|
|
||||||
|
|
||||||
gulp.task 'watch', gulp.series [ 'build' ], ->
|
|
||||||
gulp.watch([ OPTIONS.files.coffee ], [ 'build' ])
|
|
15
gulpfile.js
Normal file
15
gulpfile.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const gulp = require('gulp');
|
||||||
|
const inlinesource = require('gulp-inline-source');
|
||||||
|
|
||||||
|
const OPTIONS = {
|
||||||
|
files: {
|
||||||
|
pages: 'lib/auth/pages/*.ejs',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
gulp.task('pages', () =>
|
||||||
|
gulp
|
||||||
|
.src(OPTIONS.files.pages)
|
||||||
|
.pipe(inlinesource())
|
||||||
|
.pipe(gulp.dest('build/auth/pages')),
|
||||||
|
);
|
150
lib/actions-oclif/env/add.ts
vendored
150
lib/actions-oclif/env/add.ts
vendored
@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2019 Balena Ltd.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Command, flags } from '@oclif/command';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
import { CommandHelp } from '../../utils/oclif-utils';
|
|
||||||
|
|
||||||
interface FlagsDef {
|
|
||||||
application?: string;
|
|
||||||
device?: string;
|
|
||||||
help: void;
|
|
||||||
quiet: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArgsDef {
|
|
||||||
name: string;
|
|
||||||
value?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class EnvAddCmd extends Command {
|
|
||||||
public static description = stripIndent`
|
|
||||||
Add an environment or config variable to an application or device.
|
|
||||||
|
|
||||||
Add an environment or config variable to an application or device, as selected
|
|
||||||
by the respective command-line options.
|
|
||||||
|
|
||||||
If VALUE is omitted, the CLI will attempt to use the value of the environment
|
|
||||||
variable of same name in the CLI process' environment. In this case, a warning
|
|
||||||
message will be printed. Use \`--quiet\` to suppress it.
|
|
||||||
|
|
||||||
Service-specific variables are not currently supported. The given command line
|
|
||||||
examples variables that apply to all services in an app or device.
|
|
||||||
`;
|
|
||||||
public static examples = [
|
|
||||||
'$ balena env add TERM --application MyApp',
|
|
||||||
'$ balena env add EDITOR vim --application MyApp',
|
|
||||||
'$ balena env add EDITOR vim --device 7cf02a6',
|
|
||||||
];
|
|
||||||
|
|
||||||
public static args = [
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
required: true,
|
|
||||||
description: 'environment or config variable name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'value',
|
|
||||||
required: false,
|
|
||||||
description:
|
|
||||||
"variable value; if omitted, use value from CLI's environment",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// hardcoded 'env add' to avoid oclif's 'env:add' topic syntax
|
|
||||||
public static usage =
|
|
||||||
'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage();
|
|
||||||
|
|
||||||
public static flags = {
|
|
||||||
application: flags.string({
|
|
||||||
char: 'a',
|
|
||||||
description: 'application name',
|
|
||||||
exclusive: ['device'],
|
|
||||||
}),
|
|
||||||
device: flags.string({
|
|
||||||
char: 'd',
|
|
||||||
description: 'device UUID',
|
|
||||||
exclusive: ['application'],
|
|
||||||
}),
|
|
||||||
help: flags.help({ char: 'h' }),
|
|
||||||
quiet: flags.boolean({
|
|
||||||
char: 'q',
|
|
||||||
description: 'suppress warning messages',
|
|
||||||
default: false,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
public async run() {
|
|
||||||
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
|
||||||
EnvAddCmd,
|
|
||||||
);
|
|
||||||
const Bluebird = await import('bluebird');
|
|
||||||
const _ = await import('lodash');
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const { exitWithExpectedError } = await import('../../utils/patterns');
|
|
||||||
|
|
||||||
const cmd = this;
|
|
||||||
|
|
||||||
await Bluebird.try(async function() {
|
|
||||||
if (params.value == null) {
|
|
||||||
params.value = process.env[params.name];
|
|
||||||
|
|
||||||
if (params.value == null) {
|
|
||||||
throw new Error(
|
|
||||||
`Environment value not found for variable: ${params.name}`,
|
|
||||||
);
|
|
||||||
} else if (!options.quiet) {
|
|
||||||
cmd.warn(
|
|
||||||
`Using ${params.name}=${params.value} from CLI process environment`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reservedPrefixes = await getReservedPrefixes();
|
|
||||||
const isConfigVar = _.some(reservedPrefixes, prefix =>
|
|
||||||
_.startsWith(params.name, prefix),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (options.application) {
|
|
||||||
return balena.models.application[
|
|
||||||
isConfigVar ? 'configVar' : 'envVar'
|
|
||||||
].set(options.application, params.name, params.value);
|
|
||||||
} else if (options.device) {
|
|
||||||
return balena.models.device[isConfigVar ? 'configVar' : 'envVar'].set(
|
|
||||||
options.device,
|
|
||||||
params.name,
|
|
||||||
params.value,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
exitWithExpectedError('You must specify an application or device');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getReservedPrefixes(): Promise<string[]> {
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const settings = await balena.settings.getAll();
|
|
||||||
|
|
||||||
const response = await balena.request.send({
|
|
||||||
baseUrl: settings.apiUrl,
|
|
||||||
url: '/config/vars',
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.body.reservedNamespaces;
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
export const generate: CommandDefinition<{
|
|
||||||
name: string;
|
|
||||||
}> = {
|
|
||||||
signature: 'api-key generate <name>',
|
|
||||||
description: 'Generate a new API key with the given name',
|
|
||||||
help: stripIndent`
|
|
||||||
This command generates a new API key for the current user, with the given
|
|
||||||
name. The key will be logged to the console.
|
|
||||||
|
|
||||||
This key can be used to log into the CLI using 'balena login --token <key>',
|
|
||||||
or to authenticate requests to the API with an 'Authorization: Bearer <key>' header.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena api-key generate "Jenkins Key"
|
|
||||||
`,
|
|
||||||
async action(params, _options, done) {
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
|
|
||||||
balena.models.apiKey
|
|
||||||
.create(params.name)
|
|
||||||
.then(key => {
|
|
||||||
console.log(stripIndent`
|
|
||||||
Registered api key '${params.name}':
|
|
||||||
|
|
||||||
${key}
|
|
||||||
|
|
||||||
This key will not be shown again, so please save it now.
|
|
||||||
`);
|
|
||||||
})
|
|
||||||
.finally(done);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,173 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
commandOptions = require('./command-options')
|
|
||||||
|
|
||||||
exports.create =
|
|
||||||
signature: 'app create <name>'
|
|
||||||
description: 'create an application'
|
|
||||||
help: '''
|
|
||||||
Use this command to create a new balena application.
|
|
||||||
|
|
||||||
You can specify the application device type with the `--type` option.
|
|
||||||
Otherwise, an interactive dropdown will be shown for you to select from.
|
|
||||||
|
|
||||||
You can see a list of supported device types with
|
|
||||||
|
|
||||||
$ balena devices supported
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena app create MyApp
|
|
||||||
$ balena app create MyApp --type raspberry-pi
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'type'
|
|
||||||
parameter: 'type'
|
|
||||||
description: 'application device type (Check available types with `balena devices supported`)'
|
|
||||||
alias: 't'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
|
|
||||||
# Validate the the application name is available
|
|
||||||
# before asking the device type.
|
|
||||||
# https://github.com/balena-io/balena-cli/issues/30
|
|
||||||
balena.models.application.has(params.name).then (hasApplication) ->
|
|
||||||
if hasApplication
|
|
||||||
patterns.exitWithExpectedError('You already have an application with that name!')
|
|
||||||
|
|
||||||
.then ->
|
|
||||||
return options.type or patterns.selectDeviceType()
|
|
||||||
.then (deviceType) ->
|
|
||||||
return balena.models.application.create({
|
|
||||||
name: params.name
|
|
||||||
deviceType
|
|
||||||
})
|
|
||||||
.then (application) ->
|
|
||||||
console.info("Application created: #{application.app_name} (#{application.device_type}, id #{application.id})")
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.list =
|
|
||||||
signature: 'apps'
|
|
||||||
description: 'list all applications'
|
|
||||||
help: '''
|
|
||||||
Use this command to list all your applications.
|
|
||||||
|
|
||||||
Notice this command only shows the most important bits of information for each app.
|
|
||||||
If you want detailed information, use balena app <name> instead.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena apps
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
primary: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
balena.models.application.getAll
|
|
||||||
$select: [
|
|
||||||
'id'
|
|
||||||
'app_name'
|
|
||||||
'device_type'
|
|
||||||
]
|
|
||||||
$expand: owns__device: $select: 'is_online'
|
|
||||||
.then (applications) ->
|
|
||||||
applications.forEach (application) ->
|
|
||||||
application.device_count = _.size(application.owns__device)
|
|
||||||
application.online_devices = _.filter(application.owns__device, (d) -> d.is_online == true).length
|
|
||||||
|
|
||||||
console.log visuals.table.horizontal applications, [
|
|
||||||
'id'
|
|
||||||
'app_name'
|
|
||||||
'device_type'
|
|
||||||
'online_devices'
|
|
||||||
'device_count'
|
|
||||||
]
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.info =
|
|
||||||
signature: 'app <name>'
|
|
||||||
description: 'list a single application'
|
|
||||||
help: '''
|
|
||||||
Use this command to show detailed information for a single application.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena app MyApp
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
primary: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
balena.models.application.get(params.name).then (application) ->
|
|
||||||
console.log visuals.table.vertical application, [
|
|
||||||
"$#{application.app_name}$"
|
|
||||||
'id'
|
|
||||||
'device_type'
|
|
||||||
'git_repository'
|
|
||||||
'commit'
|
|
||||||
]
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.restart =
|
|
||||||
signature: 'app restart <name>'
|
|
||||||
description: 'restart an application'
|
|
||||||
help: '''
|
|
||||||
Use this command to restart all devices that belongs to a certain application.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena app restart MyApp
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.application.restart(params.name).nodeify(done)
|
|
||||||
|
|
||||||
exports.remove =
|
|
||||||
signature: 'app rm <name>'
|
|
||||||
description: 'remove an application'
|
|
||||||
help: '''
|
|
||||||
Use this command to remove a balena application.
|
|
||||||
|
|
||||||
Notice this command asks for confirmation interactively.
|
|
||||||
You can avoid this by passing the `--yes` boolean option.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena app rm MyApp
|
|
||||||
$ balena app rm MyApp --yes
|
|
||||||
'''
|
|
||||||
options: [ commandOptions.yes ]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
|
|
||||||
patterns.confirm(options.yes, 'Are you sure you want to delete the application?').then ->
|
|
||||||
balena.models.application.remove(params.name)
|
|
||||||
.nodeify(done)
|
|
@ -1,170 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
exports.login =
|
|
||||||
signature: 'login'
|
|
||||||
description: 'login to balena'
|
|
||||||
help: '''
|
|
||||||
Use this command to login to your balena account.
|
|
||||||
|
|
||||||
This command will prompt you to login using the following login types:
|
|
||||||
|
|
||||||
- Web authorization: open your web browser and prompt you to authorize the CLI
|
|
||||||
from the dashboard.
|
|
||||||
|
|
||||||
- Credentials: using email/password and 2FA.
|
|
||||||
|
|
||||||
- Token: using a session token or API key from the preferences page.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena login
|
|
||||||
$ balena login --web
|
|
||||||
$ balena login --token "..."
|
|
||||||
$ balena login --credentials
|
|
||||||
$ balena login --credentials --email johndoe@gmail.com --password secret
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'token'
|
|
||||||
description: 'session token or API key'
|
|
||||||
parameter: 'token'
|
|
||||||
alias: 't'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'web'
|
|
||||||
description: 'web-based login'
|
|
||||||
boolean: true
|
|
||||||
alias: 'w'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'credentials'
|
|
||||||
description: 'credential-based login'
|
|
||||||
boolean: true
|
|
||||||
alias: 'c'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'email'
|
|
||||||
parameter: 'email'
|
|
||||||
description: 'email'
|
|
||||||
alias: [ 'e', 'u' ]
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'password'
|
|
||||||
parameter: 'password'
|
|
||||||
description: 'password'
|
|
||||||
alias: 'p'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
primary: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
auth = require('../auth')
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
messages = require('../utils/messages')
|
|
||||||
|
|
||||||
login = (options) ->
|
|
||||||
if options.token?
|
|
||||||
return Promise.try ->
|
|
||||||
return options.token if _.isString(options.token)
|
|
||||||
return form.ask
|
|
||||||
message: 'Session token or API key from the preferences page'
|
|
||||||
name: 'token'
|
|
||||||
type: 'input'
|
|
||||||
.then(balena.auth.loginWithToken)
|
|
||||||
.tap ->
|
|
||||||
balena.auth.whoami()
|
|
||||||
.then (username) ->
|
|
||||||
if !username
|
|
||||||
patterns.exitWithExpectedError('Token authentication failed')
|
|
||||||
else if options.credentials
|
|
||||||
return patterns.authenticate(options)
|
|
||||||
else if options.web
|
|
||||||
console.info('Connecting to the web dashboard')
|
|
||||||
return auth.login()
|
|
||||||
|
|
||||||
return patterns.askLoginType().then (loginType) ->
|
|
||||||
|
|
||||||
if loginType is 'register'
|
|
||||||
signupUrl = 'https://dashboard.balena-cloud.com/signup'
|
|
||||||
require('opn')(signupUrl, { wait: false })
|
|
||||||
patterns.exitWithExpectedError("Please sign up at #{signupUrl}")
|
|
||||||
|
|
||||||
options[loginType] = true
|
|
||||||
return login(options)
|
|
||||||
|
|
||||||
balena.settings.get('balenaUrl').then (balenaUrl) ->
|
|
||||||
console.log(messages.balenaAsciiArt)
|
|
||||||
console.log("\nLogging in to #{balenaUrl}")
|
|
||||||
return login(options)
|
|
||||||
.then(balena.auth.whoami)
|
|
||||||
.tap (username) ->
|
|
||||||
console.info("Successfully logged in as: #{username}")
|
|
||||||
console.info """
|
|
||||||
|
|
||||||
Find out about the available commands by running:
|
|
||||||
|
|
||||||
$ balena help
|
|
||||||
|
|
||||||
#{messages.reachingOut}
|
|
||||||
"""
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.logout =
|
|
||||||
signature: 'logout'
|
|
||||||
description: 'logout from balena'
|
|
||||||
help: '''
|
|
||||||
Use this command to logout from your balena account.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena logout
|
|
||||||
'''
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.auth.logout().nodeify(done)
|
|
||||||
|
|
||||||
exports.whoami =
|
|
||||||
signature: 'whoami'
|
|
||||||
description: 'get current username and email address'
|
|
||||||
help: '''
|
|
||||||
Use this command to find out the current logged in username and email address.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena whoami
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
Promise.props
|
|
||||||
username: balena.auth.whoami()
|
|
||||||
email: balena.auth.getEmail()
|
|
||||||
url: balena.settings.get('balenaUrl')
|
|
||||||
.then (results) ->
|
|
||||||
console.log visuals.table.vertical results, [
|
|
||||||
'$account information$'
|
|
||||||
'username'
|
|
||||||
'email'
|
|
||||||
'url'
|
|
||||||
]
|
|
||||||
.nodeify(done)
|
|
@ -1,156 +0,0 @@
|
|||||||
# Imported here because it's needed for the setup
|
|
||||||
# of this action
|
|
||||||
Promise = require('bluebird')
|
|
||||||
dockerUtils = require('../utils/docker')
|
|
||||||
compose = require('../utils/compose')
|
|
||||||
{ registrySecretsHelp } = require('../utils/messages')
|
|
||||||
|
|
||||||
###
|
|
||||||
Opts must be an object with the following keys:
|
|
||||||
|
|
||||||
app: the app this build is for (optional)
|
|
||||||
arch: the architecture to build for
|
|
||||||
deviceType: the device type to build for
|
|
||||||
buildEmulated
|
|
||||||
buildOpts: arguments to forward to docker build command
|
|
||||||
###
|
|
||||||
buildProject = (docker, logger, composeOpts, opts) ->
|
|
||||||
compose.loadProject(
|
|
||||||
logger
|
|
||||||
composeOpts.projectPath
|
|
||||||
composeOpts.projectName
|
|
||||||
undefined # image: name of pre-built image
|
|
||||||
composeOpts.dockerfilePath # ok if undefined
|
|
||||||
)
|
|
||||||
.then (project) ->
|
|
||||||
appType = opts.app?.application_type?[0]
|
|
||||||
if appType? and project.descriptors.length > 1 and not appType.supports_multicontainer
|
|
||||||
logger.logWarn(
|
|
||||||
'Target application does not support multiple containers.\n' +
|
|
||||||
'Continuing with build, but you will not be able to deploy.'
|
|
||||||
)
|
|
||||||
|
|
||||||
compose.buildProject(
|
|
||||||
docker
|
|
||||||
logger
|
|
||||||
project.path
|
|
||||||
project.name
|
|
||||||
project.composition
|
|
||||||
opts.arch
|
|
||||||
opts.deviceType
|
|
||||||
opts.buildEmulated
|
|
||||||
opts.buildOpts
|
|
||||||
composeOpts.inlineLogs
|
|
||||||
)
|
|
||||||
.then ->
|
|
||||||
logger.logSuccess('Build succeeded!')
|
|
||||||
.tapCatch (e) ->
|
|
||||||
logger.logError('Build failed')
|
|
||||||
|
|
||||||
module.exports =
|
|
||||||
signature: 'build [source]'
|
|
||||||
description: 'Build a single image or a multicontainer project locally'
|
|
||||||
primary: true
|
|
||||||
help: """
|
|
||||||
Use this command to build an image or a complete multicontainer project with
|
|
||||||
the provided docker daemon in your development machine or balena device.
|
|
||||||
(See also the `balena push` command for the option of building images in the
|
|
||||||
balenaCloud build servers.)
|
|
||||||
|
|
||||||
You must provide either an application or a device-type/architecture pair to use
|
|
||||||
the balena Dockerfile pre-processor (e.g. Dockerfile.template -> Dockerfile).
|
|
||||||
|
|
||||||
This command will look into the given source directory (or the current working
|
|
||||||
directory if one isn't specified) for a docker-compose.yml file. If it is found,
|
|
||||||
this command will build each service defined in the compose file. If a compose
|
|
||||||
file isn't found, the command will look for a Dockerfile[.template] file (or
|
|
||||||
alternative Dockerfile specified with the `-f` option), and if yet that isn't
|
|
||||||
found, it will try to generate one.
|
|
||||||
|
|
||||||
#{registrySecretsHelp}
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena build
|
|
||||||
$ balena build ./source/
|
|
||||||
$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated
|
|
||||||
$ balena build --application MyApp ./source/
|
|
||||||
$ balena build --docker '/var/run/docker.sock'
|
|
||||||
$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem
|
|
||||||
"""
|
|
||||||
options: dockerUtils.appendOptions compose.appendOptions [
|
|
||||||
{
|
|
||||||
signature: 'arch'
|
|
||||||
parameter: 'arch'
|
|
||||||
description: 'The architecture to build for'
|
|
||||||
alias: 'A'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'deviceType'
|
|
||||||
parameter: 'deviceType'
|
|
||||||
description: 'The type of device this build is for'
|
|
||||||
alias: 'd'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'application'
|
|
||||||
parameter: 'application'
|
|
||||||
description: 'The target balena application this build is for'
|
|
||||||
alias: 'a'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
# compositions with many services trigger misleading warnings
|
|
||||||
require('events').defaultMaxListeners = 1000
|
|
||||||
|
|
||||||
sdk = (require('balena-sdk')).fromSharedOptions()
|
|
||||||
{ validateComposeOptions } = require('../utils/compose_ts')
|
|
||||||
{ exitWithExpectedError } = require('../utils/patterns')
|
|
||||||
helpers = require('../utils/helpers')
|
|
||||||
Logger = require('../utils/logger')
|
|
||||||
|
|
||||||
logger = new Logger()
|
|
||||||
|
|
||||||
logger.logDebug('Parsing input...')
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
# `build` accepts `[source]` as a parameter, but compose expects it
|
|
||||||
# as an option. swap them here
|
|
||||||
options.source ?= params.source
|
|
||||||
delete params.source
|
|
||||||
|
|
||||||
validateComposeOptions(sdk, options)
|
|
||||||
|
|
||||||
{ application, arch, deviceType } = options
|
|
||||||
|
|
||||||
if (not (arch? and deviceType?) and not application?) or (application? and (arch? or deviceType?))
|
|
||||||
exitWithExpectedError('You must specify either an application or an arch/deviceType pair to build for')
|
|
||||||
|
|
||||||
if arch? and deviceType?
|
|
||||||
[ undefined, arch, deviceType ]
|
|
||||||
else
|
|
||||||
Promise.join(
|
|
||||||
helpers.getApplication(application)
|
|
||||||
helpers.getArchAndDeviceType(application)
|
|
||||||
(app, { arch, device_type }) ->
|
|
||||||
app.arch = arch
|
|
||||||
app.device_type = device_type
|
|
||||||
return app
|
|
||||||
)
|
|
||||||
.then (app) ->
|
|
||||||
[ app, app.arch, app.device_type ]
|
|
||||||
|
|
||||||
.then ([ app, arch, deviceType ]) ->
|
|
||||||
Promise.join(
|
|
||||||
dockerUtils.getDocker(options)
|
|
||||||
dockerUtils.generateBuildOpts(options)
|
|
||||||
compose.generateOpts(options)
|
|
||||||
(docker, buildOpts, composeOpts) ->
|
|
||||||
buildProject(docker, logger, composeOpts, {
|
|
||||||
app
|
|
||||||
arch
|
|
||||||
deviceType
|
|
||||||
buildEmulated: !!options.emulated
|
|
||||||
buildOpts
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.asCallback(done)
|
|
@ -1,157 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import _ = require('lodash');
|
|
||||||
|
|
||||||
export const yes = {
|
|
||||||
signature: 'yes',
|
|
||||||
description: 'confirm non interactively',
|
|
||||||
boolean: true,
|
|
||||||
alias: 'y',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface YesOption {
|
|
||||||
yes: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const optionalApplication = {
|
|
||||||
signature: 'application',
|
|
||||||
parameter: 'application',
|
|
||||||
description: 'application name',
|
|
||||||
alias: ['a', 'app'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const application = _.defaults(
|
|
||||||
{ required: 'You have to specify an application' },
|
|
||||||
optionalApplication,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const optionalRelease = {
|
|
||||||
signature: 'release',
|
|
||||||
parameter: 'release',
|
|
||||||
description: 'release id',
|
|
||||||
alias: 'r',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const optionalDevice = {
|
|
||||||
signature: 'device',
|
|
||||||
parameter: 'device',
|
|
||||||
description: 'device uuid',
|
|
||||||
alias: 'd',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const optionalDeviceApiKey = {
|
|
||||||
signature: 'deviceApiKey',
|
|
||||||
description:
|
|
||||||
'custom device key - note that this is only supported on balenaOS 2.0.3+',
|
|
||||||
parameter: 'device-api-key',
|
|
||||||
alias: 'k',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const optionalDeviceType = {
|
|
||||||
signature: 'deviceType',
|
|
||||||
description: 'device type slug',
|
|
||||||
parameter: 'device-type',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const optionalOsVersion = {
|
|
||||||
signature: 'version',
|
|
||||||
description: 'a balenaOS version',
|
|
||||||
parameter: 'version',
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OptionalOsVersionOption = Partial<OsVersionOption>;
|
|
||||||
|
|
||||||
export const osVersion = _.defaults(
|
|
||||||
{
|
|
||||||
required: 'You have to specify an exact os version',
|
|
||||||
},
|
|
||||||
exports.optionalOsVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface OsVersionOption {
|
|
||||||
version?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const booleanDevice = {
|
|
||||||
signature: 'device',
|
|
||||||
description: 'device',
|
|
||||||
boolean: true,
|
|
||||||
alias: 'd',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const osVersionOrSemver = {
|
|
||||||
signature: 'version',
|
|
||||||
description: `\
|
|
||||||
exact version number, or a valid semver range,
|
|
||||||
or 'latest' (includes pre-releases),
|
|
||||||
or 'default' (excludes pre-releases if at least one stable version is available),
|
|
||||||
or 'recommended' (excludes pre-releases, will fail if only pre-release versions are available),
|
|
||||||
or 'menu' (will show the interactive menu)\
|
|
||||||
`,
|
|
||||||
parameter: 'version',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const network = {
|
|
||||||
signature: 'network',
|
|
||||||
parameter: 'network',
|
|
||||||
description: 'network type',
|
|
||||||
alias: 'n',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const wifiSsid = {
|
|
||||||
signature: 'ssid',
|
|
||||||
parameter: 'ssid',
|
|
||||||
description: 'wifi ssid, if network is wifi',
|
|
||||||
alias: 's',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const wifiKey = {
|
|
||||||
signature: 'key',
|
|
||||||
parameter: 'key',
|
|
||||||
description: 'wifi key, if network is wifi',
|
|
||||||
alias: 'k',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const forceUpdateLock = {
|
|
||||||
signature: 'force',
|
|
||||||
description: 'force action if the update lock is set',
|
|
||||||
boolean: true,
|
|
||||||
alias: 'f',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const drive = {
|
|
||||||
signature: 'drive',
|
|
||||||
description: `the drive to write the image to, like \`/dev/sdb\` or \`/dev/mmcblk0\`. \
|
|
||||||
Careful with this as you can erase your hard drive. \
|
|
||||||
Check \`balena util available-drives\` for available options.`,
|
|
||||||
parameter: 'drive',
|
|
||||||
alias: 'd',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const advancedConfig = {
|
|
||||||
signature: 'advanced',
|
|
||||||
description: 'show advanced configuration options',
|
|
||||||
boolean: true,
|
|
||||||
alias: 'v',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hostOSAccess = {
|
|
||||||
signature: 'host',
|
|
||||||
boolean: true,
|
|
||||||
description: 'access host OS (for devices with balenaOS >= 2.0.0+rev1)',
|
|
||||||
alias: 's',
|
|
||||||
};
|
|
@ -1,357 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2018 Balena Ltd.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
commandOptions = require('./command-options')
|
|
||||||
{ normalizeUuidProp } = require('../utils/normalization')
|
|
||||||
|
|
||||||
exports.read =
|
|
||||||
signature: 'config read'
|
|
||||||
description: 'read a device configuration'
|
|
||||||
help: '''
|
|
||||||
Use this command to read the config.json file from the mounted filesystem (e.g. SD card) of a provisioned device"
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena config read --type raspberry-pi
|
|
||||||
$ balena config read --type raspberry-pi --drive /dev/disk2
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'type'
|
|
||||||
description: 'device type (Check available types with `balena devices supported`)'
|
|
||||||
parameter: 'type'
|
|
||||||
alias: 't'
|
|
||||||
required: 'You have to specify a device type'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'drive'
|
|
||||||
description: 'drive'
|
|
||||||
parameter: 'drive'
|
|
||||||
alias: 'd'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
permission: 'user'
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
config = require('balena-config-json')
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
umountAsync = Promise.promisify(require('umount').umount)
|
|
||||||
prettyjson = require('prettyjson')
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
return options.drive or visuals.drive('Select the device drive')
|
|
||||||
.tap(umountAsync)
|
|
||||||
.then (drive) ->
|
|
||||||
return config.read(drive, options.type)
|
|
||||||
.tap (configJSON) ->
|
|
||||||
console.info(prettyjson.render(configJSON))
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.write =
|
|
||||||
signature: 'config write <key> <value>'
|
|
||||||
description: 'write a device configuration'
|
|
||||||
help: '''
|
|
||||||
Use this command to write the config.json file to the mounted filesystem (e.g. SD card) of a provisioned device
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena config write --type raspberry-pi username johndoe
|
|
||||||
$ balena config write --type raspberry-pi --drive /dev/disk2 username johndoe
|
|
||||||
$ balena config write --type raspberry-pi files.network/settings "..."
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'type'
|
|
||||||
description: 'device type (Check available types with `balena devices supported`)'
|
|
||||||
parameter: 'type'
|
|
||||||
alias: 't'
|
|
||||||
required: 'You have to specify a device type'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'drive'
|
|
||||||
description: 'drive'
|
|
||||||
parameter: 'drive'
|
|
||||||
alias: 'd'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
permission: 'user'
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
_ = require('lodash')
|
|
||||||
config = require('balena-config-json')
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
umountAsync = Promise.promisify(require('umount').umount)
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
return options.drive or visuals.drive('Select the device drive')
|
|
||||||
.tap(umountAsync)
|
|
||||||
.then (drive) ->
|
|
||||||
config.read(drive, options.type).then (configJSON) ->
|
|
||||||
console.info("Setting #{params.key} to #{params.value}")
|
|
||||||
_.set(configJSON, params.key, params.value)
|
|
||||||
return configJSON
|
|
||||||
.tap ->
|
|
||||||
return umountAsync(drive)
|
|
||||||
.then (configJSON) ->
|
|
||||||
return config.write(drive, options.type, configJSON)
|
|
||||||
.tap ->
|
|
||||||
console.info('Done')
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.inject =
|
|
||||||
signature: 'config inject <file>'
|
|
||||||
description: 'inject a device configuration file'
|
|
||||||
help: '''
|
|
||||||
Use this command to inject a config.json file to the mounted filesystem
|
|
||||||
(e.g. SD card or mounted balenaOS image) of a provisioned device"
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena config inject my/config.json --type raspberry-pi
|
|
||||||
$ balena config inject my/config.json --type raspberry-pi --drive /dev/disk2
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'type'
|
|
||||||
description: 'device type (Check available types with `balena devices supported`)'
|
|
||||||
parameter: 'type'
|
|
||||||
alias: 't'
|
|
||||||
required: 'You have to specify a device type'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'drive'
|
|
||||||
description: 'drive'
|
|
||||||
parameter: 'drive'
|
|
||||||
alias: 'd'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
permission: 'user'
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
config = require('balena-config-json')
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
umountAsync = Promise.promisify(require('umount').umount)
|
|
||||||
readFileAsync = Promise.promisify(require('fs').readFile)
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
return options.drive or visuals.drive('Select the device drive')
|
|
||||||
.tap(umountAsync)
|
|
||||||
.then (drive) ->
|
|
||||||
readFileAsync(params.file, 'utf8').then(JSON.parse).then (configJSON) ->
|
|
||||||
return config.write(drive, options.type, configJSON)
|
|
||||||
.tap ->
|
|
||||||
console.info('Done')
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.reconfigure =
|
|
||||||
signature: 'config reconfigure'
|
|
||||||
description: 'reconfigure a provisioned device'
|
|
||||||
help: '''
|
|
||||||
Use this command to reconfigure a provisioned device
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena config reconfigure --type raspberry-pi
|
|
||||||
$ balena config reconfigure --type raspberry-pi --advanced
|
|
||||||
$ balena config reconfigure --type raspberry-pi --drive /dev/disk2
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'type'
|
|
||||||
description: 'device type (Check available types with `balena devices supported`)'
|
|
||||||
parameter: 'type'
|
|
||||||
alias: 't'
|
|
||||||
required: 'You have to specify a device type'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'drive'
|
|
||||||
description: 'drive'
|
|
||||||
parameter: 'drive'
|
|
||||||
alias: 'd'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'advanced'
|
|
||||||
description: 'show advanced commands'
|
|
||||||
boolean: true
|
|
||||||
alias: 'v'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
permission: 'user'
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
config = require('balena-config-json')
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
{ runCommand } = require('../utils/helpers')
|
|
||||||
umountAsync = Promise.promisify(require('umount').umount)
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
return options.drive or visuals.drive('Select the device drive')
|
|
||||||
.tap(umountAsync)
|
|
||||||
.then (drive) ->
|
|
||||||
config.read(drive, options.type).get('uuid')
|
|
||||||
.tap ->
|
|
||||||
umountAsync(drive)
|
|
||||||
.then (uuid) ->
|
|
||||||
configureCommand = "os configure #{drive} --device #{uuid}"
|
|
||||||
if options.advanced
|
|
||||||
configureCommand += ' --advanced'
|
|
||||||
return runCommand(configureCommand)
|
|
||||||
.then ->
|
|
||||||
console.info('Done')
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.generate =
|
|
||||||
signature: 'config generate'
|
|
||||||
description: 'generate a config.json file'
|
|
||||||
help: '''
|
|
||||||
Use this command to generate a config.json for a device or application.
|
|
||||||
|
|
||||||
Calling this command with the exact version number of the targeted image is required.
|
|
||||||
|
|
||||||
This is interactive by default, but you can do this automatically without interactivity
|
|
||||||
by specifying an option for each question on the command line, if you know the questions
|
|
||||||
that will be asked for the relevant device type.
|
|
||||||
|
|
||||||
In case that you want to configure an image for an application with mixed device types,
|
|
||||||
you can pass the --device-type argument along with --app to specify the target device type.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena config generate --device 7cf02a6 --version 2.12.7
|
|
||||||
$ balena config generate --device 7cf02a6 --version 2.12.7 --generate-device-api-key
|
|
||||||
$ balena config generate --device 7cf02a6 --version 2.12.7 --device-api-key <existingDeviceKey>
|
|
||||||
$ balena config generate --device 7cf02a6 --version 2.12.7 --output config.json
|
|
||||||
$ balena config generate --app MyApp --version 2.12.7
|
|
||||||
$ balena config generate --app MyApp --version 2.12.7 --device-type fincm3
|
|
||||||
$ balena config generate --app MyApp --version 2.12.7 --output config.json
|
|
||||||
$ balena config generate --app MyApp --version 2.12.7 \
|
|
||||||
--network wifi --wifiSsid mySsid --wifiKey abcdefgh --appUpdatePollInterval 1
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
commandOptions.osVersion
|
|
||||||
commandOptions.optionalApplication
|
|
||||||
commandOptions.optionalDevice
|
|
||||||
commandOptions.optionalDeviceApiKey
|
|
||||||
commandOptions.optionalDeviceType
|
|
||||||
{
|
|
||||||
signature: 'generate-device-api-key'
|
|
||||||
description: 'generate a fresh device key for the device'
|
|
||||||
boolean: true
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'output'
|
|
||||||
description: 'output'
|
|
||||||
parameter: 'output'
|
|
||||||
alias: 'o'
|
|
||||||
}
|
|
||||||
# Options for non-interactive configuration
|
|
||||||
{
|
|
||||||
signature: 'network'
|
|
||||||
description: 'the network type to use: ethernet or wifi'
|
|
||||||
parameter: 'network'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'wifiSsid'
|
|
||||||
description: 'the wifi ssid to use (used only if --network is set to wifi)'
|
|
||||||
parameter: 'wifiSsid'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'wifiKey'
|
|
||||||
description: 'the wifi key to use (used only if --network is set to wifi)'
|
|
||||||
parameter: 'wifiKey'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'appUpdatePollInterval'
|
|
||||||
description: 'how frequently (in minutes) to poll for application updates'
|
|
||||||
parameter: 'appUpdatePollInterval'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(options, 'device')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
writeFileAsync = Promise.promisify(require('fs').writeFile)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
prettyjson = require('prettyjson')
|
|
||||||
|
|
||||||
{ generateDeviceConfig, generateApplicationConfig } = require('../utils/config')
|
|
||||||
helpers = require('../utils/helpers')
|
|
||||||
{ exitWithExpectedError } = require('../utils/patterns')
|
|
||||||
|
|
||||||
if not options.device? and not options.application?
|
|
||||||
exitWithExpectedError '''
|
|
||||||
You have to pass either a device or an application.
|
|
||||||
|
|
||||||
See the help page for examples:
|
|
||||||
|
|
||||||
$ balena help config generate
|
|
||||||
'''
|
|
||||||
|
|
||||||
if !options.application and options.deviceType
|
|
||||||
exitWithExpectedError '''
|
|
||||||
Specifying a different device type is only supported when
|
|
||||||
generating a config for an application:
|
|
||||||
|
|
||||||
* An application, with --app <appname>
|
|
||||||
* A specific device type, with --device-type <deviceTypeSlug>
|
|
||||||
|
|
||||||
See the help page for examples:
|
|
||||||
|
|
||||||
$ balena help config generate
|
|
||||||
'''
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
if options.device?
|
|
||||||
return balena.models.device.get(options.device)
|
|
||||||
return balena.models.application.get(options.application)
|
|
||||||
.then (resource) ->
|
|
||||||
deviceType = options.deviceType || resource.device_type
|
|
||||||
manifestPromise = balena.models.device.getManifestBySlug(deviceType)
|
|
||||||
|
|
||||||
if options.application && options.deviceType
|
|
||||||
app = resource
|
|
||||||
appManifestPromise = balena.models.device.getManifestBySlug(app.device_type)
|
|
||||||
manifestPromise = manifestPromise.tap (paramDeviceType) ->
|
|
||||||
appManifestPromise.then (appDeviceType) ->
|
|
||||||
if not helpers.areDeviceTypesCompatible(appDeviceType, paramDeviceType)
|
|
||||||
throw new balena.errors.BalenaInvalidDeviceType(
|
|
||||||
"Device type #{options.deviceType} is incompatible with application #{options.application}"
|
|
||||||
)
|
|
||||||
|
|
||||||
manifestPromise.get('options')
|
|
||||||
.then (formOptions) ->
|
|
||||||
# Pass params as an override: if there is any param with exactly the same name as a
|
|
||||||
# required option, that value is used (and the corresponding question is not asked)
|
|
||||||
form.run(formOptions, override: options)
|
|
||||||
.then (answers) ->
|
|
||||||
answers.version = options.version
|
|
||||||
|
|
||||||
if resource.uuid?
|
|
||||||
generateDeviceConfig(resource, options.deviceApiKey || options['generate-device-api-key'], answers)
|
|
||||||
else
|
|
||||||
answers.deviceType = deviceType
|
|
||||||
generateApplicationConfig(resource, answers)
|
|
||||||
.then (config) ->
|
|
||||||
if options.output?
|
|
||||||
return writeFileAsync(options.output, JSON.stringify(config))
|
|
||||||
|
|
||||||
console.log(prettyjson.render(config))
|
|
||||||
.nodeify(done)
|
|
@ -1,237 +0,0 @@
|
|||||||
# Imported here because it's needed for the setup
|
|
||||||
# of this action
|
|
||||||
Promise = require('bluebird')
|
|
||||||
dockerUtils = require('../utils/docker')
|
|
||||||
compose = require('../utils/compose')
|
|
||||||
{ registrySecretsHelp } = require('../utils/messages')
|
|
||||||
|
|
||||||
###
|
|
||||||
Opts must be an object with the following keys:
|
|
||||||
|
|
||||||
app: the application instance to deploy to
|
|
||||||
image: the image to deploy; optional
|
|
||||||
dockerfilePath: name of an alternative Dockerfile; optional
|
|
||||||
shouldPerformBuild
|
|
||||||
shouldUploadLogs
|
|
||||||
buildEmulated
|
|
||||||
buildOpts: arguments to forward to docker build command
|
|
||||||
###
|
|
||||||
deployProject = (docker, logger, composeOpts, opts) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
doodles = require('resin-doodles')
|
|
||||||
sdk = require('balena-sdk').fromSharedOptions()
|
|
||||||
|
|
||||||
compose.loadProject(
|
|
||||||
logger
|
|
||||||
composeOpts.projectPath
|
|
||||||
composeOpts.projectName
|
|
||||||
opts.image
|
|
||||||
composeOpts.dockerfilePath # ok if undefined
|
|
||||||
)
|
|
||||||
.then (project) ->
|
|
||||||
if project.descriptors.length > 1 and !opts.app.application_type?[0]?.supports_multicontainer
|
|
||||||
throw new Error('Target application does not support multiple containers. Aborting!')
|
|
||||||
|
|
||||||
# find which services use images that already exist locally
|
|
||||||
Promise.map project.descriptors, (d) ->
|
|
||||||
# unconditionally build (or pull) if explicitly requested
|
|
||||||
return d if opts.shouldPerformBuild
|
|
||||||
docker.getImage(d.image.tag ? d.image).inspect()
|
|
||||||
.return(d.serviceName)
|
|
||||||
.catchReturn()
|
|
||||||
.filter (d) -> !!d
|
|
||||||
.then (servicesToSkip) ->
|
|
||||||
# multibuild takes in a composition and always attempts to
|
|
||||||
# build or pull all services. we workaround that here by
|
|
||||||
# passing a modified composition.
|
|
||||||
compositionToBuild = _.cloneDeep(project.composition)
|
|
||||||
compositionToBuild.services = _.omit(compositionToBuild.services, servicesToSkip)
|
|
||||||
if _.size(compositionToBuild.services) is 0
|
|
||||||
logger.logInfo('Everything is up to date (use --build to force a rebuild)')
|
|
||||||
return {}
|
|
||||||
compose.buildProject(
|
|
||||||
docker
|
|
||||||
logger
|
|
||||||
project.path
|
|
||||||
project.name
|
|
||||||
compositionToBuild
|
|
||||||
opts.app.arch
|
|
||||||
opts.app.device_type
|
|
||||||
opts.buildEmulated
|
|
||||||
opts.buildOpts
|
|
||||||
composeOpts.inlineLogs
|
|
||||||
)
|
|
||||||
.then (builtImages) ->
|
|
||||||
_.keyBy(builtImages, 'serviceName')
|
|
||||||
.then (builtImages) ->
|
|
||||||
project.descriptors.map (d) ->
|
|
||||||
builtImages[d.serviceName] ? {
|
|
||||||
serviceName: d.serviceName,
|
|
||||||
name: d.image.tag ? d.image
|
|
||||||
logs: 'Build skipped; image for service already exists.'
|
|
||||||
props: {}
|
|
||||||
}
|
|
||||||
.then (images) ->
|
|
||||||
if opts.app.application_type?[0]?.is_legacy
|
|
||||||
chalk = require('chalk')
|
|
||||||
legacyDeploy = require('../utils/deploy-legacy')
|
|
||||||
|
|
||||||
msg = chalk.yellow('Target application requires legacy deploy method.')
|
|
||||||
logger.logWarn(msg)
|
|
||||||
|
|
||||||
return Promise.join(
|
|
||||||
docker
|
|
||||||
logger
|
|
||||||
sdk.auth.getToken()
|
|
||||||
sdk.auth.whoami()
|
|
||||||
sdk.settings.get('balenaUrl')
|
|
||||||
{
|
|
||||||
# opts.appName may be prefixed by 'owner/', unlike opts.app.app_name
|
|
||||||
appName: opts.appName
|
|
||||||
imageName: images[0].name
|
|
||||||
buildLogs: images[0].logs
|
|
||||||
shouldUploadLogs: opts.shouldUploadLogs
|
|
||||||
}
|
|
||||||
legacyDeploy
|
|
||||||
)
|
|
||||||
.then (releaseId) ->
|
|
||||||
sdk.models.release.get(releaseId, $select: [ 'commit' ])
|
|
||||||
Promise.join(
|
|
||||||
sdk.auth.getUserId()
|
|
||||||
sdk.auth.getToken()
|
|
||||||
sdk.settings.get('apiUrl')
|
|
||||||
(userId, auth, apiEndpoint) ->
|
|
||||||
compose.deployProject(
|
|
||||||
docker
|
|
||||||
logger
|
|
||||||
project.composition
|
|
||||||
images
|
|
||||||
opts.app.id
|
|
||||||
userId
|
|
||||||
"Bearer #{auth}"
|
|
||||||
apiEndpoint
|
|
||||||
!opts.shouldUploadLogs
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then (release) ->
|
|
||||||
logger.logSuccess('Deploy succeeded!')
|
|
||||||
logger.logSuccess("Release: #{release.commit}")
|
|
||||||
console.log()
|
|
||||||
console.log(doodles.getDoodle()) # Show charlie
|
|
||||||
console.log()
|
|
||||||
.tapCatch (e) ->
|
|
||||||
logger.logError('Deploy failed')
|
|
||||||
|
|
||||||
module.exports =
|
|
||||||
signature: 'deploy <appName> [image]'
|
|
||||||
description: 'Deploy a single image or a multicontainer project to a balena application'
|
|
||||||
help: """
|
|
||||||
Usage: `deploy <appName> ([image] | --build [--source build-dir])`
|
|
||||||
|
|
||||||
Use this command to deploy an image or a complete multicontainer project to an
|
|
||||||
application, optionally building it first. The source images are searched for
|
|
||||||
(and optionally built) using the docker daemon in your development machine or
|
|
||||||
balena device. (See also the `balena push` command for the option of building
|
|
||||||
the image in the balenaCloud build servers.)
|
|
||||||
|
|
||||||
Unless an image is specified, this command will look into the current directory
|
|
||||||
(or the one specified by --source) for a docker-compose.yml file. If one is
|
|
||||||
found, this command will deploy each service defined in the compose file,
|
|
||||||
building it first if an image for it doesn't exist. If a compose file isn't
|
|
||||||
found, the command will look for a Dockerfile[.template] file (or alternative
|
|
||||||
Dockerfile specified with the `-f` option), and if yet that isn't found, it
|
|
||||||
will try to generate one.
|
|
||||||
|
|
||||||
To deploy to an app on which you're a collaborator, use
|
|
||||||
`balena deploy <appOwnerUsername>/<appName>`.
|
|
||||||
|
|
||||||
When --build is used, all options supported by `balena build` are also supported
|
|
||||||
by this command.
|
|
||||||
|
|
||||||
#{registrySecretsHelp}
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena deploy myApp
|
|
||||||
$ balena deploy myApp --build --source myBuildDir/
|
|
||||||
$ balena deploy myApp myApp/myImage
|
|
||||||
"""
|
|
||||||
permission: 'user'
|
|
||||||
primary: true
|
|
||||||
options: dockerUtils.appendOptions compose.appendOptions [
|
|
||||||
{
|
|
||||||
signature: 'source'
|
|
||||||
parameter: 'source'
|
|
||||||
description: 'Specify an alternate source directory; default is the working directory'
|
|
||||||
alias: 's'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'build'
|
|
||||||
boolean: true
|
|
||||||
description: 'Force a rebuild before deploy'
|
|
||||||
alias: 'b'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'nologupload'
|
|
||||||
description: "Don't upload build logs to the dashboard with image (if building)"
|
|
||||||
boolean: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
# compositions with many services trigger misleading warnings
|
|
||||||
require('events').defaultMaxListeners = 1000
|
|
||||||
sdk = (require('balena-sdk')).fromSharedOptions()
|
|
||||||
{ validateComposeOptions } = require('../utils/compose_ts')
|
|
||||||
helpers = require('../utils/helpers')
|
|
||||||
Logger = require('../utils/logger')
|
|
||||||
|
|
||||||
logger = new Logger()
|
|
||||||
|
|
||||||
logger.logDebug('Parsing input...')
|
|
||||||
|
|
||||||
appName = undefined
|
|
||||||
Promise.try ->
|
|
||||||
# when Capitano converts a positional parameter (but not an option)
|
|
||||||
# to a number, the original value is preserved with the _raw suffix
|
|
||||||
{ appName, appName_raw, image } = params
|
|
||||||
|
|
||||||
# look into "balena build" options if appName isn't given
|
|
||||||
appName = appName_raw || appName || options.application
|
|
||||||
delete options.application
|
|
||||||
|
|
||||||
validateComposeOptions(sdk, options)
|
|
||||||
|
|
||||||
if not appName?
|
|
||||||
throw new Error('Please specify the name of the application to deploy')
|
|
||||||
|
|
||||||
if image? and options.build
|
|
||||||
throw new Error('Build option is not applicable when specifying an image')
|
|
||||||
|
|
||||||
Promise.join(
|
|
||||||
helpers.getApplication(appName)
|
|
||||||
helpers.getArchAndDeviceType(appName)
|
|
||||||
(app, { arch, device_type }) ->
|
|
||||||
app.arch = arch
|
|
||||||
app.device_type = device_type
|
|
||||||
return app
|
|
||||||
)
|
|
||||||
.then (app) ->
|
|
||||||
[ app, image, !!options.build, !options.nologupload ]
|
|
||||||
|
|
||||||
.then ([ app, image, shouldPerformBuild, shouldUploadLogs ]) ->
|
|
||||||
Promise.join(
|
|
||||||
dockerUtils.getDocker(options)
|
|
||||||
dockerUtils.generateBuildOpts(options)
|
|
||||||
compose.generateOpts(options)
|
|
||||||
(docker, buildOpts, composeOpts) ->
|
|
||||||
deployProject(docker, logger, composeOpts, {
|
|
||||||
app
|
|
||||||
appName # may be prefixed by 'owner/', unlike app.app_name
|
|
||||||
image
|
|
||||||
shouldPerformBuild
|
|
||||||
shouldUploadLogs
|
|
||||||
buildEmulated: !!options.emulated
|
|
||||||
buildOpts
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.asCallback(done)
|
|
@ -1,459 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
commandOptions = require('./command-options')
|
|
||||||
_ = require('lodash')
|
|
||||||
{ normalizeUuidProp } = require('../utils/normalization')
|
|
||||||
|
|
||||||
expandForAppName = {
|
|
||||||
$expand: belongs_to__application: $select: 'app_name'
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.list =
|
|
||||||
signature: 'devices'
|
|
||||||
description: 'list all devices'
|
|
||||||
help: '''
|
|
||||||
Use this command to list all devices that belong to you.
|
|
||||||
|
|
||||||
You can filter the devices by application by using the `--application` option.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena devices
|
|
||||||
$ balena devices --application MyApp
|
|
||||||
$ balena devices --app MyApp
|
|
||||||
$ balena devices -a MyApp
|
|
||||||
'''
|
|
||||||
options: [ commandOptions.optionalApplication ]
|
|
||||||
permission: 'user'
|
|
||||||
primary: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
if options.application?
|
|
||||||
return balena.models.device.getAllByApplication(options.application, expandForAppName)
|
|
||||||
return balena.models.device.getAll(expandForAppName)
|
|
||||||
|
|
||||||
.tap (devices) ->
|
|
||||||
devices = _.map devices, (device) ->
|
|
||||||
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid)
|
|
||||||
device.application_name = device.belongs_to__application[0].app_name
|
|
||||||
device.uuid = device.uuid.slice(0, 7)
|
|
||||||
return device
|
|
||||||
|
|
||||||
console.log visuals.table.horizontal devices, [
|
|
||||||
'id'
|
|
||||||
'uuid'
|
|
||||||
'device_name'
|
|
||||||
'device_type'
|
|
||||||
'application_name'
|
|
||||||
'status'
|
|
||||||
'is_online'
|
|
||||||
'supervisor_version'
|
|
||||||
'os_version'
|
|
||||||
'dashboard_url'
|
|
||||||
]
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.info =
|
|
||||||
signature: 'device <uuid>'
|
|
||||||
description: 'list a single device'
|
|
||||||
help: '''
|
|
||||||
Use this command to show information about a single device.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device 7cf02a6
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
primary: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
balena.models.device.get(params.uuid, expandForAppName)
|
|
||||||
.then (device) ->
|
|
||||||
balena.models.device.getStatus(device).then (status) ->
|
|
||||||
device.status = status
|
|
||||||
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid)
|
|
||||||
device.application_name = device.belongs_to__application[0].app_name
|
|
||||||
device.commit = device.is_on__commit
|
|
||||||
|
|
||||||
console.log visuals.table.vertical device, [
|
|
||||||
"$#{device.device_name}$"
|
|
||||||
'id'
|
|
||||||
'device_type'
|
|
||||||
'status'
|
|
||||||
'is_online'
|
|
||||||
'ip_address'
|
|
||||||
'application_name'
|
|
||||||
'last_seen'
|
|
||||||
'uuid'
|
|
||||||
'commit'
|
|
||||||
'supervisor_version'
|
|
||||||
'is_web_accessible'
|
|
||||||
'note'
|
|
||||||
'os_version'
|
|
||||||
'dashboard_url'
|
|
||||||
]
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.supported =
|
|
||||||
signature: 'devices supported'
|
|
||||||
description: 'list all supported devices'
|
|
||||||
help: '''
|
|
||||||
Use this command to get the list of all supported devices
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena devices supported
|
|
||||||
'''
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
balena.models.config.getDeviceTypes().then (deviceTypes) ->
|
|
||||||
console.log visuals.table.horizontal deviceTypes, [
|
|
||||||
'slug'
|
|
||||||
'name'
|
|
||||||
]
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.register =
|
|
||||||
signature: 'device register <application>'
|
|
||||||
description: 'register a device'
|
|
||||||
help: '''
|
|
||||||
Use this command to register a device to an application.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device register MyApp
|
|
||||||
$ balena device register MyApp --uuid <uuid>
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'uuid'
|
|
||||||
description: 'custom uuid'
|
|
||||||
parameter: 'uuid'
|
|
||||||
alias: 'u'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
|
|
||||||
Promise.join(
|
|
||||||
balena.models.application.get(params.application)
|
|
||||||
options.uuid ? balena.models.device.generateUniqueKey()
|
|
||||||
(application, uuid) ->
|
|
||||||
console.info("Registering to #{application.app_name}: #{uuid}")
|
|
||||||
return balena.models.device.register(application.id, uuid)
|
|
||||||
)
|
|
||||||
.get('uuid')
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.remove =
|
|
||||||
signature: 'device rm <uuid>'
|
|
||||||
description: 'remove a device'
|
|
||||||
help: '''
|
|
||||||
Use this command to remove a device from balena.
|
|
||||||
|
|
||||||
Notice this command asks for confirmation interactively.
|
|
||||||
You can avoid this by passing the `--yes` boolean option.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device rm 7cf02a6
|
|
||||||
$ balena device rm 7cf02a6 --yes
|
|
||||||
'''
|
|
||||||
options: [ commandOptions.yes ]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
|
|
||||||
patterns.confirm(options.yes, 'Are you sure you want to delete the device?').then ->
|
|
||||||
balena.models.device.remove(params.uuid)
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.identify =
|
|
||||||
signature: 'device identify <uuid>'
|
|
||||||
description: 'identify a device with a UUID'
|
|
||||||
help: '''
|
|
||||||
Use this command to identify a device.
|
|
||||||
|
|
||||||
In the Raspberry Pi, the ACT led is blinked several times.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device identify 23c73a1
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.device.identify(params.uuid).nodeify(done)
|
|
||||||
|
|
||||||
exports.reboot =
|
|
||||||
signature: 'device reboot <uuid>'
|
|
||||||
description: 'restart a device'
|
|
||||||
help: '''
|
|
||||||
Use this command to remotely reboot a device
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device reboot 23c73a1
|
|
||||||
'''
|
|
||||||
options: [ commandOptions.forceUpdateLock ]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.device.reboot(params.uuid, options).nodeify(done)
|
|
||||||
|
|
||||||
exports.shutdown =
|
|
||||||
signature: 'device shutdown <uuid>'
|
|
||||||
description: 'shutdown a device'
|
|
||||||
help: '''
|
|
||||||
Use this command to remotely shutdown a device
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device shutdown 23c73a1
|
|
||||||
'''
|
|
||||||
options: [ commandOptions.forceUpdateLock ]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.device.shutdown(params.uuid, options).nodeify(done)
|
|
||||||
|
|
||||||
exports.enableDeviceUrl =
|
|
||||||
signature: 'device public-url enable <uuid>'
|
|
||||||
description: 'enable public URL for a device'
|
|
||||||
help: '''
|
|
||||||
Use this command to enable public URL for a device
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device public-url enable 23c73a1
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.device.enableDeviceUrl(params.uuid).nodeify(done)
|
|
||||||
|
|
||||||
exports.disableDeviceUrl =
|
|
||||||
signature: 'device public-url disable <uuid>'
|
|
||||||
description: 'disable public URL for a device'
|
|
||||||
help: '''
|
|
||||||
Use this command to disable public URL for a device
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device public-url disable 23c73a1
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.device.disableDeviceUrl(params.uuid).nodeify(done)
|
|
||||||
|
|
||||||
exports.getDeviceUrl =
|
|
||||||
signature: 'device public-url <uuid>'
|
|
||||||
description: 'gets the public URL of a device'
|
|
||||||
help: '''
|
|
||||||
Use this command to get the public URL of a device
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device public-url 23c73a1
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.device.getDeviceUrl(params.uuid).then (url) ->
|
|
||||||
console.log(url)
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.hasDeviceUrl =
|
|
||||||
signature: 'device public-url status <uuid>'
|
|
||||||
description: 'Returns true if public URL is enabled for a device'
|
|
||||||
help: '''
|
|
||||||
Use this command to determine if public URL is enabled for a device
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device public-url status 23c73a1
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.device.hasDeviceUrl(params.uuid).then (hasDeviceUrl) ->
|
|
||||||
console.log(hasDeviceUrl)
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.rename =
|
|
||||||
signature: 'device rename <uuid> [newName]'
|
|
||||||
description: 'rename a balena device'
|
|
||||||
help: '''
|
|
||||||
Use this command to rename a device.
|
|
||||||
|
|
||||||
If you omit the name, you'll get asked for it interactively.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device rename 7cf02a6
|
|
||||||
$ balena device rename 7cf02a6 MyPi
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
Promise = require('bluebird')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
return params.newName if not _.isEmpty(params.newName)
|
|
||||||
|
|
||||||
form.ask
|
|
||||||
message: 'How do you want to name this device?'
|
|
||||||
type: 'input'
|
|
||||||
|
|
||||||
.then(_.partial(balena.models.device.rename, params.uuid))
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.move =
|
|
||||||
signature: 'device move <uuid>'
|
|
||||||
description: 'move a device to another application'
|
|
||||||
help: '''
|
|
||||||
Use this command to move a device to another application you own.
|
|
||||||
|
|
||||||
If you omit the application, you'll get asked for it interactively.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device move 7cf02a6
|
|
||||||
$ balena device move 7cf02a6 --application MyNewApp
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
options: [ commandOptions.optionalApplication ]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(params)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
|
|
||||||
balena.models.device.get(params.uuid, expandForAppName).then (device) ->
|
|
||||||
return options.application or patterns.selectApplication (application) ->
|
|
||||||
return _.every [
|
|
||||||
application.device_type is device.device_type
|
|
||||||
device.belongs_to__application[0].app_name isnt application.app_name
|
|
||||||
]
|
|
||||||
.tap (application) ->
|
|
||||||
return balena.models.device.move(params.uuid, application)
|
|
||||||
.then (application) ->
|
|
||||||
console.info("#{params.uuid} was moved to #{application}")
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.init =
|
|
||||||
signature: 'device init'
|
|
||||||
description: 'initialise a device with balenaOS'
|
|
||||||
help: '''
|
|
||||||
Use this command to download the OS image of a certain application and write it to an SD Card.
|
|
||||||
|
|
||||||
Notice this command may ask for confirmation interactively.
|
|
||||||
You can avoid this by passing the `--yes` boolean option.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device init
|
|
||||||
$ balena device init --application MyApp
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
commandOptions.optionalApplication
|
|
||||||
commandOptions.yes
|
|
||||||
commandOptions.advancedConfig
|
|
||||||
_.assign({}, commandOptions.osVersionOrSemver, { signature: 'os-version', parameter: 'os-version' })
|
|
||||||
commandOptions.drive
|
|
||||||
{
|
|
||||||
signature: 'config'
|
|
||||||
description: 'path to the config JSON file, see `balena os build-config`'
|
|
||||||
parameter: 'config'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
rimraf = Promise.promisify(require('rimraf'))
|
|
||||||
tmp = require('tmp')
|
|
||||||
tmpNameAsync = Promise.promisify(tmp.tmpName)
|
|
||||||
tmp.setGracefulCleanup()
|
|
||||||
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
{ runCommand } = require('../utils/helpers')
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
return options.application if options.application?
|
|
||||||
return patterns.selectApplication()
|
|
||||||
.then(balena.models.application.get)
|
|
||||||
.then (application) ->
|
|
||||||
|
|
||||||
download = ->
|
|
||||||
tmpNameAsync().then (tempPath) ->
|
|
||||||
osVersion = options['os-version'] or 'default'
|
|
||||||
runCommand("os download #{application.device_type} --output '#{tempPath}' --version #{osVersion}")
|
|
||||||
.disposer (tempPath) ->
|
|
||||||
return rimraf(tempPath)
|
|
||||||
|
|
||||||
Promise.using download(), (tempPath) ->
|
|
||||||
runCommand("device register #{application.app_name}")
|
|
||||||
.then(balena.models.device.get)
|
|
||||||
.tap (device) ->
|
|
||||||
configureCommand = "os configure '#{tempPath}' --device #{device.uuid}"
|
|
||||||
if options.config
|
|
||||||
configureCommand += " --config '#{options.config}'"
|
|
||||||
else if options.advanced
|
|
||||||
configureCommand += ' --advanced'
|
|
||||||
runCommand(configureCommand)
|
|
||||||
.then ->
|
|
||||||
osInitCommand = "os initialize '#{tempPath}' --type #{application.device_type}"
|
|
||||||
if options.yes
|
|
||||||
osInitCommand += ' --yes'
|
|
||||||
if options.drive
|
|
||||||
osInitCommand += " --drive #{options.drive}"
|
|
||||||
runCommand(osInitCommand)
|
|
||||||
# Make sure the device resource is removed if there is an
|
|
||||||
# error when configuring or initializing a device image
|
|
||||||
.catch (error) ->
|
|
||||||
balena.models.device.remove(device.uuid).finally ->
|
|
||||||
throw error
|
|
||||||
.then (device) ->
|
|
||||||
console.log('Done')
|
|
||||||
return device.uuid
|
|
||||||
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.osUpdate = require('./device_ts').osUpdate
|
|
@ -1,115 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
import { Device } from 'balena-sdk';
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
import { normalizeUuidProp } from '../utils/normalization';
|
|
||||||
import * as commandOptions from './command-options';
|
|
||||||
|
|
||||||
// tslint:disable-next-line:no-namespace
|
|
||||||
namespace OsUpdate {
|
|
||||||
export interface Args {
|
|
||||||
uuid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Options = commandOptions.OptionalOsVersionOption &
|
|
||||||
commandOptions.YesOption;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const osUpdate: CommandDefinition<OsUpdate.Args, OsUpdate.Options> = {
|
|
||||||
signature: 'device os-update <uuid>',
|
|
||||||
description: 'Start a Host OS update for a device',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to trigger a Host OS update for a device.
|
|
||||||
|
|
||||||
Notice this command will ask for confirmation interactively.
|
|
||||||
You can avoid this by passing the \`--yes\` boolean option.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena device os-update 23c73a1
|
|
||||||
$ balena device os-update 23c73a1 --version 2.31.0+rev1.prod
|
|
||||||
`,
|
|
||||||
options: [commandOptions.optionalOsVersion, commandOptions.yes],
|
|
||||||
permission: 'user',
|
|
||||||
async action(params, options, done) {
|
|
||||||
normalizeUuidProp(params);
|
|
||||||
const balena = await import('balena-sdk');
|
|
||||||
const _ = await import('lodash');
|
|
||||||
const sdk = balena.fromSharedOptions();
|
|
||||||
const patterns = await import('../utils/patterns');
|
|
||||||
const form = await import('resin-cli-form');
|
|
||||||
|
|
||||||
return sdk.models.device
|
|
||||||
.get(params.uuid, {
|
|
||||||
$select: ['uuid', 'device_type', 'os_version', 'os_variant'],
|
|
||||||
})
|
|
||||||
.then(async ({ uuid, device_type, os_version, os_variant }) => {
|
|
||||||
const currentOsVersion = sdk.models.device.getOsVersion({
|
|
||||||
os_version,
|
|
||||||
os_variant,
|
|
||||||
} as Device);
|
|
||||||
if (!currentOsVersion) {
|
|
||||||
patterns.exitWithExpectedError(
|
|
||||||
'The current os version of the device is not available',
|
|
||||||
);
|
|
||||||
// Just to make TS happy
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sdk.models.os
|
|
||||||
.getSupportedOsUpdateVersions(device_type, currentOsVersion)
|
|
||||||
.then(hupVersionInfo => {
|
|
||||||
if (hupVersionInfo.versions.length === 0) {
|
|
||||||
patterns.exitWithExpectedError(
|
|
||||||
'There are no available Host OS update targets for this device',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.version != null) {
|
|
||||||
if (!_.includes(hupVersionInfo.versions, options.version)) {
|
|
||||||
patterns.exitWithExpectedError(
|
|
||||||
'The provided version is not in the Host OS update targets for this device',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return options.version;
|
|
||||||
}
|
|
||||||
|
|
||||||
return form.ask({
|
|
||||||
message: 'Target OS version',
|
|
||||||
type: 'list',
|
|
||||||
choices: hupVersionInfo.versions.map(version => ({
|
|
||||||
name:
|
|
||||||
hupVersionInfo.recommended === version
|
|
||||||
? `${version} (recommended)`
|
|
||||||
: version,
|
|
||||||
value: version,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(version =>
|
|
||||||
patterns
|
|
||||||
.confirm(
|
|
||||||
options.yes || false,
|
|
||||||
'Host OS updates require a device restart when they complete. Are you sure you want to proceed?',
|
|
||||||
)
|
|
||||||
.then(() => sdk.models.device.startOsUpdate(uuid, version))
|
|
||||||
.then(() => patterns.awaitDeviceOsUpdate(uuid, version)),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,211 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
import { ApplicationVariable, DeviceVariable } from 'balena-sdk';
|
|
||||||
import * as Bluebird from 'bluebird';
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
import { normalizeUuidProp } from '../utils/normalization';
|
|
||||||
import * as commandOptions from './command-options';
|
|
||||||
|
|
||||||
export const list: CommandDefinition<
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
application?: string;
|
|
||||||
device?: string;
|
|
||||||
config: boolean;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'envs',
|
|
||||||
description: 'list all environment variables',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to list the environment variables of an application
|
|
||||||
or device.
|
|
||||||
|
|
||||||
The --config option is used to list "config" variables that configure
|
|
||||||
balena features.
|
|
||||||
|
|
||||||
Service-specific variables are not currently supported. The following
|
|
||||||
examples list variables that apply to all services in an app or device.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
$ balena envs --application MyApp
|
|
||||||
$ balena envs --application MyApp --config
|
|
||||||
$ balena envs --device 7cf02a6
|
|
||||||
`,
|
|
||||||
options: [
|
|
||||||
commandOptions.optionalApplication,
|
|
||||||
commandOptions.optionalDevice,
|
|
||||||
|
|
||||||
{
|
|
||||||
signature: 'config',
|
|
||||||
description: 'show config variables',
|
|
||||||
boolean: true,
|
|
||||||
alias: ['c', 'v', 'verbose'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
permission: 'user',
|
|
||||||
async action(_params, options, done) {
|
|
||||||
normalizeUuidProp(options, 'device');
|
|
||||||
const _ = await import('lodash');
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const visuals = await import('resin-cli-visuals');
|
|
||||||
|
|
||||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
|
||||||
|
|
||||||
return Bluebird.try(function(): Bluebird<
|
|
||||||
DeviceVariable[] | ApplicationVariable[]
|
|
||||||
> {
|
|
||||||
if (options.application) {
|
|
||||||
return balena.models.application[
|
|
||||||
options.config ? 'configVar' : 'envVar'
|
|
||||||
].getAllByApplication(options.application);
|
|
||||||
} else if (options.device) {
|
|
||||||
return balena.models.device[
|
|
||||||
options.config ? 'configVar' : 'envVar'
|
|
||||||
].getAllByDevice(options.device);
|
|
||||||
} else {
|
|
||||||
return exitWithExpectedError(
|
|
||||||
'You must specify an application or device',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.tap(function(environmentVariables) {
|
|
||||||
if (_.isEmpty(environmentVariables)) {
|
|
||||||
exitWithExpectedError('No environment variables found');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
visuals.table.horizontal(environmentVariables, [
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'value',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const remove: CommandDefinition<
|
|
||||||
{
|
|
||||||
id: number;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
yes: boolean;
|
|
||||||
device: boolean;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'env rm <id>',
|
|
||||||
description: 'remove an environment variable',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to remove an environment variable from an application
|
|
||||||
or device.
|
|
||||||
|
|
||||||
Notice this command asks for confirmation interactively.
|
|
||||||
You can avoid this by passing the \`--yes\` boolean option.
|
|
||||||
|
|
||||||
The --device option selects a device instead of an application.
|
|
||||||
|
|
||||||
Service-specific variables are not currently supported. The following
|
|
||||||
examples remove variables that apply to all services in an app or device.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena env rm 215
|
|
||||||
$ balena env rm 215 --yes
|
|
||||||
$ balena env rm 215 --device
|
|
||||||
`,
|
|
||||||
options: [commandOptions.yes, commandOptions.booleanDevice],
|
|
||||||
permission: 'user',
|
|
||||||
async action(params, options, done) {
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const patterns = await import('../utils/patterns');
|
|
||||||
|
|
||||||
return patterns
|
|
||||||
.confirm(
|
|
||||||
options.yes || false,
|
|
||||||
'Are you sure you want to delete the environment variable?',
|
|
||||||
)
|
|
||||||
.then(function() {
|
|
||||||
if (options.device) {
|
|
||||||
return balena.pine.delete({
|
|
||||||
resource: 'device_environment_variable',
|
|
||||||
id: params.id,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return balena.pine.delete({
|
|
||||||
resource: 'application_environment_variable',
|
|
||||||
id: params.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rename: CommandDefinition<
|
|
||||||
{
|
|
||||||
id: number;
|
|
||||||
value: string;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
device: boolean;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'env rename <id> <value>',
|
|
||||||
description: 'rename an environment variable',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to change the value of an application or device
|
|
||||||
environment variable.
|
|
||||||
|
|
||||||
The --device option selects a device instead of an application.
|
|
||||||
|
|
||||||
Service-specific variables are not currently supported. The following
|
|
||||||
examples modify variables that apply to all services in an app or device.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena env rename 376 emacs
|
|
||||||
$ balena env rename 376 emacs --device
|
|
||||||
`,
|
|
||||||
permission: 'user',
|
|
||||||
options: [commandOptions.booleanDevice],
|
|
||||||
async action(params, options, done) {
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
|
|
||||||
return Bluebird.try(function() {
|
|
||||||
if (options.device) {
|
|
||||||
return balena.pine.patch({
|
|
||||||
resource: 'device_environment_variable',
|
|
||||||
id: params.id,
|
|
||||||
body: {
|
|
||||||
value: params.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return balena.pine.patch({
|
|
||||||
resource: 'application_environment_variable',
|
|
||||||
id: params.id,
|
|
||||||
body: {
|
|
||||||
value: params.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}).nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,145 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
_ = require('lodash')
|
|
||||||
capitano = require('capitano')
|
|
||||||
columnify = require('columnify')
|
|
||||||
|
|
||||||
messages = require('../utils/messages')
|
|
||||||
{ getManualSortCompareFunction } = require('../utils/helpers')
|
|
||||||
{ exitWithExpectedError } = require('../utils/patterns')
|
|
||||||
{ getOclifHelpLinePairs } = require('./help_ts')
|
|
||||||
|
|
||||||
parse = (object) ->
|
|
||||||
return _.map object, (item) ->
|
|
||||||
|
|
||||||
# Hacky way to determine if an object is
|
|
||||||
# a function or a command
|
|
||||||
if item.alias?
|
|
||||||
signature = item.toString()
|
|
||||||
else
|
|
||||||
signature = item.signature.toString()
|
|
||||||
|
|
||||||
return [
|
|
||||||
signature
|
|
||||||
item.description
|
|
||||||
]
|
|
||||||
|
|
||||||
indent = (text) ->
|
|
||||||
text = _.map text.split('\n'), (line) ->
|
|
||||||
return ' ' + line
|
|
||||||
return text.join('\n')
|
|
||||||
|
|
||||||
print = (usageDescriptionPairs) ->
|
|
||||||
console.log indent columnify _.fromPairs(usageDescriptionPairs),
|
|
||||||
showHeaders: false
|
|
||||||
minWidth: 35
|
|
||||||
|
|
||||||
manuallySortedPrimaryCommands = [
|
|
||||||
'help',
|
|
||||||
'login',
|
|
||||||
'push',
|
|
||||||
'logs',
|
|
||||||
'ssh',
|
|
||||||
'apps',
|
|
||||||
'app',
|
|
||||||
'devices',
|
|
||||||
'device',
|
|
||||||
'tunnel',
|
|
||||||
'preload',
|
|
||||||
'build',
|
|
||||||
'deploy',
|
|
||||||
'join',
|
|
||||||
'leave',
|
|
||||||
'local scan',
|
|
||||||
]
|
|
||||||
|
|
||||||
general = (params, options, done) ->
|
|
||||||
console.log('Usage: balena [COMMAND] [OPTIONS]\n')
|
|
||||||
console.log(messages.reachingOut)
|
|
||||||
console.log('\nPrimary commands:\n')
|
|
||||||
|
|
||||||
# We do not want the wildcard command
|
|
||||||
# to be printed in the help screen.
|
|
||||||
commands = _.reject capitano.state.commands, (command) ->
|
|
||||||
return command.hidden or command.isWildcard()
|
|
||||||
|
|
||||||
groupedCommands = _.groupBy commands, (command) ->
|
|
||||||
if command.primary
|
|
||||||
return 'primary'
|
|
||||||
return 'secondary'
|
|
||||||
|
|
||||||
print parse(groupedCommands.primary).sort(getManualSortCompareFunction(
|
|
||||||
manuallySortedPrimaryCommands,
|
|
||||||
([signature, description], manualItem) ->
|
|
||||||
signature == manualItem or signature.startsWith("#{manualItem} ")
|
|
||||||
))
|
|
||||||
|
|
||||||
if options.verbose
|
|
||||||
console.log('\nAdditional commands:\n')
|
|
||||||
print parse(groupedCommands.secondary).concat(getOclifHelpLinePairs()).sort()
|
|
||||||
else
|
|
||||||
console.log('\nRun `balena help --verbose` to list additional commands')
|
|
||||||
|
|
||||||
if not _.isEmpty(capitano.state.globalOptions)
|
|
||||||
console.log('\nGlobal Options:\n')
|
|
||||||
print parse(capitano.state.globalOptions).sort()
|
|
||||||
|
|
||||||
return done()
|
|
||||||
|
|
||||||
command = (params, options, done) ->
|
|
||||||
capitano.state.getMatchCommand params.command, (error, command) ->
|
|
||||||
return done(error) if error?
|
|
||||||
|
|
||||||
if not command? or command.isWildcard()
|
|
||||||
exitWithExpectedError("Command not found: #{params.command}")
|
|
||||||
|
|
||||||
console.log("Usage: #{command.signature}")
|
|
||||||
|
|
||||||
if command.help?
|
|
||||||
console.log("\n#{command.help}")
|
|
||||||
else if command.description?
|
|
||||||
console.log("\n#{_.capitalize(command.description)}")
|
|
||||||
|
|
||||||
if not _.isEmpty(command.options)
|
|
||||||
console.log('\nOptions:\n')
|
|
||||||
print parse(command.options).sort()
|
|
||||||
|
|
||||||
return done()
|
|
||||||
|
|
||||||
exports.help =
|
|
||||||
signature: 'help [command...]'
|
|
||||||
description: 'show help'
|
|
||||||
help: '''
|
|
||||||
Get detailed help for an specific command.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena help apps
|
|
||||||
$ balena help os download
|
|
||||||
'''
|
|
||||||
primary: true
|
|
||||||
options: [
|
|
||||||
signature: 'verbose'
|
|
||||||
description: 'show additional commands'
|
|
||||||
boolean: true
|
|
||||||
alias: 'v'
|
|
||||||
]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
if params.command?
|
|
||||||
command(params, options, done)
|
|
||||||
else
|
|
||||||
general(params, options, done)
|
|
@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2019 Balena Ltd.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Command } from '@oclif/command';
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
|
|
||||||
export function getOclifHelpLinePairs(): Array<[string, string]> {
|
|
||||||
// Although it's tempting to have these oclif commands 'require'd in a
|
|
||||||
// central place, it would impact on performance (CLI start time). An
|
|
||||||
// improvement would probably be to automatically scan the actions-oclif
|
|
||||||
// folder.
|
|
||||||
const EnvAddCmd = require('../actions-oclif/env/add').default;
|
|
||||||
const VersionCmd = require('../actions-oclif/version').default;
|
|
||||||
return [EnvAddCmd, VersionCmd].map(getCmdUsageDescriptionLinePair);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] {
|
|
||||||
const usage = (cmd.usage || '').toString().toLowerCase();
|
|
||||||
let description = '';
|
|
||||||
// note: [^] matches any characters (including line breaks), achieving the
|
|
||||||
// same effect as the 's' regex flag which is only supported by Node 9+
|
|
||||||
const matches = /\s*([^]+?)\n[^]*/.exec(cmd.description || '');
|
|
||||||
if (matches && matches.length > 1) {
|
|
||||||
description = _.lowerFirst(_.trimEnd(matches[1], '.'));
|
|
||||||
}
|
|
||||||
return [usage, description];
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
module.exports =
|
|
||||||
apiKey: require('./api-key')
|
|
||||||
app: require('./app')
|
|
||||||
auth: require('./auth')
|
|
||||||
device: require('./device')
|
|
||||||
env: require('./environment-variables')
|
|
||||||
tags: require('./tags')
|
|
||||||
keys: require('./keys')
|
|
||||||
logs: require('./logs')
|
|
||||||
local: require('./local')
|
|
||||||
scan: require('./scan')
|
|
||||||
notes: require('./notes')
|
|
||||||
help: require('./help')
|
|
||||||
os: require('./os')
|
|
||||||
settings: require('./settings')
|
|
||||||
config: require('./config')
|
|
||||||
ssh: require('./ssh')
|
|
||||||
internal: require('./internal')
|
|
||||||
build: require('./build')
|
|
||||||
deploy: require('./deploy')
|
|
||||||
util: require('./util')
|
|
||||||
preload: require('./preload')
|
|
||||||
push: require('./push')
|
|
||||||
join: require('./join')
|
|
||||||
leave: require('./leave')
|
|
||||||
tunnel: require('./tunnel')
|
|
@ -1,88 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
# These are internal commands we want to be runnable from the outside
|
|
||||||
# One use-case for this is spawning the minimal operation with root priviledges
|
|
||||||
|
|
||||||
exports.osInit =
|
|
||||||
signature: 'internal osinit <image> <type> <config>'
|
|
||||||
description: 'do actual init of the device with the preconfigured os image'
|
|
||||||
help: '''
|
|
||||||
Don't use this command directly! Use `balena os initialize <image>` instead.
|
|
||||||
'''
|
|
||||||
hidden: true
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
init = require('balena-device-init')
|
|
||||||
helpers = require('../utils/helpers')
|
|
||||||
|
|
||||||
configPromise = Promise.try -> JSON.parse(params.config)
|
|
||||||
manifestPromise = helpers.getManifest(params.image, params.type)
|
|
||||||
Promise.join configPromise, manifestPromise, (config, manifest) ->
|
|
||||||
init.initialize(params.image, manifest, config)
|
|
||||||
.then(helpers.osProgressHandler)
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.scanDevices =
|
|
||||||
signature: 'internal scandevices'
|
|
||||||
description: 'scan for local balena-enabled devices and show a picker to choose one'
|
|
||||||
help: '''
|
|
||||||
Don't use this command directly!
|
|
||||||
'''
|
|
||||||
hidden: true
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
{ forms } = require('balena-sync')
|
|
||||||
|
|
||||||
return Promise.try ->
|
|
||||||
forms.selectLocalBalenaOsDevice()
|
|
||||||
.then (hostnameOrIp) ->
|
|
||||||
console.error("==> Selected device: #{hostnameOrIp}")
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.sudo =
|
|
||||||
signature: 'internal sudo <command>'
|
|
||||||
description: 'execute arbitrary commands in a privileged subprocess'
|
|
||||||
help: '''
|
|
||||||
Don't use this command directly!
|
|
||||||
|
|
||||||
<command> must be passed as a single argument. That means, you need to make sure
|
|
||||||
you enclose <command> in quotes (eg. balena internal sudo 'ls -alF') if for
|
|
||||||
whatever reason you invoke the command directly or, typically, pass <command>
|
|
||||||
as a single argument to spawn (eg. `spawn('balena', [ 'internal', 'sudo', 'ls -alF' ])`).
|
|
||||||
|
|
||||||
Furthermore, this command will naively split <command> on whitespace and directly
|
|
||||||
forward the parts as arguments to `sudo`, so be careful.
|
|
||||||
'''
|
|
||||||
hidden: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
os = require('os')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
|
|
||||||
return Promise.try ->
|
|
||||||
if os.platform() is 'win32'
|
|
||||||
windosu = require('windosu')
|
|
||||||
windosu.exec(params.command, {})
|
|
||||||
else
|
|
||||||
{ spawn } = require('child_process')
|
|
||||||
{ wait } = require('rindle')
|
|
||||||
cmd = params.command.split(' ')
|
|
||||||
ps = spawn('sudo', cmd, stdio: 'inherit', env: process.env)
|
|
||||||
wait(ps)
|
|
||||||
|
|
||||||
.nodeify(done)
|
|
@ -1,74 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
import * as Bluebird from 'bluebird';
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
interface Args {
|
|
||||||
deviceIp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
application?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const join: CommandDefinition<Args, Options> = {
|
|
||||||
signature: 'join [deviceIp]',
|
|
||||||
description:
|
|
||||||
'Promote a local device running balenaOS to join an application on a balena server',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to move a local device to an application on another balena server.
|
|
||||||
|
|
||||||
For example, you could provision a device against an openBalena installation
|
|
||||||
where you perform end-to-end tests and then move it to balenaCloud when it's
|
|
||||||
ready for production.
|
|
||||||
|
|
||||||
Moving a device between applications on the same server is not supported.
|
|
||||||
|
|
||||||
If you don't specify a device hostname or IP, this command will automatically
|
|
||||||
scan the local network for balenaOS devices and prompt you to select one
|
|
||||||
from an interactive picker. This usually requires root privileges.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena join
|
|
||||||
$ balena join balena.local
|
|
||||||
$ balena join balena.local --application MyApp
|
|
||||||
$ balena join 192.168.1.25
|
|
||||||
$ balena join 192.168.1.25 --application MyApp
|
|
||||||
`,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'application',
|
|
||||||
parameter: 'application',
|
|
||||||
alias: 'a',
|
|
||||||
description: 'The name of the application the device should join',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
primary: true,
|
|
||||||
|
|
||||||
async action(params, options, done) {
|
|
||||||
const balena = await import('balena-sdk');
|
|
||||||
const Logger = await import('../utils/logger');
|
|
||||||
const promote = await import('../utils/promote');
|
|
||||||
const sdk = balena.fromSharedOptions();
|
|
||||||
const logger = new Logger();
|
|
||||||
return Bluebird.try(() => {
|
|
||||||
return promote.join(logger, sdk, params.deviceIp, options.application);
|
|
||||||
}).nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,123 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
commandOptions = require('./command-options')
|
|
||||||
|
|
||||||
exports.list =
|
|
||||||
signature: 'keys'
|
|
||||||
description: 'list all ssh keys'
|
|
||||||
help: '''
|
|
||||||
Use this command to list all your SSH keys.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena keys
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
balena.models.key.getAll().then (keys) ->
|
|
||||||
console.log visuals.table.horizontal keys, [
|
|
||||||
'id'
|
|
||||||
'title'
|
|
||||||
]
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.info =
|
|
||||||
signature: 'key <id>'
|
|
||||||
description: 'list a single ssh key'
|
|
||||||
help: '''
|
|
||||||
Use this command to show information about a single SSH key.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena key 17
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
balena.models.key.get(params.id).then (key) ->
|
|
||||||
console.log visuals.table.vertical key, [
|
|
||||||
'id'
|
|
||||||
'title'
|
|
||||||
]
|
|
||||||
|
|
||||||
# Since the public key string is long, it might
|
|
||||||
# wrap to lines below, causing the table layout to break.
|
|
||||||
# See https://github.com/balena-io/balena-cli/issues/151
|
|
||||||
console.log('\n' + key.public_key)
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.remove =
|
|
||||||
signature: 'key rm <id>'
|
|
||||||
description: 'remove a ssh key'
|
|
||||||
help: '''
|
|
||||||
Use this command to remove a SSH key from balena.
|
|
||||||
|
|
||||||
Notice this command asks for confirmation interactively.
|
|
||||||
You can avoid this by passing the `--yes` boolean option.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena key rm 17
|
|
||||||
$ balena key rm 17 --yes
|
|
||||||
'''
|
|
||||||
options: [ commandOptions.yes ]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
|
|
||||||
patterns.confirm(options.yes, 'Are you sure you want to delete the key?').then ->
|
|
||||||
balena.models.key.remove(params.id)
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.add =
|
|
||||||
signature: 'key add <name> [path]'
|
|
||||||
description: 'add a SSH key to balena'
|
|
||||||
help: '''
|
|
||||||
Use this command to associate a new SSH key with your account.
|
|
||||||
|
|
||||||
If `path` is omitted, the command will attempt
|
|
||||||
to read the SSH key from stdin.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena key add Main ~/.ssh/id_rsa.pub
|
|
||||||
$ cat ~/.ssh/id_rsa.pub | balena key add Main
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
readFileAsync = Promise.promisify(require('fs').readFile)
|
|
||||||
capitano = require('capitano')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
return readFileAsync(params.path, encoding: 'utf8') if params.path?
|
|
||||||
|
|
||||||
# TODO: should this be promisified for consistency?
|
|
||||||
Promise.fromNode (callback) ->
|
|
||||||
capitano.utils.getStdin (data) ->
|
|
||||||
return callback(null, data)
|
|
||||||
|
|
||||||
.then(_.partial(balena.models.key.create, params.name))
|
|
||||||
.nodeify(done)
|
|
@ -1,59 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
import * as Bluebird from 'bluebird';
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
interface Args {
|
|
||||||
deviceIp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const leave: CommandDefinition<Args, {}> = {
|
|
||||||
signature: 'leave [deviceIp]',
|
|
||||||
description: 'Detach a local device from its balena application',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to make a local device leave the balena server it is
|
|
||||||
provisioned on. This effectively makes the device "unmanaged".
|
|
||||||
|
|
||||||
The device entry on the server is preserved after running this command,
|
|
||||||
so the device can subsequently re-join the server if needed.
|
|
||||||
|
|
||||||
If you don't specify a device hostname or IP, this command will automatically
|
|
||||||
scan the local network for balenaOS devices and prompt you to select one
|
|
||||||
from an interactive picker. This usually requires root privileges.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena leave
|
|
||||||
$ balena leave balena.local
|
|
||||||
$ balena leave 192.168.1.25
|
|
||||||
`,
|
|
||||||
options: [],
|
|
||||||
|
|
||||||
permission: 'user',
|
|
||||||
primary: true,
|
|
||||||
|
|
||||||
async action(params, _options, done) {
|
|
||||||
const balena = await import('balena-sdk');
|
|
||||||
const Logger = await import('../utils/logger');
|
|
||||||
const promote = await import('../utils/promote');
|
|
||||||
const sdk = balena.fromSharedOptions();
|
|
||||||
const logger = new Logger();
|
|
||||||
return Bluebird.try(() => {
|
|
||||||
return promote.leave(logger, sdk, params.deviceIp);
|
|
||||||
}).nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,62 +0,0 @@
|
|||||||
Promise = require('bluebird')
|
|
||||||
_ = require('lodash')
|
|
||||||
chalk = require('chalk')
|
|
||||||
|
|
||||||
dockerUtils = require('../../utils/docker')
|
|
||||||
{ exitWithExpectedError } = require('../../utils/patterns')
|
|
||||||
|
|
||||||
exports.dockerPort = dockerPort = 2375
|
|
||||||
exports.dockerTimeout = dockerTimeout = 2000
|
|
||||||
|
|
||||||
exports.filterOutSupervisorContainer = filterOutSupervisorContainer = (container) ->
|
|
||||||
for name in container.Names
|
|
||||||
return false if (name.includes('resin_supervisor') or name.includes('balena_supervisor'))
|
|
||||||
return true
|
|
||||||
|
|
||||||
exports.selectContainerFromDevice = Promise.method (deviceIp, filterSupervisor = false) ->
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
docker = dockerUtils.createClient(host: deviceIp, port: dockerPort, timeout: dockerTimeout)
|
|
||||||
|
|
||||||
# List all containers, including those not running
|
|
||||||
docker.listContainersAsync(all: true)
|
|
||||||
.filter (container) ->
|
|
||||||
return true if not filterSupervisor
|
|
||||||
filterOutSupervisorContainer(container)
|
|
||||||
.then (containers) ->
|
|
||||||
if _.isEmpty(containers)
|
|
||||||
exitWithExpectedError("No containers found in #{deviceIp}")
|
|
||||||
|
|
||||||
return form.ask
|
|
||||||
message: 'Select a container'
|
|
||||||
type: 'list'
|
|
||||||
choices: _.map containers, (container) ->
|
|
||||||
containerName = container.Names?[0] or 'Untitled'
|
|
||||||
shortContainerId = ('' + container.Id).substr(0, 11)
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: "#{containerName} (#{shortContainerId})"
|
|
||||||
value: container.Id
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.pipeContainerStream = Promise.method ({ deviceIp, name, outStream, follow = false }) ->
|
|
||||||
docker = dockerUtils.createClient(host: deviceIp, port: dockerPort)
|
|
||||||
|
|
||||||
container = docker.getContainer(name)
|
|
||||||
container.inspectAsync()
|
|
||||||
.then (containerInfo) ->
|
|
||||||
return containerInfo?.State?.Running
|
|
||||||
.then (isRunning) ->
|
|
||||||
container.attachAsync
|
|
||||||
logs: not follow or not isRunning
|
|
||||||
stream: follow and isRunning
|
|
||||||
stdout: true
|
|
||||||
stderr: true
|
|
||||||
.then (containerStream) ->
|
|
||||||
containerStream.pipe(outStream)
|
|
||||||
.catch (err) ->
|
|
||||||
err = '' + err.statusCode
|
|
||||||
if err is '404'
|
|
||||||
return console.log(chalk.red.bold("Container '#{name}' not found."))
|
|
||||||
throw err
|
|
||||||
|
|
||||||
exports.getSubShellCommand = require('../../utils/helpers').getSubShellCommand
|
|
@ -1,237 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
BOOT_PARTITION = 1
|
|
||||||
CONNECTIONS_FOLDER = '/system-connections'
|
|
||||||
|
|
||||||
getConfigurationSchema = (connnectionFileName = 'resin-wifi') ->
|
|
||||||
mapper: [
|
|
||||||
{
|
|
||||||
template:
|
|
||||||
persistentLogging: '{{persistentLogging}}'
|
|
||||||
domain: [
|
|
||||||
[ 'config_json', 'persistentLogging' ]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
{
|
|
||||||
template:
|
|
||||||
hostname: '{{hostname}}'
|
|
||||||
domain: [
|
|
||||||
[ 'config_json', 'hostname' ]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
{
|
|
||||||
template:
|
|
||||||
wifi:
|
|
||||||
ssid: '{{networkSsid}}'
|
|
||||||
'wifi-security':
|
|
||||||
psk: '{{networkKey}}'
|
|
||||||
domain: [
|
|
||||||
[ 'system_connections', connnectionFileName, 'wifi' ]
|
|
||||||
[ 'system_connections', connnectionFileName, 'wifi-security' ]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
files:
|
|
||||||
system_connections:
|
|
||||||
fileset: true
|
|
||||||
type: 'ini'
|
|
||||||
location:
|
|
||||||
path: CONNECTIONS_FOLDER.slice(1)
|
|
||||||
# Reconfix still uses the older resin-image-fs, so still needs an
|
|
||||||
# object-based partition definition.
|
|
||||||
partition: BOOT_PARTITION
|
|
||||||
config_json:
|
|
||||||
type: 'json'
|
|
||||||
location:
|
|
||||||
path: 'config.json'
|
|
||||||
partition: BOOT_PARTITION
|
|
||||||
|
|
||||||
inquirerOptions = (data) -> [
|
|
||||||
{
|
|
||||||
message: 'Network SSID'
|
|
||||||
type: 'input'
|
|
||||||
name: 'networkSsid'
|
|
||||||
default: data.networkSsid
|
|
||||||
}
|
|
||||||
{
|
|
||||||
message: 'Network Key'
|
|
||||||
type: 'input'
|
|
||||||
name: 'networkKey'
|
|
||||||
default: data.networkKey
|
|
||||||
}
|
|
||||||
{
|
|
||||||
message: 'Do you want to set advanced settings?'
|
|
||||||
type: 'confirm'
|
|
||||||
name: 'advancedSettings'
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
{
|
|
||||||
message: 'Device Hostname'
|
|
||||||
type: 'input'
|
|
||||||
name: 'hostname'
|
|
||||||
default: data.hostname,
|
|
||||||
when: (answers) ->
|
|
||||||
answers.advancedSettings
|
|
||||||
}
|
|
||||||
{
|
|
||||||
message: 'Do you want to enable persistent logging?'
|
|
||||||
type: 'confirm'
|
|
||||||
name: 'persistentLogging'
|
|
||||||
default: data.persistentLogging
|
|
||||||
when: (answers) ->
|
|
||||||
answers.advancedSettings
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
getConfiguration = (data) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
inquirer = require('inquirer')
|
|
||||||
|
|
||||||
# `persistentLogging` can be `undefined`, so we want
|
|
||||||
# to make sure that case defaults to `false`
|
|
||||||
data = _.assign data,
|
|
||||||
persistentLogging: data.persistentLogging or false
|
|
||||||
|
|
||||||
inquirer.prompt(inquirerOptions(data))
|
|
||||||
.then (answers) ->
|
|
||||||
return _.merge(data, answers)
|
|
||||||
|
|
||||||
# Taken from https://goo.gl/kr1kCt
|
|
||||||
CONNECTION_FILE = '''
|
|
||||||
[connection]
|
|
||||||
id=resin-wifi
|
|
||||||
type=wifi
|
|
||||||
|
|
||||||
[wifi]
|
|
||||||
hidden=true
|
|
||||||
mode=infrastructure
|
|
||||||
ssid=My_Wifi_Ssid
|
|
||||||
|
|
||||||
[wifi-security]
|
|
||||||
auth-alg=open
|
|
||||||
key-mgmt=wpa-psk
|
|
||||||
psk=super_secret_wifi_password
|
|
||||||
|
|
||||||
[ipv4]
|
|
||||||
method=auto
|
|
||||||
|
|
||||||
[ipv6]
|
|
||||||
addr-gen-mode=stable-privacy
|
|
||||||
method=auto
|
|
||||||
'''
|
|
||||||
|
|
||||||
###
|
|
||||||
* if the `resin-wifi` file exists (previously configured image or downloaded from the UI) it's used and reconfigured
|
|
||||||
* if the `resin-sample.ignore` exists it's copied to `resin-wifi`
|
|
||||||
* if the `resin-sample` exists it's reconfigured (legacy mode, will be removed eventually)
|
|
||||||
* otherwise, the new file is created
|
|
||||||
###
|
|
||||||
prepareConnectionFile = (target) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
imagefs = require('resin-image-fs')
|
|
||||||
|
|
||||||
imagefs.listDirectory
|
|
||||||
image: target
|
|
||||||
partition: BOOT_PARTITION
|
|
||||||
path: CONNECTIONS_FOLDER
|
|
||||||
.then (files) ->
|
|
||||||
# The required file already exists
|
|
||||||
if _.includes(files, 'resin-wifi')
|
|
||||||
return null
|
|
||||||
|
|
||||||
# Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files
|
|
||||||
if _.includes(files, 'resin-sample.ignore')
|
|
||||||
return imagefs.copy
|
|
||||||
image: target
|
|
||||||
partition: BOOT_PARTITION
|
|
||||||
path: "#{CONNECTIONS_FOLDER}/resin-sample.ignore"
|
|
||||||
,
|
|
||||||
image: target
|
|
||||||
partition: BOOT_PARTITION
|
|
||||||
path: "#{CONNECTIONS_FOLDER}/resin-wifi"
|
|
||||||
.thenReturn(null)
|
|
||||||
|
|
||||||
# Legacy mode, to be removed later
|
|
||||||
# We return the file name override from this branch
|
|
||||||
# When it is removed the following cleanup should be done:
|
|
||||||
# * delete all the null returns from this method
|
|
||||||
# * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi`
|
|
||||||
# * drop the final `then` from this method
|
|
||||||
# * adapt the code in the main listener to not receive the config from this method, and use that constant instead
|
|
||||||
if _.includes(files, 'resin-sample')
|
|
||||||
return 'resin-sample'
|
|
||||||
|
|
||||||
# In case there's no file at all (shouldn't happen normally, but the file might have been removed)
|
|
||||||
return imagefs.writeFile
|
|
||||||
image: target
|
|
||||||
partition: BOOT_PARTITION
|
|
||||||
path: "#{CONNECTIONS_FOLDER}/resin-wifi"
|
|
||||||
, CONNECTION_FILE
|
|
||||||
.thenReturn(null)
|
|
||||||
|
|
||||||
.then (connectionFileName) ->
|
|
||||||
return getConfigurationSchema(connectionFileName)
|
|
||||||
|
|
||||||
removeHostname = (schema) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
schema.mapper = _.reject schema.mapper, (mapper) ->
|
|
||||||
_.isEqual(Object.keys(mapper.template), ['hostname'])
|
|
||||||
|
|
||||||
module.exports =
|
|
||||||
signature: 'local configure <target>'
|
|
||||||
description: '(Re)configure a balenaOS drive or image'
|
|
||||||
help: '''
|
|
||||||
Use this command to configure or reconfigure a balenaOS drive or image.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena local configure /dev/sdc
|
|
||||||
$ balena local configure path/to/image.img
|
|
||||||
'''
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
path = require('path')
|
|
||||||
umount = require('umount')
|
|
||||||
umountAsync = Promise.promisify(umount.umount)
|
|
||||||
isMountedAsync = Promise.promisify(umount.isMounted)
|
|
||||||
reconfix = require('reconfix')
|
|
||||||
denymount = Promise.promisify(require('denymount'))
|
|
||||||
|
|
||||||
prepareConnectionFile(params.target)
|
|
||||||
.tap ->
|
|
||||||
isMountedAsync(params.target).then (isMounted) ->
|
|
||||||
return if not isMounted
|
|
||||||
umountAsync(params.target)
|
|
||||||
.then (configurationSchema) ->
|
|
||||||
dmOpts = {}
|
|
||||||
if process.pkg
|
|
||||||
# when running in a standalone pkg install, the 'denymount'
|
|
||||||
# executable is placed on the same folder as process.execPath
|
|
||||||
dmOpts.executablePath = path.join(path.dirname(process.execPath), 'denymount')
|
|
||||||
dmHandler = (cb) ->
|
|
||||||
reconfix.readConfiguration(configurationSchema, params.target)
|
|
||||||
.then(getConfiguration)
|
|
||||||
.then (answers) ->
|
|
||||||
if not answers.hostname
|
|
||||||
removeHostname(configurationSchema)
|
|
||||||
reconfix.writeConfiguration(configurationSchema, answers, params.target)
|
|
||||||
.asCallback(cb)
|
|
||||||
denymount params.target, dmHandler, dmOpts
|
|
||||||
.then ->
|
|
||||||
console.log('Done!')
|
|
||||||
.asCallback(done)
|
|
@ -1,120 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the 'License');
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an 'AS IS' BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
import * as SDK from 'etcher-sdk';
|
|
||||||
|
|
||||||
async function getDrive(options: {
|
|
||||||
drive?: string;
|
|
||||||
}): Promise<SDK.sourceDestination.BlockDevice> {
|
|
||||||
const sdk = await import('etcher-sdk');
|
|
||||||
|
|
||||||
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter(() => false);
|
|
||||||
const scanner = new sdk.scanner.Scanner([adapter]);
|
|
||||||
await scanner.start();
|
|
||||||
let drive: SDK.sourceDestination.BlockDevice;
|
|
||||||
if (options.drive !== undefined) {
|
|
||||||
const d = scanner.getBy('device', options.drive);
|
|
||||||
if (d === undefined || !(d instanceof sdk.sourceDestination.BlockDevice)) {
|
|
||||||
throw new Error(`Drive not found: ${options.drive}`);
|
|
||||||
}
|
|
||||||
drive = d;
|
|
||||||
} else {
|
|
||||||
const { DriveList } = await import('../../utils/visuals/drive-list');
|
|
||||||
const driveList = new DriveList(scanner);
|
|
||||||
drive = await driveList.run();
|
|
||||||
}
|
|
||||||
scanner.stop();
|
|
||||||
return drive;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const flash: CommandDefinition<
|
|
||||||
{ image: string },
|
|
||||||
{ drive: string; yes: boolean }
|
|
||||||
> = {
|
|
||||||
signature: 'local flash <image>',
|
|
||||||
description: 'Flash an image to a drive',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to flash a balenaOS image to a drive.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena local flash path/to/balenaos.img[.zip|.gz|.bz2|.xz]
|
|
||||||
$ balena local flash path/to/balenaos.img --drive /dev/disk2
|
|
||||||
$ balena local flash path/to/balenaos.img --drive /dev/disk2 --yes
|
|
||||||
`,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'yes',
|
|
||||||
boolean: true,
|
|
||||||
description: 'confirm non-interactively',
|
|
||||||
alias: 'y',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'drive',
|
|
||||||
parameter: 'drive',
|
|
||||||
description: 'drive',
|
|
||||||
alias: 'd',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
async action(params, options) {
|
|
||||||
const visuals = await import('resin-cli-visuals');
|
|
||||||
const form = await import('resin-cli-form');
|
|
||||||
const { sourceDestination, multiWrite } = await import('etcher-sdk');
|
|
||||||
|
|
||||||
const drive = await getDrive(options);
|
|
||||||
|
|
||||||
const yes =
|
|
||||||
options.yes ||
|
|
||||||
(await form.ask({
|
|
||||||
message: 'This will erase the selected drive. Are you sure?',
|
|
||||||
type: 'confirm',
|
|
||||||
name: 'yes',
|
|
||||||
default: false,
|
|
||||||
}));
|
|
||||||
if (yes !== true) {
|
|
||||||
console.log(chalk.red.bold('Aborted image flash'));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = new sourceDestination.File(
|
|
||||||
params.image,
|
|
||||||
sourceDestination.File.OpenFlags.Read,
|
|
||||||
);
|
|
||||||
const source = await file.getInnerSource();
|
|
||||||
|
|
||||||
const progressBars: { [key: string]: any } = {
|
|
||||||
flashing: new visuals.Progress('Flashing'),
|
|
||||||
verifying: new visuals.Progress('Validating'),
|
|
||||||
};
|
|
||||||
|
|
||||||
await multiWrite.pipeSourceToDestinations(
|
|
||||||
source,
|
|
||||||
[drive],
|
|
||||||
(_, error) => {
|
|
||||||
// onFail
|
|
||||||
console.log(chalk.red.bold(error.message));
|
|
||||||
},
|
|
||||||
(progress: SDK.multiWrite.MultiDestinationProgress) => {
|
|
||||||
// onProgress
|
|
||||||
progressBars[progress.type].update(progress);
|
|
||||||
},
|
|
||||||
true, // verify
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,66 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
# A function to reliably execute a command
|
|
||||||
# in all supported operating systems, including
|
|
||||||
# different Windows environments like `cmd.exe`
|
|
||||||
# and `Cygwin` should be encapsulated in a
|
|
||||||
# re-usable package.
|
|
||||||
#
|
|
||||||
module.exports =
|
|
||||||
signature: 'local logs [deviceIp]'
|
|
||||||
description: 'Get or attach to logs of a running container on a balenaOS device'
|
|
||||||
help: '''
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena local logs
|
|
||||||
$ balena local logs -f
|
|
||||||
$ balena local logs 192.168.1.10
|
|
||||||
$ balena local logs 192.168.1.10 -f
|
|
||||||
$ balena local logs 192.168.1.10 -f --app-name myapp
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
signature: 'follow'
|
|
||||||
boolean: true
|
|
||||||
description: 'follow log'
|
|
||||||
alias: 'f'
|
|
||||||
,
|
|
||||||
signature: 'app-name'
|
|
||||||
parameter: 'name'
|
|
||||||
description: 'name of container to get logs from'
|
|
||||||
alias: 'a'
|
|
||||||
]
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
{ forms } = require('balena-sync')
|
|
||||||
{ selectContainerFromDevice, pipeContainerStream } = require('./common')
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
if not params.deviceIp?
|
|
||||||
return forms.selectLocalBalenaOsDevice()
|
|
||||||
return params.deviceIp
|
|
||||||
.then (@deviceIp) =>
|
|
||||||
if not options['app-name']?
|
|
||||||
return selectContainerFromDevice(@deviceIp)
|
|
||||||
return options['app-name']
|
|
||||||
.then (appName) =>
|
|
||||||
pipeContainerStream
|
|
||||||
deviceIp: @deviceIp
|
|
||||||
name: appName
|
|
||||||
outStream: process.stdout
|
|
||||||
follow: options['follow']
|
|
@ -1,95 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
# Loads '.balena-sync.yml' configuration from 'source' directory.
|
|
||||||
# Returns the configuration object on success
|
|
||||||
#
|
|
||||||
|
|
||||||
_ = require('lodash')
|
|
||||||
|
|
||||||
balenaPush = require('balena-sync').capitano('balena-toolbox')
|
|
||||||
originalAction = balenaPush.action
|
|
||||||
|
|
||||||
# TODO: This is a temporary workaround to reuse the existing `rdt push`
|
|
||||||
# capitano frontend in `balena local push`.
|
|
||||||
|
|
||||||
# coffeelint: disable-next-line ("Line ends with trailing whitespace")
|
|
||||||
deprecationMsg = '''
|
|
||||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
||||||
Deprecation notice: `balena local push` is deprecated and will be removed in a
|
|
||||||
future release of the CLI. Please use `balena push <ipAddress>` instead.
|
|
||||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
balenaPushHelp = """#{deprecationMsg}
|
|
||||||
Use this command to push your local changes to a container on a LAN-accessible
|
|
||||||
balenaOS device on the fly.
|
|
||||||
|
|
||||||
This command requires an openssh-compatible 'ssh' client and 'rsync' to be
|
|
||||||
available in the executable PATH of the shell environment. For more information
|
|
||||||
(including Windows support) please check the README at:
|
|
||||||
https://github.com/balena-io/balena-cli
|
|
||||||
|
|
||||||
If `Dockerfile` or any file in the 'build-triggers' list is changed,
|
|
||||||
a new container will be built and run on your device.
|
|
||||||
If not, changes will simply be synced with `rsync` into the application container.
|
|
||||||
|
|
||||||
After every 'balena local push' the updated settings will be saved in
|
|
||||||
'<source>/.balena-sync.yml' and will be used in later invocations. You can
|
|
||||||
also change any option by editing '.balena-sync.yml' directly.
|
|
||||||
|
|
||||||
Here is an example '.balena-sync.yml' :
|
|
||||||
|
|
||||||
$ cat $PWD/.balena-sync.yml
|
|
||||||
local_balenaos:
|
|
||||||
app-name: local-app
|
|
||||||
build-triggers:
|
|
||||||
- Dockerfile: file-hash-abcdefabcdefabcdefabcdefabcdefabcdef
|
|
||||||
- package.json: file-hash-abcdefabcdefabcdefabcdefabcdefabcdef
|
|
||||||
environment:
|
|
||||||
- MY_VARIABLE=123
|
|
||||||
|
|
||||||
|
|
||||||
Command line options have precedence over the ones saved in '.balena-sync.yml'.
|
|
||||||
|
|
||||||
If '.gitignore' is found in the source directory then all explicitly listed files will be
|
|
||||||
excluded when using rsync to update the container. You can choose to change this default behavior with the
|
|
||||||
'--skip-gitignore' option.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena local push
|
|
||||||
$ balena local push --app-name test-server --build-triggers package.json,requirements.txt
|
|
||||||
$ balena local push --force-build
|
|
||||||
$ balena local push --force-build --skip-logs
|
|
||||||
$ balena local push --ignore lib/
|
|
||||||
$ balena local push --verbose false
|
|
||||||
$ balena local push 192.168.2.10 --source . --destination /usr/src/app
|
|
||||||
$ balena local push 192.168.2.10 -s /home/user/balenaProject -d /usr/src/app --before 'echo Hello' --after 'echo Done'
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = _.assign balenaPush,
|
|
||||||
signature: 'local push [deviceIp]'
|
|
||||||
description: '[deprecated: use "balena push ipAddress"] ' + balenaPush.description
|
|
||||||
help: balenaPushHelp
|
|
||||||
primary: false
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
console.log deprecationMsg
|
|
||||||
originalAction(params, options, done)
|
|
@ -1,114 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
{ hostOSAccess } = require('../command-options')
|
|
||||||
_ = require('lodash')
|
|
||||||
|
|
||||||
localHostOSAccessOption = _.cloneDeep(hostOSAccess)
|
|
||||||
localHostOSAccessOption.description = 'get a shell into the host OS'
|
|
||||||
|
|
||||||
module.exports =
|
|
||||||
signature: 'local ssh [deviceIp]'
|
|
||||||
description: 'Get a shell into a balenaOS device'
|
|
||||||
help: '''
|
|
||||||
Warning: 'balena local ssh' requires an openssh-compatible client to be correctly
|
|
||||||
installed in your shell environment. For more information (including Windows
|
|
||||||
support) please check the README here: https://github.com/balena-io/balena-cli
|
|
||||||
|
|
||||||
Use this command to get a shell into the running application container of
|
|
||||||
your device.
|
|
||||||
|
|
||||||
The '--host' option will get you a shell into the Host OS of the balenaOS device.
|
|
||||||
No option will return a list of containers to enter or you can explicitly select
|
|
||||||
one by passing its name to the --container option
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena local ssh
|
|
||||||
$ balena local ssh --host
|
|
||||||
$ balena local ssh --container chaotic_water
|
|
||||||
$ balena local ssh --container chaotic_water --port 22222
|
|
||||||
$ balena local ssh --verbose
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
signature: 'verbose'
|
|
||||||
boolean: true
|
|
||||||
description: 'increase verbosity'
|
|
||||||
alias: 'v'
|
|
||||||
,
|
|
||||||
localHostOSAccessOption,
|
|
||||||
signature: 'container'
|
|
||||||
parameter: 'container'
|
|
||||||
default: null
|
|
||||||
description: 'name of container to access'
|
|
||||||
alias: 'c'
|
|
||||||
,
|
|
||||||
signature: 'port'
|
|
||||||
parameter: 'port'
|
|
||||||
description: 'ssh port number (default: 22222)'
|
|
||||||
alias: 'p'
|
|
||||||
]
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
child_process = require('child_process')
|
|
||||||
Promise = require 'bluebird'
|
|
||||||
_ = require('lodash')
|
|
||||||
{ forms } = require('balena-sync')
|
|
||||||
|
|
||||||
{ selectContainerFromDevice, getSubShellCommand } = require('./common')
|
|
||||||
{ exitWithExpectedError } = require('../../utils/patterns')
|
|
||||||
|
|
||||||
if (options.host is true and options.container?)
|
|
||||||
exitWithExpectedError('Please pass either --host or --container option')
|
|
||||||
|
|
||||||
if not options.port?
|
|
||||||
options.port = 22222
|
|
||||||
|
|
||||||
verbose = if options.verbose then '-vvv' else ''
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
if not params.deviceIp?
|
|
||||||
return forms.selectLocalBalenaOsDevice()
|
|
||||||
return params.deviceIp
|
|
||||||
.then (deviceIp) ->
|
|
||||||
_.assign(options, { deviceIp })
|
|
||||||
|
|
||||||
return if options.host
|
|
||||||
|
|
||||||
if not options.container?
|
|
||||||
return selectContainerFromDevice(deviceIp)
|
|
||||||
|
|
||||||
return options.container
|
|
||||||
.then (container) ->
|
|
||||||
|
|
||||||
command = "ssh \
|
|
||||||
#{verbose} \
|
|
||||||
-t \
|
|
||||||
-p #{options.port} \
|
|
||||||
-o LogLevel=ERROR \
|
|
||||||
-o StrictHostKeyChecking=no \
|
|
||||||
-o UserKnownHostsFile=/dev/null \
|
|
||||||
root@#{options.deviceIp}"
|
|
||||||
|
|
||||||
if not options.host
|
|
||||||
shellCmd = '''/bin/sh -c $"'if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi'"'''
|
|
||||||
dockerCmd = "'$(if [ -f /usr/bin/balena ]; then echo \"balena\"; else echo \"docker\"; fi)'"
|
|
||||||
command += " #{dockerCmd} exec -ti #{container} #{shellCmd}"
|
|
||||||
|
|
||||||
subShellCommand = getSubShellCommand(command)
|
|
||||||
child_process.spawn subShellCommand.program, subShellCommand.args,
|
|
||||||
stdio: 'inherit'
|
|
||||||
.nodeify(done)
|
|
@ -1,79 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
# A function to reliably execute a command
|
|
||||||
# in all supported operating systems, including
|
|
||||||
# different Windows environments like `cmd.exe`
|
|
||||||
# and `Cygwin` should be encapsulated in a
|
|
||||||
# re-usable package.
|
|
||||||
#
|
|
||||||
module.exports =
|
|
||||||
signature: 'local stop [deviceIp]'
|
|
||||||
description: 'Stop a running container on a balenaOS device'
|
|
||||||
help: '''
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena local stop
|
|
||||||
$ balena local stop --app-name myapp
|
|
||||||
$ balena local stop --all
|
|
||||||
$ balena local stop 192.168.1.10
|
|
||||||
$ balena local stop 192.168.1.10 --app-name myapp
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
signature: 'all'
|
|
||||||
boolean: true
|
|
||||||
description: 'stop all containers'
|
|
||||||
,
|
|
||||||
signature: 'app-name'
|
|
||||||
parameter: 'name'
|
|
||||||
description: 'name of container to stop'
|
|
||||||
alias: 'a'
|
|
||||||
]
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
chalk = require('chalk')
|
|
||||||
{ forms, config, BalenaLocalDockerUtils } = require('balena-sync')
|
|
||||||
{ selectContainerFromDevice, filterOutSupervisorContainer } = require('./common')
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
if not params.deviceIp?
|
|
||||||
return forms.selectLocalBalenaOsDevice()
|
|
||||||
return params.deviceIp
|
|
||||||
.then (@deviceIp) =>
|
|
||||||
@docker = new BalenaLocalDockerUtils(@deviceIp)
|
|
||||||
|
|
||||||
if options.all
|
|
||||||
# Only list running containers
|
|
||||||
return @docker.docker.listContainersAsync(all: false)
|
|
||||||
.filter(filterOutSupervisorContainer)
|
|
||||||
.then (containers) =>
|
|
||||||
Promise.map containers, ({ Names, Id }) =>
|
|
||||||
console.log(chalk.yellow.bold("* Stopping container #{Names[0]}"))
|
|
||||||
@docker.stopContainer(Id)
|
|
||||||
|
|
||||||
ymlConfig = config.load()
|
|
||||||
@appName = options['app-name'] ? ymlConfig['local_balenaos']?['app-name']
|
|
||||||
@docker.checkForRunningContainer(@appName)
|
|
||||||
.then (isRunning) =>
|
|
||||||
if not isRunning
|
|
||||||
return selectContainerFromDevice(@deviceIp, true)
|
|
||||||
|
|
||||||
console.log(chalk.yellow.bold("* Stopping container #{@appName}"))
|
|
||||||
return @appName
|
|
||||||
.then (runningContainerName) =>
|
|
||||||
@docker.stopContainer(runningContainerName)
|
|
@ -1,184 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2019 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
import { normalizeUuidProp } from '../utils/normalization';
|
|
||||||
import { validateDotLocalUrl } from '../utils/validation';
|
|
||||||
|
|
||||||
type CloudLog =
|
|
||||||
| {
|
|
||||||
isSystem: false;
|
|
||||||
serviceId: number;
|
|
||||||
timestamp: number;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
isSystem: true;
|
|
||||||
timestamp: number;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const logs: CommandDefinition<
|
|
||||||
{
|
|
||||||
uuidOrDevice: string;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tail?: boolean;
|
|
||||||
service?: [string] | string;
|
|
||||||
system?: boolean;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'logs <uuidOrDevice>',
|
|
||||||
description: 'show device logs',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to show logs for a specific device.
|
|
||||||
|
|
||||||
By default, the command prints all log messages and exits.
|
|
||||||
|
|
||||||
To continuously stream output, and see new logs in real time, use the \`--tail\` option.
|
|
||||||
|
|
||||||
If an IP or .local address is passed to this command, logs are displayed from
|
|
||||||
a local mode device with that address. Note that --tail is implied
|
|
||||||
when this command is provided a local mode device.
|
|
||||||
|
|
||||||
Logs from a single service can be displayed with the --service flag. Just system logs
|
|
||||||
can be shown with the --system flag. Note that these flags can be used together.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena logs 23c73a1
|
|
||||||
$ balena logs 23c73a1 --tail
|
|
||||||
|
|
||||||
$ balena logs 192.168.0.31
|
|
||||||
$ balena logs 192.168.0.31 --service my-service
|
|
||||||
$ balena logs 192.168.0.31 --service my-service-1 --service my-service-2
|
|
||||||
|
|
||||||
$ balena logs 23c73a1.local --system
|
|
||||||
$ balena logs 23c73a1.local --system --service my-service`,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'tail',
|
|
||||||
description: 'continuously stream output',
|
|
||||||
boolean: true,
|
|
||||||
alias: 't',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'service',
|
|
||||||
description: stripIndent`
|
|
||||||
Reject logs not originating from this service.
|
|
||||||
This can be used in combination with --system or other --service flags.`,
|
|
||||||
parameter: 'service',
|
|
||||||
alias: 's',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'system',
|
|
||||||
alias: 'S',
|
|
||||||
boolean: true,
|
|
||||||
description:
|
|
||||||
'Only show system logs. This can be used in combination with --service.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
primary: true,
|
|
||||||
async action(params, options, done) {
|
|
||||||
normalizeUuidProp(params);
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const isArray = await import('lodash/isArray');
|
|
||||||
const { serviceIdToName } = await import('../utils/cloud');
|
|
||||||
const { displayDeviceLogs, displayLogObject } = await import(
|
|
||||||
'../utils/device/logs'
|
|
||||||
);
|
|
||||||
const { validateIPAddress } = await import('../utils/validation');
|
|
||||||
const { exitIfNotLoggedIn, exitWithExpectedError } = await import(
|
|
||||||
'../utils/patterns'
|
|
||||||
);
|
|
||||||
const Logger = await import('../utils/logger');
|
|
||||||
|
|
||||||
const logger = new Logger();
|
|
||||||
|
|
||||||
const servicesToDisplay =
|
|
||||||
options.service != null
|
|
||||||
? isArray(options.service)
|
|
||||||
? options.service
|
|
||||||
: [options.service]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const displayCloudLog = async (line: CloudLog) => {
|
|
||||||
if (!line.isSystem) {
|
|
||||||
let serviceName = await serviceIdToName(balena, line.serviceId);
|
|
||||||
if (serviceName == null) {
|
|
||||||
serviceName = 'Unknown service';
|
|
||||||
}
|
|
||||||
displayLogObject(
|
|
||||||
{ serviceName, ...line },
|
|
||||||
logger,
|
|
||||||
options.system || false,
|
|
||||||
servicesToDisplay,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
displayLogObject(
|
|
||||||
line,
|
|
||||||
logger,
|
|
||||||
options.system || false,
|
|
||||||
servicesToDisplay,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
validateIPAddress(params.uuidOrDevice) ||
|
|
||||||
validateDotLocalUrl(params.uuidOrDevice)
|
|
||||||
) {
|
|
||||||
const { DeviceAPI } = await import('../utils/device/api');
|
|
||||||
const deviceApi = new DeviceAPI(logger, params.uuidOrDevice);
|
|
||||||
logger.logDebug('Checking we can access device');
|
|
||||||
try {
|
|
||||||
await deviceApi.ping();
|
|
||||||
} catch (e) {
|
|
||||||
exitWithExpectedError(
|
|
||||||
new Error(
|
|
||||||
`Cannot access local mode device at address ${params.uuidOrDevice}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const logStream = await deviceApi.getLogStream();
|
|
||||||
displayDeviceLogs(
|
|
||||||
logStream,
|
|
||||||
logger,
|
|
||||||
options.system || false,
|
|
||||||
servicesToDisplay,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
exitIfNotLoggedIn();
|
|
||||||
if (options.tail) {
|
|
||||||
return balena.logs
|
|
||||||
.subscribe(params.uuidOrDevice, { count: 100 })
|
|
||||||
.then(function(logStream) {
|
|
||||||
logStream.on('line', displayCloudLog);
|
|
||||||
logStream.on('error', done);
|
|
||||||
})
|
|
||||||
.catch(done);
|
|
||||||
} else {
|
|
||||||
return balena.logs
|
|
||||||
.history(params.uuidOrDevice)
|
|
||||||
.each(displayCloudLog)
|
|
||||||
.catch(done);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,55 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
{ normalizeUuidProp } = require('../utils/normalization')
|
|
||||||
|
|
||||||
exports.set =
|
|
||||||
signature: 'note <|note>'
|
|
||||||
description: 'set a device note'
|
|
||||||
help: '''
|
|
||||||
Use this command to set or update a device note.
|
|
||||||
|
|
||||||
If note command isn't passed, the tool attempts to read from `stdin`.
|
|
||||||
|
|
||||||
To view the notes, use $ balena device <uuid>.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena note "My useful note" --device 7cf02a6
|
|
||||||
$ cat note.txt | balena note --device 7cf02a6
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
signature: 'device'
|
|
||||||
parameter: 'device'
|
|
||||||
description: 'device uuid'
|
|
||||||
alias: [ 'd', 'dev' ]
|
|
||||||
required: 'You have to specify a device'
|
|
||||||
]
|
|
||||||
permission: 'user'
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(options, 'device')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
_ = require('lodash')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
|
|
||||||
{ exitWithExpectedError } = require('../utils/patterns')
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
if _.isEmpty(params.note)
|
|
||||||
exitWithExpectedError('Missing note content')
|
|
||||||
|
|
||||||
balena.models.device.note(options.device, params.note)
|
|
||||||
.nodeify(done)
|
|
@ -1,427 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
commandOptions = require('./command-options')
|
|
||||||
_ = require('lodash')
|
|
||||||
{ normalizeUuidProp } = require('../utils/normalization')
|
|
||||||
|
|
||||||
formatVersion = (v, isRecommended) ->
|
|
||||||
result = "v#{v}"
|
|
||||||
if isRecommended
|
|
||||||
result += ' (recommended)'
|
|
||||||
return result
|
|
||||||
|
|
||||||
resolveVersion = (deviceType, version) ->
|
|
||||||
if version isnt 'menu'
|
|
||||||
if version[0] == 'v'
|
|
||||||
version = version.slice(1)
|
|
||||||
return Promise.resolve(version)
|
|
||||||
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
|
|
||||||
balena.models.os.getSupportedVersions(deviceType)
|
|
||||||
.then ({ versions, recommended }) ->
|
|
||||||
choices = versions.map (v) ->
|
|
||||||
value: v
|
|
||||||
name: formatVersion(v, v is recommended)
|
|
||||||
|
|
||||||
return form.ask
|
|
||||||
message: 'Select the OS version:'
|
|
||||||
type: 'list'
|
|
||||||
choices: choices
|
|
||||||
default: recommended
|
|
||||||
|
|
||||||
exports.versions =
|
|
||||||
signature: 'os versions <type>'
|
|
||||||
description: 'show the available balenaOS versions for the given device type'
|
|
||||||
help: '''
|
|
||||||
Use this command to show the available balenaOS versions for a certain device type.
|
|
||||||
Check available types with `balena devices supported`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
$ balena os versions raspberrypi3
|
|
||||||
'''
|
|
||||||
action: (params, options, done) ->
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
|
|
||||||
balena.models.os.getSupportedVersions(params.type)
|
|
||||||
.then ({ versions, recommended }) ->
|
|
||||||
versions.forEach (v) ->
|
|
||||||
console.log(formatVersion(v, v is recommended))
|
|
||||||
|
|
||||||
exports.download =
|
|
||||||
signature: 'os download <type>'
|
|
||||||
description: 'download an unconfigured os image'
|
|
||||||
help: '''
|
|
||||||
Use this command to download an unconfigured os image for a certain device type.
|
|
||||||
Check available types with `balena devices supported`
|
|
||||||
|
|
||||||
If version is not specified the newest stable (non-pre-release) version of OS
|
|
||||||
is downloaded if available, or the newest version otherwise (if all existing
|
|
||||||
versions for the given device type are pre-release).
|
|
||||||
|
|
||||||
You can pass `--version menu` to pick the OS version from the interactive menu
|
|
||||||
of all available versions.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img
|
|
||||||
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 1.24.1
|
|
||||||
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^1.20.0
|
|
||||||
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version latest
|
|
||||||
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version default
|
|
||||||
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version menu
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'output'
|
|
||||||
description: 'output path'
|
|
||||||
parameter: 'output'
|
|
||||||
alias: 'o'
|
|
||||||
required: 'You have to specify the output location'
|
|
||||||
}
|
|
||||||
commandOptions.osVersionOrSemver
|
|
||||||
]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
unzip = require('unzip2')
|
|
||||||
fs = require('fs')
|
|
||||||
rindle = require('rindle')
|
|
||||||
manager = require('balena-image-manager')
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
|
|
||||||
console.info("Getting device operating system for #{params.type}")
|
|
||||||
|
|
||||||
displayVersion = ''
|
|
||||||
Promise.try ->
|
|
||||||
if not options.version
|
|
||||||
console.warn('OS version is not specified, using the default version:
|
|
||||||
the newest stable (non-pre-release) version if available,
|
|
||||||
or the newest version otherwise (if all existing
|
|
||||||
versions for the given device type are pre-release).')
|
|
||||||
return 'default'
|
|
||||||
return resolveVersion(params.type, options.version)
|
|
||||||
.then (version) ->
|
|
||||||
if version isnt 'default'
|
|
||||||
displayVersion = " #{version}"
|
|
||||||
return manager.get(params.type, version)
|
|
||||||
.then (stream) ->
|
|
||||||
bar = new visuals.Progress("Downloading Device OS#{displayVersion}")
|
|
||||||
spinner = new visuals.Spinner("Downloading Device OS#{displayVersion} (size unknown)")
|
|
||||||
|
|
||||||
stream.on 'progress', (state) ->
|
|
||||||
if state?
|
|
||||||
bar.update(state)
|
|
||||||
else
|
|
||||||
spinner.start()
|
|
||||||
|
|
||||||
stream.on 'end', ->
|
|
||||||
spinner.stop()
|
|
||||||
|
|
||||||
# We completely rely on the `mime` custom property
|
|
||||||
# to make this decision.
|
|
||||||
# The actual stream should be checked instead.
|
|
||||||
if stream.mime is 'application/zip'
|
|
||||||
output = unzip.Extract(path: options.output)
|
|
||||||
else
|
|
||||||
output = fs.createWriteStream(options.output)
|
|
||||||
|
|
||||||
return rindle.wait(stream.pipe(output)).return(options.output)
|
|
||||||
.tap (output) ->
|
|
||||||
console.info('The image was downloaded successfully')
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
buildConfigForDeviceType = (deviceType, advanced = false) ->
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
helpers = require('../utils/helpers')
|
|
||||||
|
|
||||||
questions = deviceType.options
|
|
||||||
if not advanced
|
|
||||||
advancedGroup = _.find questions,
|
|
||||||
name: 'advanced'
|
|
||||||
isGroup: true
|
|
||||||
|
|
||||||
if advancedGroup?
|
|
||||||
override = helpers.getGroupDefaults(advancedGroup)
|
|
||||||
|
|
||||||
return form.run(questions, { override })
|
|
||||||
|
|
||||||
buildConfig = (image, deviceTypeSlug, advanced = false) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
helpers = require('../utils/helpers')
|
|
||||||
|
|
||||||
Promise.resolve(helpers.getManifest(image, deviceTypeSlug))
|
|
||||||
.then (deviceTypeManifest) ->
|
|
||||||
buildConfigForDeviceType(deviceTypeManifest, advanced)
|
|
||||||
|
|
||||||
exports.buildConfig =
|
|
||||||
signature: 'os build-config <image> <device-type>'
|
|
||||||
description: 'build the OS config and save it to the JSON file'
|
|
||||||
help: '''
|
|
||||||
Use this command to prebuild the OS config once and skip the interactive part of `balena os configure`.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
$ balena os build-config ../path/rpi3.img raspberrypi3 --output rpi3-config.json
|
|
||||||
$ balena os configure ../path/rpi3.img --device 7cf02a6 --config rpi3-config.json
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
options: [
|
|
||||||
commandOptions.advancedConfig
|
|
||||||
{
|
|
||||||
signature: 'output'
|
|
||||||
description: 'the path to the output JSON file'
|
|
||||||
alias: 'o'
|
|
||||||
required: 'the output path is required'
|
|
||||||
parameter: 'output'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
fs = require('fs')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
writeFileAsync = Promise.promisify(fs.writeFile)
|
|
||||||
|
|
||||||
buildConfig(params.image, params['device-type'], options.advanced)
|
|
||||||
.then (answers) ->
|
|
||||||
writeFileAsync(options.output, JSON.stringify(answers, null, 4))
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
exports.configure =
|
|
||||||
signature: 'os configure <image>'
|
|
||||||
description: 'configure an os image'
|
|
||||||
help: '''
|
|
||||||
Use this command to configure a previously downloaded operating system image for
|
|
||||||
the specific device or for an application generally.
|
|
||||||
|
|
||||||
This command will try to automatically determine the operating system version in order
|
|
||||||
to correctly configure the image. It may fail to do so however, in which case you'll
|
|
||||||
have to call this command again with the exact version number of the targeted image.
|
|
||||||
|
|
||||||
Note that device api keys are only supported on balenaOS 2.0.3+.
|
|
||||||
|
|
||||||
This command still supports the *deprecated* format where the UUID and optionally device key
|
|
||||||
are passed directly on the command line, but the recommended way is to pass either an --app or
|
|
||||||
--device argument. The deprecated format will be removed in a future release.
|
|
||||||
|
|
||||||
In case that you want to configure an image for an application with mixed device types,
|
|
||||||
you can pass the --device-type argument along with --app to specify the target device type.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena os configure ../path/rpi3.img --device 7cf02a6
|
|
||||||
$ balena os configure ../path/rpi3.img --device 7cf02a6 --device-api-key <existingDeviceKey>
|
|
||||||
$ balena os configure ../path/rpi3.img --app MyApp
|
|
||||||
$ balena os configure ../path/rpi3.img --app MyApp --version 2.12.7
|
|
||||||
$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
options: [
|
|
||||||
commandOptions.advancedConfig
|
|
||||||
commandOptions.optionalApplication
|
|
||||||
commandOptions.optionalDevice
|
|
||||||
commandOptions.optionalDeviceApiKey
|
|
||||||
commandOptions.optionalDeviceType
|
|
||||||
commandOptions.optionalOsVersion
|
|
||||||
{
|
|
||||||
signature: 'config'
|
|
||||||
description: 'path to the config JSON file, see `balena os build-config`'
|
|
||||||
parameter: 'config'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
normalizeUuidProp(options, 'device')
|
|
||||||
fs = require('fs')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
readFileAsync = Promise.promisify(fs.readFile)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
init = require('balena-device-init')
|
|
||||||
helpers = require('../utils/helpers')
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
{ generateDeviceConfig, generateApplicationConfig } = require('../utils/config')
|
|
||||||
|
|
||||||
if _.filter([
|
|
||||||
options.device
|
|
||||||
options.application
|
|
||||||
]).length != 1
|
|
||||||
patterns.exitWithExpectedError '''
|
|
||||||
To configure an image, you must provide exactly one of:
|
|
||||||
|
|
||||||
* A device, with --device <uuid>
|
|
||||||
* An application, with --app <appname>
|
|
||||||
|
|
||||||
See the help page for examples:
|
|
||||||
|
|
||||||
$ balena help os configure
|
|
||||||
'''
|
|
||||||
|
|
||||||
if !options.application and options.deviceType
|
|
||||||
patterns.exitWithExpectedError '''
|
|
||||||
Specifying a different device type is only supported when
|
|
||||||
configuring an image using an application as a parameter:
|
|
||||||
|
|
||||||
* An application, with --app <appname>
|
|
||||||
* A specific device type, with --device-type <deviceTypeSlug>
|
|
||||||
|
|
||||||
See the help page for examples:
|
|
||||||
|
|
||||||
$ balena help os configure
|
|
||||||
'''
|
|
||||||
|
|
||||||
uuid = options.device
|
|
||||||
deviceApiKey = options.deviceApiKey
|
|
||||||
|
|
||||||
console.info('Configuring operating system image')
|
|
||||||
|
|
||||||
configurationResourceType = if uuid then 'device' else 'application'
|
|
||||||
|
|
||||||
balena.models[configurationResourceType].get(uuid || options.application)
|
|
||||||
.then (appOrDevice) ->
|
|
||||||
deviceType = options.deviceType || appOrDevice.device_type
|
|
||||||
manifestPromise = helpers.getManifest(params.image, deviceType)
|
|
||||||
|
|
||||||
if options.application && options.deviceType
|
|
||||||
app = appOrDevice
|
|
||||||
appManifestPromise = balena.models.device.getManifestBySlug(app.device_type)
|
|
||||||
paramManifestPromise = balena.models.device.getManifestBySlug(options.deviceType)
|
|
||||||
manifestPromise = Promise.resolve(manifestPromise).tap ->
|
|
||||||
Promise.join appManifestPromise, paramManifestPromise, (appDeviceType, paramDeviceType) ->
|
|
||||||
if not helpers.areDeviceTypesCompatible(appDeviceType, paramDeviceType)
|
|
||||||
throw new balena.errors.BalenaInvalidDeviceType(
|
|
||||||
"Device type #{options.deviceType} is incompatible with application #{options.application}"
|
|
||||||
)
|
|
||||||
|
|
||||||
answersPromise = Promise.try ->
|
|
||||||
if options.config
|
|
||||||
return readFileAsync(options.config, 'utf8')
|
|
||||||
.then(JSON.parse)
|
|
||||||
return manifestPromise.then (deviceTypeManifest) ->
|
|
||||||
buildConfigForDeviceType(deviceTypeManifest, options.advanced)
|
|
||||||
|
|
||||||
Promise.join answersPromise, manifestPromise, (answers, manifest) ->
|
|
||||||
answers.version = options.version
|
|
||||||
|
|
||||||
if configurationResourceType == 'application'
|
|
||||||
answers.deviceType = deviceType
|
|
||||||
|
|
||||||
if not answers.version?
|
|
||||||
answers.version = Promise.resolve(helpers.getOsVersion(params.image, manifest)).tap (version) ->
|
|
||||||
if not version?
|
|
||||||
throw new Error(
|
|
||||||
'Could not read OS version from the image. ' +
|
|
||||||
'Please specify the version manually with the ' +
|
|
||||||
'--version argument to this command.'
|
|
||||||
)
|
|
||||||
|
|
||||||
Promise.props(answers).then (answers) ->
|
|
||||||
(if configurationResourceType == 'device'
|
|
||||||
generateDeviceConfig(appOrDevice, deviceApiKey, answers)
|
|
||||||
else
|
|
||||||
generateApplicationConfig(appOrDevice, answers)
|
|
||||||
)
|
|
||||||
.then (config) ->
|
|
||||||
init.configure(params.image, manifest, config, answers)
|
|
||||||
.then(helpers.osProgressHandler)
|
|
||||||
.nodeify(done)
|
|
||||||
|
|
||||||
INIT_WARNING_MESSAGE = '''
|
|
||||||
Note: Initializing the device may ask for administrative permissions
|
|
||||||
because we need to access the raw devices directly.
|
|
||||||
'''
|
|
||||||
|
|
||||||
exports.initialize =
|
|
||||||
signature: 'os initialize <image>'
|
|
||||||
description: 'initialize an os image'
|
|
||||||
help: """
|
|
||||||
Use this command to initialize a device with previously configured operating system image.
|
|
||||||
|
|
||||||
#{INIT_WARNING_MESSAGE}
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena os initialize ../path/rpi.img --type 'raspberry-pi'
|
|
||||||
"""
|
|
||||||
permission: 'user'
|
|
||||||
options: [
|
|
||||||
commandOptions.yes
|
|
||||||
{
|
|
||||||
signature: 'type'
|
|
||||||
description: 'device type (Check available types with `balena devices supported`)'
|
|
||||||
parameter: 'type'
|
|
||||||
alias: 't'
|
|
||||||
required: 'You have to specify a device type'
|
|
||||||
}
|
|
||||||
commandOptions.drive
|
|
||||||
]
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
umountAsync = Promise.promisify(require('umount').umount)
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
patterns = require('../utils/patterns')
|
|
||||||
helpers = require('../utils/helpers')
|
|
||||||
|
|
||||||
console.info("""
|
|
||||||
Initializing device
|
|
||||||
|
|
||||||
#{INIT_WARNING_MESSAGE}
|
|
||||||
""")
|
|
||||||
Promise.resolve(helpers.getManifest(params.image, options.type))
|
|
||||||
.then (manifest) ->
|
|
||||||
return manifest.initialization?.options
|
|
||||||
.then (questions) ->
|
|
||||||
return form.run questions,
|
|
||||||
override:
|
|
||||||
drive: options.drive
|
|
||||||
.tap (answers) ->
|
|
||||||
return if not answers.drive?
|
|
||||||
patterns.confirm(
|
|
||||||
options.yes
|
|
||||||
"This will erase #{answers.drive}. Are you sure?"
|
|
||||||
"Going to erase #{answers.drive}."
|
|
||||||
)
|
|
||||||
.return(answers.drive)
|
|
||||||
.then(umountAsync)
|
|
||||||
.tap (answers) ->
|
|
||||||
return helpers.sudo([
|
|
||||||
'internal'
|
|
||||||
'osinit'
|
|
||||||
params.image
|
|
||||||
options.type
|
|
||||||
JSON.stringify(answers)
|
|
||||||
])
|
|
||||||
.then (answers) ->
|
|
||||||
return if not answers.drive?
|
|
||||||
|
|
||||||
# TODO: balena local makes use of ejectAsync, see below
|
|
||||||
# DO we need this / should we do that here?
|
|
||||||
|
|
||||||
# getDrive = (drive) ->
|
|
||||||
# driveListAsync().then (drives) ->
|
|
||||||
# selectedDrive = _.find(drives, device: drive)
|
|
||||||
|
|
||||||
# if not selectedDrive?
|
|
||||||
# throw new Error("Drive not found: #{drive}")
|
|
||||||
|
|
||||||
# return selectedDrive
|
|
||||||
# if (os.platform() is 'win32') and selectedDrive.mountpoint?
|
|
||||||
# ejectAsync = Promise.promisify(require('removedrive').eject)
|
|
||||||
# return ejectAsync(selectedDrive.mountpoint)
|
|
||||||
|
|
||||||
umountAsync(answers.drive).tap ->
|
|
||||||
console.info("You can safely remove #{answers.drive} now")
|
|
||||||
.nodeify(done)
|
|
@ -1,334 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
_ = require('lodash')
|
|
||||||
|
|
||||||
dockerUtils = require('../utils/docker')
|
|
||||||
|
|
||||||
allDeviceTypes = undefined
|
|
||||||
|
|
||||||
isCurrent = (commit) ->
|
|
||||||
return commit == 'latest' or commit == 'current'
|
|
||||||
|
|
||||||
getDeviceTypes = ->
|
|
||||||
Bluebird = require('bluebird')
|
|
||||||
_ = require('lodash')
|
|
||||||
if allDeviceTypes != undefined
|
|
||||||
return Bluebird.resolve(allDeviceTypes)
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
balena.models.config.getDeviceTypes()
|
|
||||||
.then (deviceTypes) ->
|
|
||||||
_.sortBy(deviceTypes, 'name')
|
|
||||||
.tap (dt) ->
|
|
||||||
allDeviceTypes = dt
|
|
||||||
|
|
||||||
getDeviceTypesWithSameArch = (deviceTypeSlug) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
|
|
||||||
getDeviceTypes()
|
|
||||||
.then (deviceTypes) ->
|
|
||||||
deviceType = _.find(deviceTypes, slug: deviceTypeSlug)
|
|
||||||
_(deviceTypes).filter(arch: deviceType.arch).map('slug').value()
|
|
||||||
|
|
||||||
getApplicationsWithSuccessfulBuilds = (deviceType) ->
|
|
||||||
preload = require('balena-preload')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
|
|
||||||
getDeviceTypesWithSameArch(deviceType)
|
|
||||||
.then (deviceTypes) ->
|
|
||||||
balena.pine.get
|
|
||||||
resource: 'my_application'
|
|
||||||
options:
|
|
||||||
$filter:
|
|
||||||
device_type:
|
|
||||||
$in: deviceTypes
|
|
||||||
owns__release:
|
|
||||||
$any:
|
|
||||||
$alias: 'r'
|
|
||||||
$expr:
|
|
||||||
r:
|
|
||||||
status: 'success'
|
|
||||||
$expand: preload.applicationExpandOptions
|
|
||||||
$select: [ 'id', 'app_name', 'device_type', 'commit', 'should_track_latest_release' ]
|
|
||||||
$orderby: 'app_name asc'
|
|
||||||
|
|
||||||
selectApplication = (deviceType) ->
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
{ exitWithExpectedError } = require('../utils/patterns')
|
|
||||||
|
|
||||||
applicationInfoSpinner = new visuals.Spinner('Downloading list of applications and releases.')
|
|
||||||
applicationInfoSpinner.start()
|
|
||||||
|
|
||||||
getApplicationsWithSuccessfulBuilds(deviceType)
|
|
||||||
.then (applications) ->
|
|
||||||
applicationInfoSpinner.stop()
|
|
||||||
if applications.length == 0
|
|
||||||
exitWithExpectedError("You have no apps with successful releases for a '#{deviceType}' device type.")
|
|
||||||
form.ask
|
|
||||||
message: 'Select an application'
|
|
||||||
type: 'list'
|
|
||||||
choices: applications.map (app) ->
|
|
||||||
name: app.app_name
|
|
||||||
value: app
|
|
||||||
|
|
||||||
selectApplicationCommit = (releases) ->
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
{ exitWithExpectedError } = require('../utils/patterns')
|
|
||||||
|
|
||||||
if releases.length == 0
|
|
||||||
exitWithExpectedError('This application has no successful releases.')
|
|
||||||
DEFAULT_CHOICE = { 'name': 'current', 'value': 'current' }
|
|
||||||
choices = [ DEFAULT_CHOICE ].concat releases.map (release) ->
|
|
||||||
name: "#{release.end_timestamp} - #{release.commit}"
|
|
||||||
value: release.commit
|
|
||||||
return form.ask
|
|
||||||
message: 'Select a release'
|
|
||||||
type: 'list'
|
|
||||||
default: 'current'
|
|
||||||
choices: choices
|
|
||||||
|
|
||||||
offerToDisableAutomaticUpdates = (application, commit, pinDevice) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
form = require('resin-cli-form')
|
|
||||||
|
|
||||||
if isCurrent(commit) or not application.should_track_latest_release or pinDevice
|
|
||||||
return Promise.resolve()
|
|
||||||
message = '''
|
|
||||||
|
|
||||||
This application is set to automatically update all devices to the current version.
|
|
||||||
This might be unexpected behaviour: with this enabled, the preloaded device will still
|
|
||||||
download and install the current release once it is online.
|
|
||||||
|
|
||||||
Do you want to disable automatic updates for this application?
|
|
||||||
|
|
||||||
Warning: To re-enable this requires direct api calls,
|
|
||||||
see https://balena.io/docs/reference/api/resources/device/#set-device-to-release
|
|
||||||
|
|
||||||
Alternatively you can pass the --pin-device-to-release flag to pin only this device to the selected release.
|
|
||||||
'''
|
|
||||||
form.ask
|
|
||||||
message: message,
|
|
||||||
type: 'confirm'
|
|
||||||
.then (update) ->
|
|
||||||
if not update
|
|
||||||
return
|
|
||||||
balena.pine.patch
|
|
||||||
resource: 'application'
|
|
||||||
id: application.id
|
|
||||||
body:
|
|
||||||
should_track_latest_release: false
|
|
||||||
|
|
||||||
preloadOptions = dockerUtils.appendConnectionOptions [
|
|
||||||
{
|
|
||||||
signature: 'app'
|
|
||||||
parameter: 'appId'
|
|
||||||
description: 'id of the application to preload'
|
|
||||||
alias: 'a'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'commit'
|
|
||||||
parameter: 'hash'
|
|
||||||
description: '''
|
|
||||||
The commit hash for a specific application release to preload, use "current" to specify the current
|
|
||||||
release (ignored if no appId is given). The current release is usually also the latest, but can be
|
|
||||||
manually pinned using https://github.com/balena-io-projects/staged-releases .
|
|
||||||
'''
|
|
||||||
alias: 'c'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'splash-image'
|
|
||||||
parameter: 'splashImage.png'
|
|
||||||
description: 'path to a png image to replace the splash screen'
|
|
||||||
alias: 's'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'dont-check-arch'
|
|
||||||
boolean: true
|
|
||||||
description: 'Disables check for matching architecture in image and application'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'pin-device-to-release'
|
|
||||||
boolean: true
|
|
||||||
description: 'Pin the preloaded device to the preloaded release on provision'
|
|
||||||
alias: 'p'
|
|
||||||
}
|
|
||||||
{
|
|
||||||
signature: 'add-certificate'
|
|
||||||
parameter: 'certificate.crt'
|
|
||||||
description: '''
|
|
||||||
Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container.
|
|
||||||
The file name must end with '.crt' and must not be already contained in the preloader's
|
|
||||||
/etc/ssl/certs folder.
|
|
||||||
Can be repeated to add multiple certificates.
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
# Remove dockerPort `-p` alias as it conflicts with pin-device-to-release
|
|
||||||
delete _.find(preloadOptions, signature: 'dockerPort').alias
|
|
||||||
|
|
||||||
module.exports =
|
|
||||||
signature: 'preload <image>'
|
|
||||||
description: 'preload an app on a disk image (or Edison zip archive)'
|
|
||||||
help: '''
|
|
||||||
Warning: "balena preload" requires Docker to be correctly installed in
|
|
||||||
your shell environment. For more information (including Windows support)
|
|
||||||
please check the README here: https://github.com/balena-io/balena-cli .
|
|
||||||
|
|
||||||
Use this command to preload an application to a local disk image (or
|
|
||||||
Edison zip archive) with a built release from balena.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png
|
|
||||||
$ balena preload balena.img
|
|
||||||
'''
|
|
||||||
permission: 'user'
|
|
||||||
primary: true
|
|
||||||
options: preloadOptions
|
|
||||||
action: (params, options, done) ->
|
|
||||||
_ = require('lodash')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
preload = require('balena-preload')
|
|
||||||
visuals = require('resin-cli-visuals')
|
|
||||||
nodeCleanup = require('node-cleanup')
|
|
||||||
{ exitWithExpectedError } = require('../utils/patterns')
|
|
||||||
|
|
||||||
progressBars = {}
|
|
||||||
|
|
||||||
progressHandler = (event) ->
|
|
||||||
progressBar = progressBars[event.name]
|
|
||||||
if not progressBar
|
|
||||||
progressBar = progressBars[event.name] = new visuals.Progress(event.name)
|
|
||||||
progressBar.update(percentage: event.percentage)
|
|
||||||
|
|
||||||
spinners = {}
|
|
||||||
|
|
||||||
spinnerHandler = (event) ->
|
|
||||||
spinner = spinners[event.name]
|
|
||||||
if not spinner
|
|
||||||
spinner = spinners[event.name] = new visuals.Spinner(event.name)
|
|
||||||
if event.action == 'start'
|
|
||||||
spinner.start()
|
|
||||||
else
|
|
||||||
console.log()
|
|
||||||
spinner.stop()
|
|
||||||
|
|
||||||
options.commit = if isCurrent(options.commit) then 'latest' else options.commit
|
|
||||||
options.image = params.image
|
|
||||||
options.appId = options.app
|
|
||||||
delete options.app
|
|
||||||
|
|
||||||
options.splashImage = options['splash-image']
|
|
||||||
delete options['splash-image']
|
|
||||||
|
|
||||||
options.dontCheckArch = options['dont-check-arch'] || false
|
|
||||||
delete options['dont-check-arch']
|
|
||||||
if options.dontCheckArch and not options.appId
|
|
||||||
exitWithExpectedError('You need to specify an app id if you disable the architecture check.')
|
|
||||||
|
|
||||||
options.pinDevice = options['pin-device-to-release'] || false
|
|
||||||
delete options['pin-device-to-release']
|
|
||||||
|
|
||||||
if _.isArray(options['add-certificate'])
|
|
||||||
certificates = options['add-certificate']
|
|
||||||
else if options['add-certificate'] == undefined
|
|
||||||
certificates = []
|
|
||||||
else
|
|
||||||
certificates = [ options['add-certificate'] ]
|
|
||||||
for certificate in certificates
|
|
||||||
if not certificate.endsWith('.crt')
|
|
||||||
exitWithExpectedError('Certificate file name must end with ".crt"')
|
|
||||||
|
|
||||||
# Get a configured dockerode instance
|
|
||||||
dockerUtils.getDocker(options)
|
|
||||||
.then (docker) ->
|
|
||||||
|
|
||||||
preloader = new preload.Preloader(
|
|
||||||
balena
|
|
||||||
docker
|
|
||||||
options.appId
|
|
||||||
options.commit
|
|
||||||
options.image
|
|
||||||
options.splashImage
|
|
||||||
options.proxy
|
|
||||||
options.dontCheckArch
|
|
||||||
options.pinDevice
|
|
||||||
certificates
|
|
||||||
)
|
|
||||||
|
|
||||||
gotSignal = false
|
|
||||||
|
|
||||||
nodeCleanup (exitCode, signal) ->
|
|
||||||
if signal
|
|
||||||
gotSignal = true
|
|
||||||
nodeCleanup.uninstall() # don't call cleanup handler again
|
|
||||||
preloader.cleanup()
|
|
||||||
.then ->
|
|
||||||
# calling process.exit() won't inform parent process of signal
|
|
||||||
process.kill(process.pid, signal)
|
|
||||||
return false
|
|
||||||
|
|
||||||
if process.env.DEBUG
|
|
||||||
preloader.stderr.pipe(process.stderr)
|
|
||||||
|
|
||||||
preloader.on('progress', progressHandler)
|
|
||||||
preloader.on('spinner', spinnerHandler)
|
|
||||||
|
|
||||||
return new Promise (resolve, reject) ->
|
|
||||||
preloader.on('error', reject)
|
|
||||||
|
|
||||||
preloader.prepare()
|
|
||||||
.then ->
|
|
||||||
# If no appId was provided, show a list of matching apps
|
|
||||||
Promise.try ->
|
|
||||||
if not preloader.appId
|
|
||||||
selectApplication(preloader.config.deviceType)
|
|
||||||
.then (application) ->
|
|
||||||
preloader.setApplication(application)
|
|
||||||
.then ->
|
|
||||||
# Use the commit given as --commit or show an interactive commit selection menu
|
|
||||||
Promise.try ->
|
|
||||||
if options.commit
|
|
||||||
if isCurrent(options.commit) and preloader.application.commit
|
|
||||||
# handle `--commit current` (and its `--commit latest` synonym)
|
|
||||||
return 'latest'
|
|
||||||
release = _.find preloader.application.owns__release, (release) ->
|
|
||||||
release.commit.startsWith(options.commit)
|
|
||||||
if not release
|
|
||||||
exitWithExpectedError('There is no release matching this commit')
|
|
||||||
return release.commit
|
|
||||||
selectApplicationCommit(preloader.application.owns__release)
|
|
||||||
.then (commit) ->
|
|
||||||
if isCurrent(commit)
|
|
||||||
preloader.commit = preloader.application.commit
|
|
||||||
else
|
|
||||||
preloader.commit = commit
|
|
||||||
|
|
||||||
# Propose to disable automatic app updates if the commit is not the current release
|
|
||||||
offerToDisableAutomaticUpdates(preloader.application, commit, options.pinDevice)
|
|
||||||
.then ->
|
|
||||||
# All options are ready: preload the image.
|
|
||||||
preloader.preload()
|
|
||||||
.catch(balena.errors.BalenaError, exitWithExpectedError)
|
|
||||||
.then(resolve)
|
|
||||||
.catch(reject)
|
|
||||||
.then(done)
|
|
||||||
.finally ->
|
|
||||||
if not gotSignal
|
|
||||||
preloader.cleanup()
|
|
@ -1,380 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2019 Balena Ltd.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BalenaSDK } from 'balena-sdk';
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
import { registrySecretsHelp } from '../utils/messages';
|
|
||||||
import {
|
|
||||||
validateApplicationName,
|
|
||||||
validateDotLocalUrl,
|
|
||||||
validateIPAddress,
|
|
||||||
} from '../utils/validation';
|
|
||||||
|
|
||||||
enum BuildTarget {
|
|
||||||
Cloud,
|
|
||||||
Device,
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBuildTarget(appOrDevice: string): BuildTarget | null {
|
|
||||||
// First try the application regex from the api
|
|
||||||
if (validateApplicationName(appOrDevice)) {
|
|
||||||
return BuildTarget.Cloud;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validateIPAddress(appOrDevice) || validateDotLocalUrl(appOrDevice)) {
|
|
||||||
return BuildTarget.Device;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAppOwner(sdk: BalenaSDK, appName: string) {
|
|
||||||
const { exitWithExpectedError, selectFromList } = await import(
|
|
||||||
'../utils/patterns'
|
|
||||||
);
|
|
||||||
const _ = await import('lodash');
|
|
||||||
|
|
||||||
const applications = await sdk.models.application.getAll({
|
|
||||||
$expand: {
|
|
||||||
user: {
|
|
||||||
$select: ['username'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
$filter: {
|
|
||||||
$eq: [{ $tolower: { $: 'app_name' } }, appName.toLowerCase()],
|
|
||||||
},
|
|
||||||
$select: ['id'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (applications == null || applications.length === 0) {
|
|
||||||
exitWithExpectedError(
|
|
||||||
stripIndent`
|
|
||||||
No applications found with name: ${appName}.
|
|
||||||
|
|
||||||
This could mean that the application does not exist, or you do
|
|
||||||
not have the permissions required to access it.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (applications.length === 1) {
|
|
||||||
return _.get(applications, '[0].user[0].username');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we got more than one application with the same name it means that the
|
|
||||||
// user has access to a collab app with the same name as a personal app. We
|
|
||||||
// present a list to the user which shows the fully qualified application
|
|
||||||
// name (user/appname) and allows them to select
|
|
||||||
const entries = _.map(applications, app => {
|
|
||||||
const username = _.get(app, 'user[0].username');
|
|
||||||
return {
|
|
||||||
name: `${username}/${appName}`,
|
|
||||||
extra: username,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const selected = await selectFromList(
|
|
||||||
`${
|
|
||||||
entries.length
|
|
||||||
} applications found with that name, please select the application you would like to push to`,
|
|
||||||
entries,
|
|
||||||
);
|
|
||||||
|
|
||||||
return selected.extra;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const push: CommandDefinition<
|
|
||||||
{
|
|
||||||
// when Capitano converts a positional parameter (but not an option)
|
|
||||||
// to a number, the original value is preserved with the _raw suffix
|
|
||||||
applicationOrDevice: string;
|
|
||||||
applicationOrDevice_raw: string;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source?: string;
|
|
||||||
emulated?: boolean;
|
|
||||||
dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile)
|
|
||||||
nocache?: boolean;
|
|
||||||
'registry-secrets'?: string;
|
|
||||||
nolive?: boolean;
|
|
||||||
detached?: boolean;
|
|
||||||
service?: string | string[];
|
|
||||||
system?: boolean;
|
|
||||||
env?: string | string[];
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'push <applicationOrDevice>',
|
|
||||||
primary: true,
|
|
||||||
description:
|
|
||||||
'Start a remote build on the balena cloud build servers or a local mode device',
|
|
||||||
help: stripIndent`
|
|
||||||
This command can be used to start a build on the remote balena cloud builders,
|
|
||||||
or a local mode balena device.
|
|
||||||
|
|
||||||
When building on the balenaCloud servers, the given source directory will be
|
|
||||||
sent to the remote server. This can be used as a drop-in replacement for the
|
|
||||||
"git push" deployment method.
|
|
||||||
|
|
||||||
When building on a local mode device, the given source directory will be
|
|
||||||
built on the device, and the resulting containers will be run on the device.
|
|
||||||
Logs will be streamed back from the device as part of the same invocation.
|
|
||||||
The web dashboard can be used to switch a device to local mode:
|
|
||||||
https://www.balena.io/docs/learn/develop/local-mode/
|
|
||||||
Note that local mode requires a supervisor version of at least v7.21.0.
|
|
||||||
The logs from only a single service can be shown with the --service flag, and
|
|
||||||
showing only the system logs can be achieved with --system. Note that these
|
|
||||||
flags can be used together.
|
|
||||||
|
|
||||||
When pushing to a local device a live session will be started.
|
|
||||||
The project source folder is watched for filesystem events, and changes
|
|
||||||
to files and folders are automatically synchronized to the running
|
|
||||||
containers. The synchronisation is only in one direction, from this machine to
|
|
||||||
the device, and changes made on the device itself may be overwritten.
|
|
||||||
This feature requires a device running supervisor version v9.7.0 or greater.
|
|
||||||
|
|
||||||
${registrySecretsHelp.split('\n').join('\n\t\t')}
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena push myApp
|
|
||||||
$ balena push myApp --source <source directory>
|
|
||||||
$ balena push myApp -s <source directory>
|
|
||||||
|
|
||||||
$ balena push 10.0.0.1
|
|
||||||
$ balena push 10.0.0.1 --source <source directory>
|
|
||||||
$ balena push 10.0.0.1 --service my-service
|
|
||||||
$ balena push 10.0.0.1 --env MY_ENV_VAR=value --env my-service:SERVICE_VAR=value
|
|
||||||
$ balena push 10.0.0.1 --nolive
|
|
||||||
|
|
||||||
$ balena push 23c73a1.local --system
|
|
||||||
$ balena push 23c73a1.local --system --service my-service
|
|
||||||
`,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'source',
|
|
||||||
alias: 's',
|
|
||||||
description:
|
|
||||||
'The source that should be sent to the balena builder to be built (defaults to the current directory)',
|
|
||||||
parameter: 'source',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'emulated',
|
|
||||||
alias: 'e',
|
|
||||||
description: 'Force an emulated build to occur on the remote builder',
|
|
||||||
boolean: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'dockerfile',
|
|
||||||
parameter: 'Dockerfile',
|
|
||||||
description:
|
|
||||||
'Alternative Dockerfile name/path, relative to the source folder',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'nocache',
|
|
||||||
alias: 'c',
|
|
||||||
description: "Don't use cache when building this project",
|
|
||||||
boolean: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'registry-secrets',
|
|
||||||
alias: 'R',
|
|
||||||
parameter: 'secrets.yml|.json',
|
|
||||||
description: stripIndent`
|
|
||||||
Path to a local YAML or JSON file containing Docker registry passwords used to pull base images.
|
|
||||||
Note that if registry-secrets are not provided on the command line, a secrets configuration
|
|
||||||
file from the balena directory will be used (usually $HOME/.balena/secrets.yml|.json)`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'nolive',
|
|
||||||
boolean: true,
|
|
||||||
description: stripIndent`
|
|
||||||
Don't run a live session on this push. The filesystem will not be monitored, and changes
|
|
||||||
will not be synchronised to any running containers. Note that both this flag and --detached
|
|
||||||
and required to cause the process to end once the initial build has completed.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'detached',
|
|
||||||
alias: 'd',
|
|
||||||
description: `Don't tail application logs when pushing to a local mode device`,
|
|
||||||
boolean: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'service',
|
|
||||||
description: stripIndent`
|
|
||||||
Reject logs not originating from this service.
|
|
||||||
This can be used in combination with --system and other --service flags.
|
|
||||||
Only valid when pushing to a local mode device.`,
|
|
||||||
parameter: 'service',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'system',
|
|
||||||
description: stripIndent`
|
|
||||||
Only show system logs. This can be used in combination with --service.
|
|
||||||
Only valid when pushing to a local mode device.`,
|
|
||||||
boolean: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'env',
|
|
||||||
parameter: 'env',
|
|
||||||
description: stripIndent`
|
|
||||||
When performing a push to device, run the built containers with environment
|
|
||||||
variables provided with this argument. Environment variables can be applied
|
|
||||||
to individual services by adding their service name before the argument,
|
|
||||||
separated by a colon, e.g:
|
|
||||||
--env main:MY_ENV=value
|
|
||||||
Note that if the service name cannot be found in the composition, the entire
|
|
||||||
left hand side of the = character will be treated as the variable name.
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
async action(params, options, done) {
|
|
||||||
const sdk = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const Bluebird = await import('bluebird');
|
|
||||||
const isArray = await import('lodash/isArray');
|
|
||||||
const remote = await import('../utils/remote-build');
|
|
||||||
const deviceDeploy = await import('../utils/device/deploy');
|
|
||||||
const { exitIfNotLoggedIn, exitWithExpectedError } = await import(
|
|
||||||
'../utils/patterns'
|
|
||||||
);
|
|
||||||
const { validateSpecifiedDockerfile, getRegistrySecrets } = await import(
|
|
||||||
'../utils/compose_ts'
|
|
||||||
);
|
|
||||||
const { BuildError } = await import('../utils/device/errors');
|
|
||||||
|
|
||||||
const appOrDevice: string | null =
|
|
||||||
params.applicationOrDevice_raw || params.applicationOrDevice;
|
|
||||||
if (appOrDevice == null) {
|
|
||||||
exitWithExpectedError('You must specify an application or a device');
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = options.source || '.';
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log(`[debug] Using ${source} as build source`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockerfilePath = validateSpecifiedDockerfile(
|
|
||||||
source,
|
|
||||||
options.dockerfile,
|
|
||||||
);
|
|
||||||
|
|
||||||
const registrySecrets = await getRegistrySecrets(
|
|
||||||
sdk,
|
|
||||||
options['registry-secrets'],
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildTarget = getBuildTarget(appOrDevice);
|
|
||||||
switch (buildTarget) {
|
|
||||||
case BuildTarget.Cloud:
|
|
||||||
// Ensure that the live argument has not been passed to a cloud build
|
|
||||||
if (options.nolive != null) {
|
|
||||||
exitWithExpectedError(
|
|
||||||
'The --nolive flag is only valid when pushing to a local mode device',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.detached) {
|
|
||||||
exitWithExpectedError(
|
|
||||||
`The --detached flag is only valid when pushing to a local mode device.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.service) {
|
|
||||||
exitWithExpectedError(
|
|
||||||
'The --service flag is only valid when pushing to a local mode device.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.system) {
|
|
||||||
exitWithExpectedError(
|
|
||||||
'The --system flag is only valid when pushing to a local mode device.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.env) {
|
|
||||||
exitWithExpectedError(
|
|
||||||
'The --env flag is only valid when pushing to a local mode device.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = appOrDevice;
|
|
||||||
await exitIfNotLoggedIn();
|
|
||||||
await Bluebird.join(
|
|
||||||
sdk.auth.getToken(),
|
|
||||||
sdk.settings.get('balenaUrl'),
|
|
||||||
getAppOwner(sdk, app),
|
|
||||||
async (token, baseUrl, owner) => {
|
|
||||||
const opts = {
|
|
||||||
dockerfilePath,
|
|
||||||
emulated: options.emulated || false,
|
|
||||||
nocache: options.nocache || false,
|
|
||||||
registrySecrets,
|
|
||||||
};
|
|
||||||
const args = {
|
|
||||||
app,
|
|
||||||
owner,
|
|
||||||
source,
|
|
||||||
auth: token,
|
|
||||||
baseUrl,
|
|
||||||
sdk,
|
|
||||||
opts,
|
|
||||||
};
|
|
||||||
|
|
||||||
return await remote.startRemoteBuild(args);
|
|
||||||
},
|
|
||||||
).nodeify(done);
|
|
||||||
break;
|
|
||||||
case BuildTarget.Device:
|
|
||||||
const device = appOrDevice;
|
|
||||||
const servicesToDisplay =
|
|
||||||
options.service != null
|
|
||||||
? isArray(options.service)
|
|
||||||
? options.service
|
|
||||||
: [options.service]
|
|
||||||
: undefined;
|
|
||||||
// TODO: Support passing a different port
|
|
||||||
await Bluebird.resolve(
|
|
||||||
deviceDeploy.deployToDevice({
|
|
||||||
source,
|
|
||||||
deviceHost: device,
|
|
||||||
dockerfilePath,
|
|
||||||
registrySecrets,
|
|
||||||
nocache: options.nocache || false,
|
|
||||||
nolive: options.nolive || false,
|
|
||||||
detached: options.detached || false,
|
|
||||||
services: servicesToDisplay,
|
|
||||||
system: options.system || false,
|
|
||||||
env:
|
|
||||||
typeof options.env === 'string'
|
|
||||||
? [options.env]
|
|
||||||
: options.env || [],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(BuildError, e => {
|
|
||||||
exitWithExpectedError(e.toString());
|
|
||||||
})
|
|
||||||
.nodeify(done);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
exitWithExpectedError(
|
|
||||||
stripIndent`
|
|
||||||
Build target not recognised. Please provide either an application name or device address.
|
|
||||||
|
|
||||||
The only supported device addresses currently are IP addresses.
|
|
||||||
|
|
||||||
If you believe your build target should have been detected, and this is an error, please
|
|
||||||
create an issue.`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,100 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
dockerInfoProperties = [
|
|
||||||
'Containers'
|
|
||||||
'ContainersRunning'
|
|
||||||
'ContainersPaused'
|
|
||||||
'ContainersStopped'
|
|
||||||
'Images'
|
|
||||||
'Driver'
|
|
||||||
'SystemTime'
|
|
||||||
'KernelVersion'
|
|
||||||
'OperatingSystem'
|
|
||||||
'Architecture'
|
|
||||||
]
|
|
||||||
|
|
||||||
dockerVersionProperties = [
|
|
||||||
'Version'
|
|
||||||
'ApiVersion'
|
|
||||||
]
|
|
||||||
|
|
||||||
module.exports =
|
|
||||||
signature: 'scan'
|
|
||||||
description: 'Scan for balenaOS devices in your local network'
|
|
||||||
help: '''
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena scan
|
|
||||||
$ balena scan --timeout 120
|
|
||||||
$ balena scan --verbose
|
|
||||||
'''
|
|
||||||
options: [
|
|
||||||
signature: 'verbose'
|
|
||||||
boolean: true
|
|
||||||
description: 'Display full info'
|
|
||||||
alias: 'v'
|
|
||||||
,
|
|
||||||
signature: 'timeout'
|
|
||||||
parameter: 'timeout'
|
|
||||||
description: 'Scan timeout in seconds'
|
|
||||||
alias: 't'
|
|
||||||
]
|
|
||||||
primary: true
|
|
||||||
root: true
|
|
||||||
action: (params, options, done) ->
|
|
||||||
Promise = require('bluebird')
|
|
||||||
_ = require('lodash')
|
|
||||||
prettyjson = require('prettyjson')
|
|
||||||
{ discover } = require('balena-sync')
|
|
||||||
{ SpinnerPromise } = require('resin-cli-visuals')
|
|
||||||
{ dockerPort, dockerTimeout } = require('./local/common')
|
|
||||||
dockerUtils = require('../utils/docker')
|
|
||||||
{ exitWithExpectedError } = require('../utils/patterns')
|
|
||||||
|
|
||||||
if options.timeout?
|
|
||||||
options.timeout *= 1000
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
new SpinnerPromise
|
|
||||||
promise: discover.discoverLocalBalenaOsDevices(options.timeout)
|
|
||||||
startMessage: 'Scanning for local balenaOS devices..'
|
|
||||||
stopMessage: 'Reporting scan results'
|
|
||||||
.filter ({ address }) ->
|
|
||||||
Promise.try ->
|
|
||||||
docker = dockerUtils.createClient(host: address, port: dockerPort, timeout: dockerTimeout)
|
|
||||||
docker.pingAsync()
|
|
||||||
.return(true)
|
|
||||||
.catchReturn(false)
|
|
||||||
.tap (devices) ->
|
|
||||||
if _.isEmpty(devices)
|
|
||||||
exitWithExpectedError('Could not find any balenaOS devices in the local network')
|
|
||||||
.map ({ host, address }) ->
|
|
||||||
docker = dockerUtils.createClient(host: address, port: dockerPort, timeout: dockerTimeout)
|
|
||||||
Promise.props
|
|
||||||
dockerInfo: docker.infoAsync().catchReturn('Could not get Docker info')
|
|
||||||
dockerVersion: docker.versionAsync().catchReturn('Could not get Docker version')
|
|
||||||
.then ({ dockerInfo, dockerVersion }) ->
|
|
||||||
|
|
||||||
if not options.verbose
|
|
||||||
dockerInfo = _.pick(dockerInfo, dockerInfoProperties) if _.isObject(dockerInfo)
|
|
||||||
dockerVersion = _.pick(dockerVersion, dockerVersionProperties) if _.isObject(dockerVersion)
|
|
||||||
|
|
||||||
return { host, address, dockerInfo, dockerVersion }
|
|
||||||
.then (devicesInfo) ->
|
|
||||||
console.log(prettyjson.render(devicesInfo, noColor: true))
|
|
||||||
.nodeify(done)
|
|
@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
|
|
||||||
export const list: CommandDefinition = {
|
|
||||||
signature: 'settings',
|
|
||||||
description: 'print current settings',
|
|
||||||
help: `\
|
|
||||||
Use this command to display detected settings
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena settings\
|
|
||||||
`,
|
|
||||||
async action(_params, _options, done) {
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const prettyjson = await import('prettyjson');
|
|
||||||
|
|
||||||
return balena.settings
|
|
||||||
.getAll()
|
|
||||||
.then(prettyjson.render)
|
|
||||||
.then(console.log)
|
|
||||||
.nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,368 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2019 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
import * as BalenaSdk from 'balena-sdk';
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
import { BalenaDeviceNotFound } from 'balena-errors';
|
|
||||||
import { validateDotLocalUrl, validateIPAddress } from '../utils/validation';
|
|
||||||
|
|
||||||
async function getContainerId(
|
|
||||||
sdk: BalenaSdk.BalenaSDK,
|
|
||||||
uuid: string,
|
|
||||||
serviceName: string,
|
|
||||||
sshOpts: {
|
|
||||||
port?: number;
|
|
||||||
proxyCommand?: string;
|
|
||||||
proxyUrl: string;
|
|
||||||
username: string;
|
|
||||||
},
|
|
||||||
version?: string,
|
|
||||||
id?: number,
|
|
||||||
): Promise<string> {
|
|
||||||
const semver = await import('resin-semver');
|
|
||||||
|
|
||||||
if (version == null || id == null) {
|
|
||||||
const device = await sdk.models.device.get(uuid, {
|
|
||||||
$select: ['id', 'supervisor_version'],
|
|
||||||
});
|
|
||||||
version = device.supervisor_version;
|
|
||||||
id = device.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let containerId: string | undefined;
|
|
||||||
if (semver.gte(version, '8.6.0')) {
|
|
||||||
const apiUrl = await sdk.settings.get('apiUrl');
|
|
||||||
// TODO: Move this into the SDKs device model
|
|
||||||
const request = await sdk.request.send({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/supervisor/v2/containerId',
|
|
||||||
baseUrl: apiUrl,
|
|
||||||
body: {
|
|
||||||
method: 'GET',
|
|
||||||
deviceId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (request.status !== 200) {
|
|
||||||
throw new Error(
|
|
||||||
`There was an error connecting to device ${uuid}, HTTP response code: ${
|
|
||||||
request.status
|
|
||||||
}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = request.body;
|
|
||||||
if (body.status !== 'success') {
|
|
||||||
throw new Error(
|
|
||||||
`There was an error communicating with device ${uuid}.\n\tError: ${
|
|
||||||
body.message
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
containerId = body.services[serviceName];
|
|
||||||
} else {
|
|
||||||
console.log(stripIndent`
|
|
||||||
Using legacy method to detect container ID. This will be slow.
|
|
||||||
To speed up this process, please update your device to an OS
|
|
||||||
which has a supervisor version of at least v8.6.0.
|
|
||||||
`);
|
|
||||||
// We need to execute a balena ps command on the device,
|
|
||||||
// and parse the output, looking for a specific
|
|
||||||
// container
|
|
||||||
const { child_process } = await import('mz');
|
|
||||||
const escapeRegex = await import('lodash/escapeRegExp');
|
|
||||||
const { getSubShellCommand } = await import('../utils/helpers');
|
|
||||||
const { deviceContainerEngineBinary } = await import('../utils/device/ssh');
|
|
||||||
|
|
||||||
const command = generateVpnSshCommand({
|
|
||||||
uuid,
|
|
||||||
verbose: false,
|
|
||||||
port: sshOpts.port,
|
|
||||||
command: `host ${uuid} '"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"'`,
|
|
||||||
proxyCommand: sshOpts.proxyCommand,
|
|
||||||
proxyUrl: sshOpts.proxyUrl,
|
|
||||||
username: sshOpts.username,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subShellCommand = getSubShellCommand(command);
|
|
||||||
const subprocess = child_process.spawn(
|
|
||||||
subShellCommand.program,
|
|
||||||
subShellCommand.args,
|
|
||||||
{
|
|
||||||
stdio: [null, 'pipe', null],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const containers = await new Promise<string>((resolve, reject) => {
|
|
||||||
let output = '';
|
|
||||||
subprocess.stdout.on('data', chunk => (output += chunk.toString()));
|
|
||||||
subprocess.on('close', (code: number) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Non-zero error code when looking for service container: ${code}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
resolve(output);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const lines = containers.split('\n');
|
|
||||||
const regex = new RegExp(`\\/?${escapeRegex(serviceName)}_\\d+_\\d+`);
|
|
||||||
for (const container of lines) {
|
|
||||||
const [cId, name] = container.split(' ');
|
|
||||||
if (regex.test(name)) {
|
|
||||||
containerId = cId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containerId == null) {
|
|
||||||
throw new Error(
|
|
||||||
`Could not find a service ${serviceName} on device ${uuid}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return containerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateVpnSshCommand(opts: {
|
|
||||||
uuid: string;
|
|
||||||
command: string;
|
|
||||||
verbose: boolean;
|
|
||||||
port?: number;
|
|
||||||
username: string;
|
|
||||||
proxyUrl: string;
|
|
||||||
proxyCommand?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
`ssh ${
|
|
||||||
opts.verbose ? '-vvv' : ''
|
|
||||||
} -t -o LogLevel=ERROR -o StrictHostKeyChecking=no ` +
|
|
||||||
`-o UserKnownHostsFile=/dev/null ` +
|
|
||||||
`${opts.proxyCommand != null ? opts.proxyCommand : ''} ` +
|
|
||||||
`${opts.port != null ? `-p ${opts.port}` : ''} ` +
|
|
||||||
`${opts.username}@ssh.${opts.proxyUrl} ${opts.command}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ssh: CommandDefinition<
|
|
||||||
{
|
|
||||||
applicationOrDevice: string;
|
|
||||||
// when Capitano converts a positional parameter (but not an option)
|
|
||||||
// to a number, the original value is preserved with the _raw suffix
|
|
||||||
applicationOrDevice_raw: string;
|
|
||||||
serviceName?: string;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
port: string;
|
|
||||||
service: string;
|
|
||||||
verbose: true | undefined;
|
|
||||||
noProxy: boolean;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'ssh <applicationOrDevice> [serviceName]',
|
|
||||||
description: 'SSH into the host or application container of a device',
|
|
||||||
primary: true,
|
|
||||||
help: stripIndent`
|
|
||||||
This command can be used to start a shell on a local or remote device.
|
|
||||||
|
|
||||||
If a service name is not provided, a shell will be opened on the host OS.
|
|
||||||
|
|
||||||
If an application name is provided, all online devices in the application
|
|
||||||
will be presented, and the chosen device will then have a shell opened on
|
|
||||||
in it's service container or host OS.
|
|
||||||
|
|
||||||
For local devices, the ip address and .local domain name are supported.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
balena ssh MyApp
|
|
||||||
|
|
||||||
balena ssh f49cefd
|
|
||||||
balena ssh f49cefd my-service
|
|
||||||
balena ssh f49cefd --port <port>
|
|
||||||
|
|
||||||
balena ssh 192.168.0.1 --verbose
|
|
||||||
balena ssh f49cefd.local my-service
|
|
||||||
|
|
||||||
Warning: 'balena ssh' requires an openssh-compatible client to be correctly
|
|
||||||
installed in your shell environment. For more information (including Windows
|
|
||||||
support) please check:
|
|
||||||
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies`,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'port',
|
|
||||||
parameter: 'port',
|
|
||||||
description: 'SSH gateway port',
|
|
||||||
alias: 'p',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'verbose',
|
|
||||||
boolean: true,
|
|
||||||
description: 'Increase verbosity',
|
|
||||||
alias: 'v',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'noproxy',
|
|
||||||
boolean: true,
|
|
||||||
description: stripIndent`
|
|
||||||
Don't use the proxy configuration for this connection. This flag
|
|
||||||
only make sense if you've configured a proxy globally.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
action: async (params, options) => {
|
|
||||||
const applicationOrDevice =
|
|
||||||
params.applicationOrDevice_raw || params.applicationOrDevice;
|
|
||||||
const bash = await import('bash');
|
|
||||||
// TODO: Make this typed
|
|
||||||
const hasbin = require('hasbin');
|
|
||||||
const { getSubShellCommand } = await import('../utils/helpers');
|
|
||||||
const { child_process } = await import('mz');
|
|
||||||
const {
|
|
||||||
exitIfNotLoggedIn,
|
|
||||||
exitWithExpectedError,
|
|
||||||
getOnlineTargetUuid,
|
|
||||||
} = await import('../utils/patterns');
|
|
||||||
const sdk = BalenaSdk.fromSharedOptions();
|
|
||||||
|
|
||||||
const verbose = options.verbose === true;
|
|
||||||
// ugh TODO: Fix this
|
|
||||||
const proxyConfig = (global as any).PROXY_CONFIG;
|
|
||||||
const useProxy = !!proxyConfig && !options.noProxy;
|
|
||||||
const port = options.port != null ? parseInt(options.port, 10) : undefined;
|
|
||||||
|
|
||||||
// if we're doing a direct SSH connection locally...
|
|
||||||
if (
|
|
||||||
validateDotLocalUrl(applicationOrDevice) ||
|
|
||||||
validateIPAddress(applicationOrDevice)
|
|
||||||
) {
|
|
||||||
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
|
|
||||||
return await performLocalDeviceSSH({
|
|
||||||
address: applicationOrDevice,
|
|
||||||
port,
|
|
||||||
verbose,
|
|
||||||
service: params.serviceName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// this will be a tunnelled SSH connection...
|
|
||||||
exitIfNotLoggedIn();
|
|
||||||
const uuid = await getOnlineTargetUuid(sdk, applicationOrDevice);
|
|
||||||
let version: string | undefined;
|
|
||||||
let id: number | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const device = await sdk.models.device.get(uuid, {
|
|
||||||
$select: ['id', 'supervisor_version', 'is_online'],
|
|
||||||
});
|
|
||||||
id = device.id;
|
|
||||||
version = device.supervisor_version;
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof BalenaDeviceNotFound) {
|
|
||||||
exitWithExpectedError(`Could not find device: ${uuid}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [hasTunnelBin, username, proxyUrl] = await Promise.all([
|
|
||||||
useProxy ? await hasbin('proxytunnel') : undefined,
|
|
||||||
sdk.auth.whoami(),
|
|
||||||
sdk.settings.get('proxyUrl'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getSshProxyCommand = () => {
|
|
||||||
if (!useProxy) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasTunnelBin) {
|
|
||||||
console.warn(stripIndent`
|
|
||||||
Proxy is enabled but the \`proxytunnel\` binary cannot be found.
|
|
||||||
Please install it if you want to route the \`balena ssh\` requests through the proxy.
|
|
||||||
Alternatively you can pass \`--noproxy\` param to the \`balena ssh\` command to ignore the proxy config
|
|
||||||
for the \`ssh\` requests.
|
|
||||||
|
|
||||||
Attempting the unproxied request for now.`);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
let tunnelOptions: Dictionary<string> = {
|
|
||||||
proxy: `${proxyConfig.host}:${proxyConfig.port}`,
|
|
||||||
dest: '%h:%p',
|
|
||||||
};
|
|
||||||
const { proxyAuth } = proxyConfig;
|
|
||||||
if (proxyAuth) {
|
|
||||||
const i = proxyAuth.indexOf(':');
|
|
||||||
tunnelOptions = {
|
|
||||||
user: proxyAuth.substring(0, i),
|
|
||||||
pass: proxyAuth.substring(i + 1),
|
|
||||||
...tunnelOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProxyCommand = `proxytunnel ${bash.args(tunnelOptions, '--', '=')}`;
|
|
||||||
return `-o ${bash.args({ ProxyCommand }, '', '=')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const proxyCommand = getSshProxyCommand();
|
|
||||||
|
|
||||||
if (username == null) {
|
|
||||||
exitWithExpectedError(
|
|
||||||
`Opening an SSH connection to a remote device requires you to be logged in.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point, we have a long uuid with a device
|
|
||||||
// that we know exists and is accessible
|
|
||||||
let containerId: string | undefined;
|
|
||||||
if (params.serviceName != null) {
|
|
||||||
containerId = await getContainerId(
|
|
||||||
sdk,
|
|
||||||
uuid,
|
|
||||||
params.serviceName,
|
|
||||||
{
|
|
||||||
port,
|
|
||||||
proxyCommand,
|
|
||||||
proxyUrl,
|
|
||||||
username: username!,
|
|
||||||
},
|
|
||||||
version,
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let accessCommand: string;
|
|
||||||
if (containerId != null) {
|
|
||||||
accessCommand = `enter ${uuid} ${containerId}`;
|
|
||||||
} else {
|
|
||||||
accessCommand = `host ${uuid}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = generateVpnSshCommand({
|
|
||||||
uuid,
|
|
||||||
command: accessCommand,
|
|
||||||
verbose,
|
|
||||||
port,
|
|
||||||
proxyCommand,
|
|
||||||
proxyUrl,
|
|
||||||
username: username!,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subShellCommand = getSubShellCommand(command);
|
|
||||||
await child_process.spawn(subShellCommand.program, subShellCommand.args, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,277 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2018 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ApplicationTag, DeviceTag, ReleaseTag } from 'balena-sdk';
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
import { normalizeUuidProp } from '../utils/normalization';
|
|
||||||
import * as commandOptions from './command-options';
|
|
||||||
|
|
||||||
export const list: CommandDefinition<
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
application?: string;
|
|
||||||
device?: string;
|
|
||||||
release?: number;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'tags',
|
|
||||||
description: 'list all resource tags',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to list all tags for
|
|
||||||
a particular application, device or release.
|
|
||||||
|
|
||||||
This command lists all application/device/release tags.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
$ balena tags --application MyApp
|
|
||||||
$ balena tags --device 7cf02a6
|
|
||||||
$ balena tags --release 1234
|
|
||||||
`,
|
|
||||||
options: [
|
|
||||||
commandOptions.optionalApplication,
|
|
||||||
commandOptions.optionalDevice,
|
|
||||||
commandOptions.optionalRelease,
|
|
||||||
],
|
|
||||||
permission: 'user',
|
|
||||||
async action(_params, options, done) {
|
|
||||||
normalizeUuidProp(options, 'device');
|
|
||||||
const Bluebird = await import('bluebird');
|
|
||||||
const _ = await import('lodash');
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const visuals = await import('resin-cli-visuals');
|
|
||||||
|
|
||||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
|
||||||
|
|
||||||
return Bluebird.try<ApplicationTag[] | DeviceTag[] | ReleaseTag[]>(() => {
|
|
||||||
const wrongParametersError = stripIndent`
|
|
||||||
To list resource tags, you must provide exactly one of:
|
|
||||||
|
|
||||||
* An application, with --application <appname>
|
|
||||||
* A device, with --device <uuid>
|
|
||||||
* A release, with --release <id>
|
|
||||||
|
|
||||||
See the help page for examples:
|
|
||||||
|
|
||||||
$ balena help tags
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (
|
|
||||||
_.filter([options.application, options.device, options.release])
|
|
||||||
.length !== 1
|
|
||||||
) {
|
|
||||||
return exitWithExpectedError(wrongParametersError);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.application) {
|
|
||||||
return balena.models.application.tags.getAllByApplication(
|
|
||||||
options.application,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.device) {
|
|
||||||
return balena.models.device.tags.getAllByDevice(options.device);
|
|
||||||
}
|
|
||||||
if (options.release) {
|
|
||||||
return balena.models.release.tags.getAllByRelease(options.release);
|
|
||||||
}
|
|
||||||
|
|
||||||
// return never, so that TS typings are happy
|
|
||||||
return exitWithExpectedError(wrongParametersError);
|
|
||||||
})
|
|
||||||
.tap(function(environmentVariables) {
|
|
||||||
if (_.isEmpty(environmentVariables)) {
|
|
||||||
exitWithExpectedError('No tags found');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
visuals.table.horizontal(environmentVariables, [
|
|
||||||
'id',
|
|
||||||
'tag_key',
|
|
||||||
'value',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const set: CommandDefinition<
|
|
||||||
{
|
|
||||||
tagKey: string;
|
|
||||||
value?: string;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
application?: string;
|
|
||||||
device?: string;
|
|
||||||
release?: number;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'tag set <tagKey> [value]',
|
|
||||||
description: 'set a resource tag',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to set a tag to an application, device or release.
|
|
||||||
|
|
||||||
You can optionally provide a value to be associated with the created
|
|
||||||
tag, as an extra argument after the tag key. When the value isn't
|
|
||||||
provided, a tag with an empty value is created.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena tag set mySimpleTag --application MyApp
|
|
||||||
$ balena tag set myCompositeTag myTagValue --application MyApp
|
|
||||||
$ balena tag set myCompositeTag myTagValue --device 7cf02a6
|
|
||||||
$ balena tag set myCompositeTag myTagValue --release 1234
|
|
||||||
$ balena tag set myCompositeTag "my tag value with whitespaces" --release 1234
|
|
||||||
`,
|
|
||||||
options: [
|
|
||||||
commandOptions.optionalApplication,
|
|
||||||
commandOptions.optionalDevice,
|
|
||||||
commandOptions.optionalRelease,
|
|
||||||
],
|
|
||||||
permission: 'user',
|
|
||||||
async action(params, options, done) {
|
|
||||||
normalizeUuidProp(options, 'device');
|
|
||||||
const Bluebird = await import('bluebird');
|
|
||||||
const _ = await import('lodash');
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
|
|
||||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
|
||||||
|
|
||||||
return Bluebird.try(() => {
|
|
||||||
if (_.isEmpty(params.tagKey)) {
|
|
||||||
return exitWithExpectedError('No tag key was provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
_.filter([options.application, options.device, options.release])
|
|
||||||
.length !== 1
|
|
||||||
) {
|
|
||||||
return exitWithExpectedError(stripIndent`
|
|
||||||
To set a resource tag, you must provide exactly one of:
|
|
||||||
|
|
||||||
* An application, with --application <appname>
|
|
||||||
* A device, with --device <uuid>
|
|
||||||
* A release, with --release <id>
|
|
||||||
|
|
||||||
See the help page for examples:
|
|
||||||
|
|
||||||
$ balena help tag set
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.value == null) {
|
|
||||||
params.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.application) {
|
|
||||||
return balena.models.application.tags.set(
|
|
||||||
options.application,
|
|
||||||
params.tagKey,
|
|
||||||
params.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.device) {
|
|
||||||
return balena.models.device.tags.set(
|
|
||||||
options.device,
|
|
||||||
params.tagKey,
|
|
||||||
params.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.release) {
|
|
||||||
return balena.models.release.tags.set(
|
|
||||||
options.release,
|
|
||||||
params.tagKey,
|
|
||||||
params.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}).nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const remove: CommandDefinition<
|
|
||||||
{
|
|
||||||
tagKey: string;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
application?: string;
|
|
||||||
device?: string;
|
|
||||||
release?: number;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'tag rm <tagKey>',
|
|
||||||
description: 'remove a resource tag',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to remove a tag from an application, device or release.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ balena tag rm myTagKey --application MyApp
|
|
||||||
$ balena tag rm myTagKey --device 7cf02a6
|
|
||||||
$ balena tag rm myTagKey --release 1234
|
|
||||||
`,
|
|
||||||
options: [
|
|
||||||
commandOptions.optionalApplication,
|
|
||||||
commandOptions.optionalDevice,
|
|
||||||
commandOptions.optionalRelease,
|
|
||||||
],
|
|
||||||
permission: 'user',
|
|
||||||
async action(params, options, done) {
|
|
||||||
const Bluebird = await import('bluebird');
|
|
||||||
const _ = await import('lodash');
|
|
||||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
|
||||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
|
||||||
|
|
||||||
return Bluebird.try(() => {
|
|
||||||
if (_.isEmpty(params.tagKey)) {
|
|
||||||
return exitWithExpectedError('No tag key was provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
_.filter([options.application, options.device, options.release])
|
|
||||||
.length !== 1
|
|
||||||
) {
|
|
||||||
return exitWithExpectedError(stripIndent`
|
|
||||||
To remove a resource tag, you must provide exactly one of:
|
|
||||||
|
|
||||||
* An application, with --application <appname>
|
|
||||||
* A device, with --device <uuid>
|
|
||||||
* A release, with --release <id>
|
|
||||||
|
|
||||||
See the help page for examples:
|
|
||||||
|
|
||||||
$ balena help tag rm
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.application) {
|
|
||||||
return balena.models.application.tags.remove(
|
|
||||||
options.application,
|
|
||||||
params.tagKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.device) {
|
|
||||||
return balena.models.device.tags.remove(options.device, params.tagKey);
|
|
||||||
}
|
|
||||||
if (options.release) {
|
|
||||||
return balena.models.release.tags.remove(
|
|
||||||
options.release,
|
|
||||||
params.tagKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}).nodeify(done);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,235 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2019 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
import * as Bluebird from 'bluebird';
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
import { createServer, Server, Socket } from 'net';
|
|
||||||
import { isArray } from 'util';
|
|
||||||
|
|
||||||
import { getOnlineTargetUuid } from '../utils/patterns';
|
|
||||||
import { tunnelConnectionToDevice } from '../utils/tunnel';
|
|
||||||
|
|
||||||
interface Args {
|
|
||||||
deviceOrApplication: string;
|
|
||||||
// when Capitano converts a positional parameter (but not an option)
|
|
||||||
// to a number, the original value is preserved with the _raw suffix
|
|
||||||
deviceOrApplication_raw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
port: string | string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class InvalidPortMappingError extends Error {
|
|
||||||
constructor(mapping: string) {
|
|
||||||
super(`'${mapping}' is not a valid port mapping.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NoPortsDefinedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('No ports have been provided.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidPort = (port: number) => {
|
|
||||||
const MAX_PORT_VALUE = Math.pow(2, 16) - 1;
|
|
||||||
return port > 0 && port <= MAX_PORT_VALUE;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tunnel: CommandDefinition<Args, Options> = {
|
|
||||||
signature: 'tunnel <deviceOrApplication>',
|
|
||||||
description: 'Tunnel local ports to your balenaOS device',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to open local ports which tunnel to listening ports on your balenaOS device.
|
|
||||||
|
|
||||||
For example, you could open port 8080 on your local machine to connect to your managed balenaOS
|
|
||||||
device running a web server listening on port 3000.
|
|
||||||
|
|
||||||
You can tunnel multiple ports at any given time.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
# map remote port 22222 to localhost:22222
|
|
||||||
$ balena tunnel abcde12345 -p 22222
|
|
||||||
|
|
||||||
# map remote port 22222 to localhost:222
|
|
||||||
$ balena tunnel abcde12345 -p 22222:222
|
|
||||||
|
|
||||||
# map remote port 22222 to any address on your host machine, port 22222
|
|
||||||
$ balena tunnel abcde12345 -p 22222:0.0.0.0
|
|
||||||
|
|
||||||
# map remote port 22222 to any address on your host machine, port 222
|
|
||||||
$ balena tunnel abcde12345 -p 22222:0.0.0.0:222
|
|
||||||
|
|
||||||
# multiple port tunnels can be specified at any one time
|
|
||||||
$ balena tunnel abcde12345 -p 8080:3000 -p 8081:9000
|
|
||||||
`,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'port',
|
|
||||||
parameter: 'port',
|
|
||||||
alias: 'p',
|
|
||||||
description: 'The mapping of remote to local ports.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
primary: true,
|
|
||||||
|
|
||||||
action: async (params, options) => {
|
|
||||||
const deviceOrApplication =
|
|
||||||
params.deviceOrApplication_raw || params.deviceOrApplication;
|
|
||||||
const Logger = await import('../utils/logger');
|
|
||||||
const logger = new Logger();
|
|
||||||
const balena = await import('balena-sdk');
|
|
||||||
const sdk = balena.fromSharedOptions();
|
|
||||||
|
|
||||||
const logConnection = (
|
|
||||||
fromHost: string,
|
|
||||||
fromPort: number,
|
|
||||||
localAddress: string,
|
|
||||||
localPort: number,
|
|
||||||
deviceAddress: string,
|
|
||||||
devicePort: number,
|
|
||||||
err?: Error,
|
|
||||||
) => {
|
|
||||||
const logMessage = `${fromHost}:${fromPort} => ${localAddress}:${localPort} ===> ${deviceAddress}:${devicePort}`;
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
logger.logError(`${logMessage} :: ${err.message}`);
|
|
||||||
} else {
|
|
||||||
logger.logLogs(logMessage);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.port === undefined) {
|
|
||||||
throw new NoPortsDefinedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ports =
|
|
||||||
typeof options.port !== 'string' && isArray(options.port)
|
|
||||||
? (options.port as string[])
|
|
||||||
: [options.port as string];
|
|
||||||
|
|
||||||
const uuid = await getOnlineTargetUuid(sdk, deviceOrApplication);
|
|
||||||
const device = await sdk.models.device.get(uuid);
|
|
||||||
|
|
||||||
logger.logInfo(`Opening a tunnel to ${device.uuid}...`);
|
|
||||||
|
|
||||||
const localListeners = _.chain(ports)
|
|
||||||
.map(mapping => {
|
|
||||||
const regexResult = /^([0-9]+)(?:$|\:(?:([\w\:\.]+)\:|)([0-9]+))$/.exec(
|
|
||||||
mapping,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (regexResult === null) {
|
|
||||||
throw new InvalidPortMappingError(mapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
// grab the groups
|
|
||||||
// tslint:disable-next-line:prefer-const
|
|
||||||
let [, remotePort, localAddress, localPort] = regexResult;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isValidPort(parseInt(localPort, undefined)) ||
|
|
||||||
!isValidPort(parseInt(remotePort, undefined))
|
|
||||||
) {
|
|
||||||
throw new InvalidPortMappingError(mapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
// default bind to localAddress
|
|
||||||
if (localAddress == null) {
|
|
||||||
localAddress = 'localhost';
|
|
||||||
}
|
|
||||||
|
|
||||||
// default use same port number locally as remote
|
|
||||||
if (localPort == null) {
|
|
||||||
localPort = remotePort;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
localPort: parseInt(localPort, undefined),
|
|
||||||
localAddress,
|
|
||||||
remotePort: parseInt(remotePort, undefined),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.map(({ localPort, localAddress, remotePort }) => {
|
|
||||||
return tunnelConnectionToDevice(device.uuid, remotePort, sdk)
|
|
||||||
.then(handler =>
|
|
||||||
createServer((client: Socket) => {
|
|
||||||
return handler(client)
|
|
||||||
.then(() => {
|
|
||||||
logConnection(
|
|
||||||
client.remoteAddress || '',
|
|
||||||
client.remotePort || 0,
|
|
||||||
client.localAddress,
|
|
||||||
client.localPort,
|
|
||||||
device.vpn_address || '',
|
|
||||||
remotePort,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(err =>
|
|
||||||
logConnection(
|
|
||||||
client.remoteAddress || '',
|
|
||||||
client.remotePort || 0,
|
|
||||||
client.localAddress,
|
|
||||||
client.localPort,
|
|
||||||
device.vpn_address || '',
|
|
||||||
remotePort,
|
|
||||||
err,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
server =>
|
|
||||||
new Bluebird.Promise<Server>((resolve, reject) => {
|
|
||||||
server.on('error', reject);
|
|
||||||
server.listen(localPort, localAddress, () => {
|
|
||||||
resolve(server);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
logger.logInfo(
|
|
||||||
` - tunnelling ${localAddress}:${localPort} to ${
|
|
||||||
device.uuid
|
|
||||||
}:${remotePort}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
logger.logWarn(
|
|
||||||
` - not tunnelling ${localAddress}:${localPort} to ${
|
|
||||||
device.uuid
|
|
||||||
}:${remotePort}, failed ${JSON.stringify(err.message)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.value();
|
|
||||||
|
|
||||||
const results = await Promise.all(localListeners);
|
|
||||||
if (!results.includes(true)) {
|
|
||||||
throw new Error('No ports are valid for tunnelling');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.logInfo('Waiting for connections...');
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,65 +0,0 @@
|
|||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
/*
|
|
||||||
Copyright 2016-2017 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommandDefinition } from 'capitano';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
export const availableDrives: CommandDefinition<{}, {}> = {
|
|
||||||
signature: 'util available-drives',
|
|
||||||
description: 'list available drives',
|
|
||||||
help: stripIndent`
|
|
||||||
Use this command to list your machine's drives usable for writing the OS image to.
|
|
||||||
Skips the system drives.
|
|
||||||
`,
|
|
||||||
async action() {
|
|
||||||
const sdk = await import('etcher-sdk');
|
|
||||||
const visuals = await import('resin-cli-visuals');
|
|
||||||
|
|
||||||
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter(() => false);
|
|
||||||
const scanner = new sdk.scanner.Scanner([adapter]);
|
|
||||||
await scanner.start();
|
|
||||||
|
|
||||||
function formatDrive(drive: any) {
|
|
||||||
const size = drive.size / 1000000000;
|
|
||||||
return {
|
|
||||||
device: drive.device,
|
|
||||||
size: `${size.toFixed(1)} GB`,
|
|
||||||
description: drive.description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scanner.drives.size === 0) {
|
|
||||||
console.error(
|
|
||||||
`${chalk.red('x')} No available drives were detected, plug one in!`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
visuals.table.horizontal(Array.from(scanner.drives).map(formatDrive), [
|
|
||||||
'device',
|
|
||||||
'size',
|
|
||||||
'description',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
scanner.stop();
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,158 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016-2019 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
Promise = require('bluebird')
|
|
||||||
capitano = require('capitano')
|
|
||||||
actions = require('./actions')
|
|
||||||
events = require('./events')
|
|
||||||
|
|
||||||
capitano.permission 'user', (done) ->
|
|
||||||
require('./utils/patterns').exitIfNotLoggedIn()
|
|
||||||
.then(done, done)
|
|
||||||
|
|
||||||
capitano.command
|
|
||||||
signature: '*'
|
|
||||||
action: ->
|
|
||||||
capitano.execute(command: 'help')
|
|
||||||
|
|
||||||
capitano.globalOption
|
|
||||||
signature: 'help'
|
|
||||||
boolean: true
|
|
||||||
alias: 'h'
|
|
||||||
|
|
||||||
capitano.globalOption
|
|
||||||
signature: 'version'
|
|
||||||
boolean: true
|
|
||||||
alias: 'v'
|
|
||||||
|
|
||||||
# ---------- Help Module ----------
|
|
||||||
capitano.command(actions.help.help)
|
|
||||||
|
|
||||||
# ---------- Api key module ----------
|
|
||||||
capitano.command(actions.apiKey.generate)
|
|
||||||
|
|
||||||
# ---------- App Module ----------
|
|
||||||
capitano.command(actions.app.create)
|
|
||||||
capitano.command(actions.app.list)
|
|
||||||
capitano.command(actions.app.remove)
|
|
||||||
capitano.command(actions.app.restart)
|
|
||||||
capitano.command(actions.app.info)
|
|
||||||
|
|
||||||
# ---------- Auth Module ----------
|
|
||||||
capitano.command(actions.auth.login)
|
|
||||||
capitano.command(actions.auth.logout)
|
|
||||||
capitano.command(actions.auth.whoami)
|
|
||||||
|
|
||||||
# ---------- Device Module ----------
|
|
||||||
capitano.command(actions.device.list)
|
|
||||||
capitano.command(actions.device.supported)
|
|
||||||
capitano.command(actions.device.rename)
|
|
||||||
capitano.command(actions.device.init)
|
|
||||||
capitano.command(actions.device.remove)
|
|
||||||
capitano.command(actions.device.identify)
|
|
||||||
capitano.command(actions.device.reboot)
|
|
||||||
capitano.command(actions.device.shutdown)
|
|
||||||
capitano.command(actions.device.enableDeviceUrl)
|
|
||||||
capitano.command(actions.device.disableDeviceUrl)
|
|
||||||
capitano.command(actions.device.getDeviceUrl)
|
|
||||||
capitano.command(actions.device.hasDeviceUrl)
|
|
||||||
capitano.command(actions.device.register)
|
|
||||||
capitano.command(actions.device.move)
|
|
||||||
capitano.command(actions.device.osUpdate)
|
|
||||||
capitano.command(actions.device.info)
|
|
||||||
|
|
||||||
# ---------- Notes Module ----------
|
|
||||||
capitano.command(actions.notes.set)
|
|
||||||
|
|
||||||
# ---------- Keys Module ----------
|
|
||||||
capitano.command(actions.keys.list)
|
|
||||||
capitano.command(actions.keys.add)
|
|
||||||
capitano.command(actions.keys.info)
|
|
||||||
capitano.command(actions.keys.remove)
|
|
||||||
|
|
||||||
# ---------- Env Module ----------
|
|
||||||
capitano.command(actions.env.list)
|
|
||||||
capitano.command(actions.env.rename)
|
|
||||||
capitano.command(actions.env.remove)
|
|
||||||
|
|
||||||
# ---------- Tags Module ----------
|
|
||||||
capitano.command(actions.tags.list)
|
|
||||||
capitano.command(actions.tags.set)
|
|
||||||
capitano.command(actions.tags.remove)
|
|
||||||
|
|
||||||
# ---------- OS Module ----------
|
|
||||||
capitano.command(actions.os.versions)
|
|
||||||
capitano.command(actions.os.download)
|
|
||||||
capitano.command(actions.os.buildConfig)
|
|
||||||
capitano.command(actions.os.configure)
|
|
||||||
capitano.command(actions.os.initialize)
|
|
||||||
|
|
||||||
# ---------- Config Module ----------
|
|
||||||
capitano.command(actions.config.read)
|
|
||||||
capitano.command(actions.config.write)
|
|
||||||
capitano.command(actions.config.inject)
|
|
||||||
capitano.command(actions.config.reconfigure)
|
|
||||||
capitano.command(actions.config.generate)
|
|
||||||
|
|
||||||
# ---------- Settings Module ----------
|
|
||||||
capitano.command(actions.settings.list)
|
|
||||||
|
|
||||||
# ---------- Logs Module ----------
|
|
||||||
capitano.command(actions.logs.logs)
|
|
||||||
|
|
||||||
# ---------- Tunnel Module ----------
|
|
||||||
capitano.command(actions.tunnel.tunnel)
|
|
||||||
|
|
||||||
# ---------- Preload Module ----------
|
|
||||||
capitano.command(actions.preload)
|
|
||||||
|
|
||||||
# ---------- SSH Module ----------
|
|
||||||
capitano.command(actions.ssh.ssh)
|
|
||||||
|
|
||||||
# ---------- Local balenaOS Module ----------
|
|
||||||
capitano.command(actions.local.configure)
|
|
||||||
capitano.command(actions.local.flash)
|
|
||||||
capitano.command(actions.scan)
|
|
||||||
|
|
||||||
# ---------- Public utils ----------
|
|
||||||
capitano.command(actions.util.availableDrives)
|
|
||||||
|
|
||||||
# ---------- Internal utils ----------
|
|
||||||
capitano.command(actions.internal.osInit)
|
|
||||||
capitano.command(actions.internal.scanDevices)
|
|
||||||
capitano.command(actions.internal.sudo)
|
|
||||||
|
|
||||||
#------------ Local build and deploy -------
|
|
||||||
capitano.command(actions.build)
|
|
||||||
capitano.command(actions.deploy)
|
|
||||||
|
|
||||||
#------------ Push/remote builds -------
|
|
||||||
capitano.command(actions.push.push)
|
|
||||||
|
|
||||||
#------------ Join/Leave -------
|
|
||||||
capitano.command(actions.join.join)
|
|
||||||
capitano.command(actions.leave.leave)
|
|
||||||
|
|
||||||
cli = capitano.parse(process.argv)
|
|
||||||
runCommand = ->
|
|
||||||
capitanoExecuteAsync = Promise.promisify(capitano.execute)
|
|
||||||
if cli.global?.help
|
|
||||||
capitanoExecuteAsync(command: "help #{cli.command ? ''}")
|
|
||||||
else
|
|
||||||
capitanoExecuteAsync(cli)
|
|
||||||
|
|
||||||
Promise.all([events.trackCommand(cli), runCommand()])
|
|
||||||
.catch(require('./errors').handleError)
|
|
@ -1,107 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2019 Balena Ltd.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sentry.io setup
|
|
||||||
* @see https://docs.sentry.io/clients/node/
|
|
||||||
*/
|
|
||||||
function setupRaven() {
|
|
||||||
const Raven = require('raven');
|
|
||||||
Raven.disableConsoleAlerts();
|
|
||||||
Raven.config(require('./config').sentryDsn, {
|
|
||||||
captureUnhandledRejections: true,
|
|
||||||
autoBreadcrumbs: true,
|
|
||||||
release: require('../package.json').version,
|
|
||||||
}).install(function(_logged: any, error: Error) {
|
|
||||||
console.error(error);
|
|
||||||
return process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
Raven.setContext({
|
|
||||||
extra: {
|
|
||||||
args: process.argv,
|
|
||||||
node_version: process.version,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkNodeVersion() {
|
|
||||||
const validNodeVersions = require('../package.json').engines.node;
|
|
||||||
if (!require('semver').satisfies(process.version, validNodeVersions)) {
|
|
||||||
const { stripIndent } = require('common-tags');
|
|
||||||
console.warn(stripIndent`
|
|
||||||
------------------------------------------------------------------------------
|
|
||||||
Warning: Node version "${
|
|
||||||
process.version
|
|
||||||
}" does not match required versions "${validNodeVersions}".
|
|
||||||
This may cause unexpected behaviour. To upgrade Node, visit:
|
|
||||||
https://nodejs.org/en/download/
|
|
||||||
------------------------------------------------------------------------------
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupGlobalHttpProxy() {
|
|
||||||
// Doing this before requiring any other modules,
|
|
||||||
// including the 'balena-sdk', to prevent any module from reading the http proxy config
|
|
||||||
// before us
|
|
||||||
const globalTunnel = require('global-tunnel-ng');
|
|
||||||
const settings = require('balena-settings-client');
|
|
||||||
let proxy;
|
|
||||||
try {
|
|
||||||
proxy = settings.get('proxy') || null;
|
|
||||||
} catch (error1) {
|
|
||||||
proxy = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init the tunnel even if the proxy is not configured
|
|
||||||
// because it can also get the proxy from the http(s)_proxy env var
|
|
||||||
// If that is not set as well the initialize will do nothing
|
|
||||||
globalTunnel.initialize(proxy);
|
|
||||||
|
|
||||||
// TODO: make this a feature of capitano https://github.com/balena-io/capitano/issues/48
|
|
||||||
(global as any).PROXY_CONFIG = globalTunnel.proxyConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupBalenaSdkSharedOptions() {
|
|
||||||
// We don't yet use balena-sdk directly everywhere, but we set up shared
|
|
||||||
// options correctly so we can do safely in submodules
|
|
||||||
const BalenaSdk = require('balena-sdk');
|
|
||||||
const settings = require('balena-settings-client');
|
|
||||||
BalenaSdk.setSharedOptions({
|
|
||||||
apiUrl: settings.get('apiUrl'),
|
|
||||||
imageMakerUrl: settings.get('imageMakerUrl'),
|
|
||||||
dataDirectory: settings.get('dataDirectory'),
|
|
||||||
retries: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function globalInit() {
|
|
||||||
setupRaven();
|
|
||||||
checkNodeVersion();
|
|
||||||
setupGlobalHttpProxy();
|
|
||||||
setupBalenaSdkSharedOptions();
|
|
||||||
|
|
||||||
// Assign bluebird as the global promise library.
|
|
||||||
// stream-to-promise will produce native promises if not for this module,
|
|
||||||
// which is likely to lead to errors as much of the CLI coffeescript code
|
|
||||||
// expects bluebird promises.
|
|
||||||
require('any-promise/register/bluebird');
|
|
||||||
|
|
||||||
// check for CLI updates once a day
|
|
||||||
require('./utils/update').notify();
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2019 Balena Ltd.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Main } from '@oclif/command';
|
|
||||||
import { ExitError } from '@oclif/errors';
|
|
||||||
|
|
||||||
import { handleError } from './errors';
|
|
||||||
|
|
||||||
class CustomMain extends Main {
|
|
||||||
protected _helpOverride(): boolean {
|
|
||||||
// Disable oclif's default handler for the 'version' command
|
|
||||||
if (['-v', '--version', 'version'].includes(this.argv[0])) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
return super._helpOverride();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* oclif CLI entrypoint
|
|
||||||
*/
|
|
||||||
export function run(argv: string[]) {
|
|
||||||
CustomMain.run(argv.slice(2)).then(
|
|
||||||
require('@oclif/command/flush'),
|
|
||||||
(error: Error) => {
|
|
||||||
// oclif sometimes exits with ExitError code 0 (not an error)
|
|
||||||
if (error instanceof ExitError && error.oclif.exit === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleError(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
248
lib/app.ts
248
lib/app.ts
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2019 Balena Ltd.
|
* Copyright 2019-2020 Balena Ltd.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -14,145 +14,139 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
|
|
||||||
import { exitWithExpectedError } from './utils/patterns';
|
import * as packageJSON from '../package.json';
|
||||||
|
import { CliSettings } from './utils/bootstrap';
|
||||||
|
import { onceAsync, stripIndent } from './utils/lazy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple command-line pre-parsing to choose between oclif or Capitano.
|
* Sentry.io setup
|
||||||
* @param argv process.argv
|
* @see https://docs.sentry.io/error-reporting/quickstart/?platform=node
|
||||||
*/
|
*/
|
||||||
function routeCliFramework(argv: string[]): void {
|
export const setupSentry = onceAsync(async () => {
|
||||||
if (process.env.DEBUG) {
|
const config = await import('./config');
|
||||||
console.log(
|
const Sentry = await import('@sentry/node');
|
||||||
`Debug: original argv0="${process.argv0}" argv=[${argv}] length=${
|
Sentry.init({
|
||||||
argv.length
|
dsn: config.sentryDsn,
|
||||||
}`,
|
release: packageJSON.version,
|
||||||
);
|
});
|
||||||
|
Sentry.configureScope((scope) => {
|
||||||
|
scope.setExtras({
|
||||||
|
is_pkg: !!(process as any).pkg,
|
||||||
|
node_version: process.version,
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Sentry.getCurrentHub();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkNodeVersion() {
|
||||||
|
const validNodeVersions = packageJSON.engines.node;
|
||||||
|
if (!(await import('semver')).satisfies(process.version, validNodeVersions)) {
|
||||||
|
console.warn(stripIndent`
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
Warning: Node version "${process.version}" does not match required versions "${validNodeVersions}".
|
||||||
|
This may cause unexpected behavior. To upgrade Node, visit:
|
||||||
|
https://nodejs.org/en/download/
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
const cmdSlice = argv.slice(2);
|
}
|
||||||
|
|
||||||
// Look for commands that have been deleted, to print a notice
|
/** Setup balena-sdk options that are shared with imported packages */
|
||||||
checkDeletedCommand(cmdSlice);
|
function setupBalenaSdkSharedOptions(settings: CliSettings) {
|
||||||
|
const BalenaSdk = require('balena-sdk') as typeof import('balena-sdk');
|
||||||
|
BalenaSdk.setSharedOptions({
|
||||||
|
apiUrl: settings.get<string>('apiUrl'),
|
||||||
|
dataDirectory: settings.get<string>('dataDirectory'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (cmdSlice.length > 0) {
|
/**
|
||||||
// convert 'balena --version' or 'balena -v' to 'balena version'
|
* Addresses the console warning:
|
||||||
if (['--version', '-v'].includes(cmdSlice[0])) {
|
* (node:49500) MaxListenersExceededWarning: Possible EventEmitter memory
|
||||||
cmdSlice[0] = 'version';
|
* leak detected. 11 error listeners added. Use emitter.setMaxListeners() to
|
||||||
}
|
* increase limit
|
||||||
// convert 'balena --help' or 'balena -h' to 'balena help'
|
*/
|
||||||
else if (['--help', '-h'].includes(cmdSlice[0])) {
|
export function setMaxListeners(maxListeners: number) {
|
||||||
cmdSlice[0] = 'help';
|
require('events').EventEmitter.defaultMaxListeners = maxListeners;
|
||||||
}
|
}
|
||||||
// convert e.g. 'balena help env add' to 'balena env add --help'
|
|
||||||
if (cmdSlice.length > 1 && cmdSlice[0] === 'help') {
|
|
||||||
cmdSlice.shift();
|
|
||||||
cmdSlice.push('--help');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isOclif, isTopic] = isOclifCommand(cmdSlice);
|
/** Selected CLI initialization steps */
|
||||||
if (isOclif) {
|
async function init() {
|
||||||
if (isTopic) {
|
if (process.env.BALENARC_NO_SENTRY) {
|
||||||
// convert space-separated commands to oclif's topic:command syntax
|
console.error(`WARN: disabling Sentry.io error reporting`);
|
||||||
argv = [
|
|
||||||
argv[0],
|
|
||||||
argv[1],
|
|
||||||
cmdSlice[0] + ':' + cmdSlice[1],
|
|
||||||
...cmdSlice.slice(2),
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
argv = [argv[0], argv[1], ...cmdSlice];
|
|
||||||
}
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log(`Debug: new argv=[${argv}] length=${argv.length}`);
|
|
||||||
}
|
|
||||||
require('./app-oclif').run(argv);
|
|
||||||
} else {
|
} else {
|
||||||
require('./app-capitano');
|
await setupSentry();
|
||||||
}
|
}
|
||||||
|
checkNodeVersion();
|
||||||
|
|
||||||
|
const settings = new CliSettings();
|
||||||
|
|
||||||
|
// Proxy setup should be done early on, before loading balena-sdk
|
||||||
|
await (await import('./utils/proxy')).setupGlobalHttpProxy(settings);
|
||||||
|
|
||||||
|
setupBalenaSdkSharedOptions(settings);
|
||||||
|
|
||||||
|
// check for CLI updates once a day
|
||||||
|
(await import('./utils/update')).notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Execute the oclif parser and the CLI command. */
|
||||||
*
|
async function oclifRun(
|
||||||
* @param argvSlice process.argv.slice(2)
|
command: string[],
|
||||||
*/
|
options: import('./preparser').AppOptions,
|
||||||
function checkDeletedCommand(argvSlice: string[]): void {
|
) {
|
||||||
if (argvSlice[0] === 'help') {
|
const { CustomMain } = await import('./utils/oclif-utils');
|
||||||
argvSlice = argvSlice.slice(1);
|
const runPromise = CustomMain.run(command).then(
|
||||||
}
|
() => {
|
||||||
function replaced(
|
if (!options.noFlush) {
|
||||||
oldCmd: string,
|
return require('@oclif/command/flush');
|
||||||
alternative: string,
|
|
||||||
version: string,
|
|
||||||
verb = 'replaced',
|
|
||||||
) {
|
|
||||||
exitWithExpectedError(stripIndent`
|
|
||||||
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
|
|
||||||
Please use "balena ${alternative}" instead.
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
function removed(oldCmd: string, alternative: string, version: string) {
|
|
||||||
let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`;
|
|
||||||
if (alternative) {
|
|
||||||
msg = [msg, alternative].join('\n');
|
|
||||||
}
|
|
||||||
exitWithExpectedError(msg);
|
|
||||||
}
|
|
||||||
const stopAlternative =
|
|
||||||
'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.';
|
|
||||||
const cmds: { [cmd: string]: [(...args: any) => void, ...string[]] } = {
|
|
||||||
sync: [replaced, 'push', 'v11.0.0', 'removed'],
|
|
||||||
'local logs': [replaced, 'logs', 'v11.0.0'],
|
|
||||||
'local push': [replaced, 'push', 'v11.0.0'],
|
|
||||||
'local scan': [replaced, 'scan', 'v11.0.0'],
|
|
||||||
'local ssh': [replaced, 'ssh', 'v11.0.0'],
|
|
||||||
'local stop': [removed, stopAlternative, 'v11.0.0'],
|
|
||||||
};
|
|
||||||
let cmd: string | undefined;
|
|
||||||
if (argvSlice.length > 1) {
|
|
||||||
cmd = [argvSlice[0], argvSlice[1]].join(' ');
|
|
||||||
} else if (argvSlice.length > 0) {
|
|
||||||
cmd = argvSlice[0];
|
|
||||||
}
|
|
||||||
if (cmd && Object.getOwnPropertyNames(cmds).includes(cmd)) {
|
|
||||||
cmds[cmd][0](cmd, ...cmds[cmd].slice(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the CLI command has been converted from Capitano to oclif.
|
|
||||||
* Return an array of two boolean values:
|
|
||||||
* r[0] : whether the CLI command is implemented with oclif
|
|
||||||
* r[1] : if r[0] is true, whether the CLI command is implemented with
|
|
||||||
* oclif "topics" (colon-separated subcommands like `env:add`)
|
|
||||||
* @param argvSlice process.argv.slice(2)
|
|
||||||
*/
|
|
||||||
function isOclifCommand(argvSlice: string[]): [boolean, boolean] {
|
|
||||||
// Look for commands that have been transitioned to oclif
|
|
||||||
if (argvSlice.length > 0) {
|
|
||||||
// balena version
|
|
||||||
if (argvSlice[0] === 'version') {
|
|
||||||
return [true, false];
|
|
||||||
}
|
|
||||||
if (argvSlice.length > 1) {
|
|
||||||
// balena env add
|
|
||||||
if (argvSlice[0] === 'env' && argvSlice[1] === 'add') {
|
|
||||||
return [true, true];
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
(error) => {
|
||||||
return [false, false];
|
// oclif sometimes exits with ExitError code 0 (not an error)
|
||||||
|
// (Avoid `error instanceof ExitError` here for the reasons explained
|
||||||
|
// in the CONTRIBUTING.md file regarding the `instanceof` operator.)
|
||||||
|
if (error.oclif?.exit === 0) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { trackPromise } = await import('./hooks/prerun/track');
|
||||||
|
await Promise.all([trackPromise, runPromise]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** CLI entrypoint. Called by the `bin/balena` and `bin/balena-dev` scripts. */
|
||||||
* CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which
|
export async function run(
|
||||||
* call this function.
|
cliArgs = process.argv,
|
||||||
*/
|
options: import('./preparser').AppOptions = {},
|
||||||
export function run(): void {
|
) {
|
||||||
// globalInit() must be called very early on (before other imports) because
|
try {
|
||||||
// it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk
|
const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
|
||||||
// shared options, and performs node version requirement checks.
|
normalizeEnvVars();
|
||||||
require('./app-common').globalInit();
|
|
||||||
routeCliFramework(process.argv);
|
// The 'pkgExec' special/internal command provides a Node.js interpreter
|
||||||
|
// for use of the standalone zip package. See pkgExec function.
|
||||||
|
if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') {
|
||||||
|
return pkgExec(cliArgs[3], cliArgs.slice(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
await init();
|
||||||
|
|
||||||
|
const { preparseArgs, checkDeletedCommand } = await import('./preparser');
|
||||||
|
|
||||||
|
// Look for commands that have been removed and if so, exit with a notice
|
||||||
|
checkDeletedCommand(cliArgs.slice(2));
|
||||||
|
|
||||||
|
const args = await preparseArgs(cliArgs);
|
||||||
|
await oclifRun(args, options);
|
||||||
|
} catch (err) {
|
||||||
|
await (await import('./errors')).handleError(err);
|
||||||
|
} finally {
|
||||||
|
// Windows fix: reading from stdin prevents the process from exiting
|
||||||
|
process.stdin.pause();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,63 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
###*
|
|
||||||
# @module auth
|
|
||||||
###
|
|
||||||
|
|
||||||
open = require('opn')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
server = require('./server')
|
|
||||||
utils = require('./utils')
|
|
||||||
|
|
||||||
###*
|
|
||||||
# @summary Login to the balena CLI using the web dashboard
|
|
||||||
# @function
|
|
||||||
# @public
|
|
||||||
#
|
|
||||||
# @description
|
|
||||||
# This function opens the user's default browser and points it
|
|
||||||
# to the balena dashboard where the session token exchange will
|
|
||||||
# take place.
|
|
||||||
#
|
|
||||||
# Once the the token is retrieved, it's automatically persisted.
|
|
||||||
#
|
|
||||||
# @fulfil {String} - session token
|
|
||||||
# @returns {Promise}
|
|
||||||
#
|
|
||||||
# @example
|
|
||||||
# auth.login().then (sessionToken) ->
|
|
||||||
# console.log('I\'m logged in!')
|
|
||||||
# console.log("My session token is: #{sessionToken}")
|
|
||||||
###
|
|
||||||
exports.login = ->
|
|
||||||
options =
|
|
||||||
port: 8989
|
|
||||||
path: '/auth'
|
|
||||||
|
|
||||||
# Needs to be 127.0.0.1 not localhost, because the ip only is whitelisted
|
|
||||||
# from mixed content warnings (as the target of a form in the result page)
|
|
||||||
callbackUrl = "http://127.0.0.1:#{options.port}#{options.path}"
|
|
||||||
return utils.getDashboardLoginURL(callbackUrl).then (loginUrl) ->
|
|
||||||
|
|
||||||
# Leave a bit of time for the
|
|
||||||
# server to get up and runing
|
|
||||||
setTimeout ->
|
|
||||||
open(loginUrl, { wait: false })
|
|
||||||
, 1000
|
|
||||||
|
|
||||||
return server.awaitForToken(options)
|
|
||||||
.tap(balena.auth.loginWithToken)
|
|
66
lib/auth/index.ts
Normal file
66
lib/auth/index.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2016-2020 Balena
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getBalenaSdk } from '../utils/lazy';
|
||||||
|
import { LoginServer } from './server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @module auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Login to the balena CLI using the web dashboard
|
||||||
|
* @function
|
||||||
|
* @public
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* This function opens the user's default browser and points it
|
||||||
|
* to the balena dashboard where the session token exchange will
|
||||||
|
* take place.
|
||||||
|
*
|
||||||
|
* Once the the token is retrieved, it's automatically persisted.
|
||||||
|
*
|
||||||
|
* @fulfil {String} - session token
|
||||||
|
* @returns {Promise}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* auth.login().then (sessionToken) ->
|
||||||
|
* console.log('I\'m logged in!')
|
||||||
|
* console.log("My session token is: #{sessionToken}")
|
||||||
|
*/
|
||||||
|
export async function login({ host = '127.0.0.1', port = 0 }) {
|
||||||
|
const utils = await import('./utils');
|
||||||
|
|
||||||
|
const loginServer = new LoginServer();
|
||||||
|
const {
|
||||||
|
host: actualHost,
|
||||||
|
port: actualPort,
|
||||||
|
urlPath,
|
||||||
|
} = await loginServer.start({ host, port });
|
||||||
|
|
||||||
|
const callbackUrl = `http://${actualHost}:${actualPort}${urlPath}`;
|
||||||
|
const loginUrl = await utils.getDashboardLoginURL(callbackUrl);
|
||||||
|
|
||||||
|
console.info(`Opening web browser for URL:\n${loginUrl}`);
|
||||||
|
const open = await import('open');
|
||||||
|
open(loginUrl, { wait: false });
|
||||||
|
|
||||||
|
const balena = getBalenaSdk();
|
||||||
|
const token = await loginServer.awaitForToken();
|
||||||
|
await balena.auth.loginWithToken(token);
|
||||||
|
loginServer.shutdown();
|
||||||
|
return token;
|
||||||
|
}
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
<title>Balena CLI - Error</title>
|
<title>balena CLI - Error</title>
|
||||||
<meta name="description" content="">
|
<meta name="description" content="">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>
|
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>
|
||||||
@ -12,7 +12,8 @@
|
|||||||
<div class="center">
|
<div class="center">
|
||||||
<img class="icon" src="./static/images/sad.png" inline>
|
<img class="icon" src="./static/images/sad.png" inline>
|
||||||
<h1>Something went wrong</h1>
|
<h1>Something went wrong</h1>
|
||||||
<p>You couldn't login to the balena CLI for some reason</p>
|
<br>
|
||||||
|
<p>The balena CLI login was not successful.</p>
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://forums.balena.io/" class="button danger">Get help in our forums</a>
|
<a href="https://forums.balena.io/" class="button danger">Get help in our forums</a>
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
<title>Balena CLI - Success</title>
|
<title>balena CLI - Success</title>
|
||||||
<meta name="description" content="">
|
<meta name="description" content="">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>
|
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>
|
||||||
@ -12,10 +12,9 @@
|
|||||||
<div class="center">
|
<div class="center">
|
||||||
<img class="icon" src="./static/images/happy.png" inline>
|
<img class="icon" src="./static/images/happy.png" inline>
|
||||||
<h1>Success!</h1>
|
<h1>Success!</h1>
|
||||||
<p>You successfully logged in the balena CLI</p>
|
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<p>The balena CLI login was successful.</p>
|
||||||
<a href="<%= dashboardUrl %>" class="button normal">Go to the dashboard</a>
|
<p>You may now close this page and return to the command prompt.</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,101 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
express = require('express')
|
|
||||||
path = require('path')
|
|
||||||
bodyParser = require('body-parser')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
utils = require('./utils')
|
|
||||||
|
|
||||||
createServer = ({ port, isDev } = {}) ->
|
|
||||||
app = express()
|
|
||||||
app.use bodyParser.urlencoded
|
|
||||||
extended: true
|
|
||||||
|
|
||||||
app.set('view engine', 'ejs')
|
|
||||||
app.set('views', path.join(__dirname, 'pages'))
|
|
||||||
|
|
||||||
if isDev
|
|
||||||
app.use(express.static(path.join(__dirname, 'pages', 'static')))
|
|
||||||
|
|
||||||
server = app.listen(port)
|
|
||||||
|
|
||||||
return { app, server }
|
|
||||||
|
|
||||||
###*
|
|
||||||
# @summary Await for token
|
|
||||||
# @function
|
|
||||||
# @protected
|
|
||||||
#
|
|
||||||
# @param {Object} options - options
|
|
||||||
# @param {String} options.path - callback path
|
|
||||||
# @param {Number} options.port - http port
|
|
||||||
#
|
|
||||||
# @example
|
|
||||||
# server.awaitForToken
|
|
||||||
# path: '/auth'
|
|
||||||
# port: 9001
|
|
||||||
# .then (token) ->
|
|
||||||
# console.log(token)
|
|
||||||
###
|
|
||||||
exports.awaitForToken = (options) ->
|
|
||||||
{ app, server } = createServer(port: options.port)
|
|
||||||
|
|
||||||
return new Promise (resolve, reject) ->
|
|
||||||
closeServer = (errorMessage, successPayload) ->
|
|
||||||
server.close ->
|
|
||||||
if errorMessage
|
|
||||||
reject(new Error(errorMessage))
|
|
||||||
return
|
|
||||||
|
|
||||||
resolve(successPayload)
|
|
||||||
|
|
||||||
renderAndDone = ({ request, response, viewName, errorMessage, statusCode, token }) ->
|
|
||||||
return getContext(viewName)
|
|
||||||
.then (context) ->
|
|
||||||
response.status(statusCode || 200).render(viewName, context)
|
|
||||||
request.connection.destroy()
|
|
||||||
closeServer(errorMessage, token)
|
|
||||||
|
|
||||||
app.post options.path, (request, response) ->
|
|
||||||
token = request.body.token?.trim()
|
|
||||||
|
|
||||||
Promise.try ->
|
|
||||||
if not token
|
|
||||||
throw new Error('No token')
|
|
||||||
return utils.loginIfTokenValid(token)
|
|
||||||
.tap (loggedIn) ->
|
|
||||||
if not loggedIn
|
|
||||||
throw new Error('Invalid token')
|
|
||||||
.then ->
|
|
||||||
renderAndDone({ request, response, viewName: 'success', token })
|
|
||||||
.catch (error) ->
|
|
||||||
renderAndDone({
|
|
||||||
request, response, viewName: 'error',
|
|
||||||
statusCode: 401, errorMessage: error.message
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use (request, response) ->
|
|
||||||
response.status(404).send('Not found')
|
|
||||||
closeServer('Unknown path or verb')
|
|
||||||
|
|
||||||
exports.getContext = getContext = (viewName) ->
|
|
||||||
if viewName is 'success'
|
|
||||||
return Promise.props
|
|
||||||
dashboardUrl: balena.settings.get('dashboardUrl')
|
|
||||||
|
|
||||||
return Promise.resolve({})
|
|
148
lib/auth/server.ts
Normal file
148
lib/auth/server.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2016-2020 Balena
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as bodyParser from 'body-parser';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import * as express from 'express';
|
||||||
|
import type { Socket } from 'net';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import * as utils from './utils';
|
||||||
|
import { ExpectedError } from '../errors';
|
||||||
|
|
||||||
|
export class LoginServer extends EventEmitter {
|
||||||
|
protected expressApp: express.Express;
|
||||||
|
protected server: import('net').Server;
|
||||||
|
protected serverSockets: Socket[] = [];
|
||||||
|
protected firstError: Error;
|
||||||
|
protected token: string;
|
||||||
|
|
||||||
|
public readonly loginPath = '/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the HTTP server, listening on the given IP address and port number.
|
||||||
|
* If the port number is 0, the OS will allocate a free port number.
|
||||||
|
*/
|
||||||
|
public async start({ host = '127.0.0.1', port = 0 } = {}): Promise<{
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
urlPath: string;
|
||||||
|
}> {
|
||||||
|
this.once('error', (err: Error) => {
|
||||||
|
this.firstError = err;
|
||||||
|
});
|
||||||
|
this.on('token', (token: string) => {
|
||||||
|
this.token = token;
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = (this.expressApp = express());
|
||||||
|
app.use(
|
||||||
|
bodyParser.urlencoded({
|
||||||
|
extended: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
app.set('views', path.join(__dirname, 'pages'));
|
||||||
|
|
||||||
|
this.server = await new Promise<import('net').Server>((resolve, reject) => {
|
||||||
|
const server = app.listen(port, host, (err: Error) => {
|
||||||
|
if (err) {
|
||||||
|
this.emit('error', err);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.on('connection', (socket) => this.serverSockets.push(socket));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.expressApp.post(this.loginPath, async (request, response) => {
|
||||||
|
this.server.close(); // stop listening for new connections
|
||||||
|
try {
|
||||||
|
const token = request.body.token?.trim();
|
||||||
|
if (!token) {
|
||||||
|
throw new ExpectedError('No token');
|
||||||
|
}
|
||||||
|
const loggedIn = await utils.loginIfTokenValid(token);
|
||||||
|
if (!loggedIn) {
|
||||||
|
throw new ExpectedError('Invalid token');
|
||||||
|
}
|
||||||
|
this.emit('token', token);
|
||||||
|
response.status(200).render('success');
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error);
|
||||||
|
response.status(401).render('error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.expressApp.use((_request, response) => {
|
||||||
|
this.server.close(); // stop listening for new connections
|
||||||
|
this.emit('error', new Error('Unknown path or verb'));
|
||||||
|
response.status(404).send('Not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAddress(): { host: string; port: number; urlPath: string } {
|
||||||
|
const info = this.server.address() as import('net').AddressInfo;
|
||||||
|
return {
|
||||||
|
host: info.address,
|
||||||
|
port: info.port,
|
||||||
|
urlPath: this.loginPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shut the server down.
|
||||||
|
* Call this method to avoid the process hanging in some situations.
|
||||||
|
*/
|
||||||
|
public shutdown() {
|
||||||
|
// A Node.js `http.server` instance prevents the process from exiting for up to
|
||||||
|
// 2 minutes (by default) if a client keeps a HTTP connection open, and regardless
|
||||||
|
// of whether `server.close()` was called: the `server.close(callback)` callback
|
||||||
|
// takes just as long to complete. Setting `server.timeout` to some value like
|
||||||
|
// 3 seconds works, but then the CLI process hangs for "only" 3 seconds. Reducing
|
||||||
|
// the timeout to 1 second may cause authentication failure if the laptop or CI
|
||||||
|
// server are slow for any reason. The only reliable way around it seems to be to
|
||||||
|
// explicitly unref the sockets, so the event loop stops waiting for it. See:
|
||||||
|
// https://github.com/nodejs/node/issues/2642
|
||||||
|
// https://github.com/nodejs/node-v0.x-archive/issues/9066
|
||||||
|
//
|
||||||
|
this.serverSockets.forEach((s) => s.unref());
|
||||||
|
this.serverSockets.splice(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Await for the user to complete login through a web browser.
|
||||||
|
* Resolve to the authentication token string.
|
||||||
|
*
|
||||||
|
* @return Promise that resolves to the authentication token string
|
||||||
|
*/
|
||||||
|
public async awaitForToken(): Promise<string> {
|
||||||
|
if (this.firstError) {
|
||||||
|
throw this.firstError;
|
||||||
|
}
|
||||||
|
if (this.token) {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
this.on('error', reject);
|
||||||
|
this.on('token', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,80 +0,0 @@
|
|||||||
###
|
|
||||||
Copyright 2016 Balena
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
###
|
|
||||||
|
|
||||||
balena = require('balena-sdk').fromSharedOptions()
|
|
||||||
_ = require('lodash')
|
|
||||||
url = require('url')
|
|
||||||
Promise = require('bluebird')
|
|
||||||
|
|
||||||
###*
|
|
||||||
# @summary Get dashboard CLI login URL
|
|
||||||
# @function
|
|
||||||
# @protected
|
|
||||||
#
|
|
||||||
# @param {String} callbackUrl - callback url
|
|
||||||
# @fulfil {String} - dashboard login url
|
|
||||||
# @returns {Promise}
|
|
||||||
#
|
|
||||||
# @example
|
|
||||||
# utils.getDashboardLoginURL('http://127.0.0.1:3000').then (url) ->
|
|
||||||
# console.log(url)
|
|
||||||
###
|
|
||||||
exports.getDashboardLoginURL = (callbackUrl) ->
|
|
||||||
|
|
||||||
# Encode percentages signs from the escaped url
|
|
||||||
# characters to avoid angular getting confused.
|
|
||||||
callbackUrl = encodeURIComponent(callbackUrl).replace(/%/g, '%25')
|
|
||||||
|
|
||||||
balena.settings.get('dashboardUrl').then (dashboardUrl) ->
|
|
||||||
return url.resolve(dashboardUrl, "/login/cli/#{callbackUrl}")
|
|
||||||
|
|
||||||
###*
|
|
||||||
# @summary Log in using a token, but only if the token is valid
|
|
||||||
# @function
|
|
||||||
# @protected
|
|
||||||
#
|
|
||||||
# @description
|
|
||||||
# This function checks that the token is not only well-structured
|
|
||||||
# but that it also authenticates with the server successfully.
|
|
||||||
#
|
|
||||||
# If authenticated, the token is persisted, if not then the previous
|
|
||||||
# login state is restored.
|
|
||||||
#
|
|
||||||
# @param {String} token - session token or api key
|
|
||||||
# @fulfil {Boolean} - whether the login was successful or not
|
|
||||||
# @returns {Promise}
|
|
||||||
#
|
|
||||||
# utils.loginIfTokenValid('...').then (loggedIn) ->
|
|
||||||
# if loggedIn
|
|
||||||
# console.log('Token is valid!')
|
|
||||||
###
|
|
||||||
exports.loginIfTokenValid = (token) ->
|
|
||||||
if not token? or _.isEmpty(token.trim())
|
|
||||||
return Promise.resolve(false)
|
|
||||||
|
|
||||||
return balena.auth.getToken()
|
|
||||||
.catchReturn(undefined)
|
|
||||||
.then (currentToken) ->
|
|
||||||
balena.auth.loginWithToken(token)
|
|
||||||
.return(token)
|
|
||||||
.then(balena.auth.isLoggedIn)
|
|
||||||
.tap (isLoggedIn) ->
|
|
||||||
return if isLoggedIn
|
|
||||||
|
|
||||||
if currentToken?
|
|
||||||
return balena.auth.loginWithToken(currentToken)
|
|
||||||
else
|
|
||||||
return balena.auth.logout()
|
|
89
lib/auth/utils.ts
Normal file
89
lib/auth/utils.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2016 Balena
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
import * as url from 'url';
|
||||||
|
import { getBalenaSdk } from '../utils/lazy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get dashboard CLI login URL
|
||||||
|
* @function
|
||||||
|
* @protected
|
||||||
|
*
|
||||||
|
* @param {String} callbackUrl - callback url
|
||||||
|
* @fulfil {String} - dashboard login url
|
||||||
|
* @returns {Promise}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* utils.getDashboardLoginURL('http://127.0.0.1:3000').then (url) ->
|
||||||
|
* console.log(url)
|
||||||
|
*/
|
||||||
|
export const getDashboardLoginURL = (callbackUrl: string) => {
|
||||||
|
// Encode percentages signs from the escaped url
|
||||||
|
// characters to avoid angular getting confused.
|
||||||
|
callbackUrl = encodeURIComponent(callbackUrl).replace(/%/g, '%25');
|
||||||
|
|
||||||
|
return getBalenaSdk()
|
||||||
|
.settings.get('dashboardUrl')
|
||||||
|
.then((dashboardUrl) =>
|
||||||
|
url.resolve(dashboardUrl, `/login/cli/${callbackUrl}`),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Log in using a token, but only if the token is valid
|
||||||
|
* @function
|
||||||
|
* @protected
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* This function checks that the token is not only well-structured
|
||||||
|
* but that it also authenticates with the server successfully.
|
||||||
|
*
|
||||||
|
* If authenticated, the token is persisted, if not then the previous
|
||||||
|
* login state is restored.
|
||||||
|
*
|
||||||
|
* @param {String} token - session token or api key
|
||||||
|
* @fulfil {Boolean} - whether the login was successful or not
|
||||||
|
* @returns {Promise}
|
||||||
|
*
|
||||||
|
* utils.loginIfTokenValid('...').then (loggedIn) ->
|
||||||
|
* if loggedIn
|
||||||
|
* console.log('Token is valid!')
|
||||||
|
*/
|
||||||
|
export const loginIfTokenValid = async (token: string): Promise<boolean> => {
|
||||||
|
if (_.isEmpty(token?.trim())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const balena = getBalenaSdk();
|
||||||
|
|
||||||
|
let currentToken;
|
||||||
|
try {
|
||||||
|
currentToken = await balena.auth.getToken();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
await balena.auth.loginWithToken(token);
|
||||||
|
const isLoggedIn = await balena.auth.isLoggedIn();
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
if (currentToken != null) {
|
||||||
|
await balena.auth.loginWithToken(currentToken);
|
||||||
|
} else {
|
||||||
|
await balena.auth.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isLoggedIn;
|
||||||
|
};
|
132
lib/command.ts
Normal file
132
lib/command.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Command from '@oclif/command';
|
||||||
|
import { InsufficientPrivilegesError } from './errors';
|
||||||
|
|
||||||
|
export default abstract class BalenaCommand extends Command {
|
||||||
|
/**
|
||||||
|
* When set to true, command will be listed in `help`,
|
||||||
|
* otherwise listed in `help --verbose` with secondary commands.
|
||||||
|
*/
|
||||||
|
public static primary = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require elevated privileges to run.
|
||||||
|
* When set to true, command will exit with an error
|
||||||
|
* if executed without root on Mac/Linux
|
||||||
|
* or if executed by non-Administrator on Windows.
|
||||||
|
*/
|
||||||
|
public static root = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require authentication to run.
|
||||||
|
* When set to true, command will exit with an error
|
||||||
|
* if user is not already logged in.
|
||||||
|
*/
|
||||||
|
public static authenticated = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept piped input.
|
||||||
|
* When set to true, command will read from stdin during init
|
||||||
|
* and make contents available on member `stdin`.
|
||||||
|
*/
|
||||||
|
public static readStdin = false;
|
||||||
|
|
||||||
|
public stdin: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw InsufficientPrivilegesError if not root on Mac/Linux
|
||||||
|
* or non-Administrator on Windows.
|
||||||
|
*
|
||||||
|
* Called automatically if `root=true`.
|
||||||
|
* Can be called explicitly by command implementation, if e.g.:
|
||||||
|
* - check should only be done conditionally
|
||||||
|
* - other code needs to execute before check
|
||||||
|
*/
|
||||||
|
protected static async checkElevatedPrivileges() {
|
||||||
|
const isElevated = await (await import('is-elevated'))();
|
||||||
|
if (!isElevated) {
|
||||||
|
throw new InsufficientPrivilegesError(
|
||||||
|
'You need root/admin privileges to run this command',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw NotLoggedInError if not logged in.
|
||||||
|
*
|
||||||
|
* Called automatically if `authenticated=true`.
|
||||||
|
* Can be called explicitly by command implementation, if e.g.:
|
||||||
|
* - check should only be done conditionally
|
||||||
|
* - other code needs to execute before check
|
||||||
|
*
|
||||||
|
* Note, currently public to allow use outside of derived commands
|
||||||
|
* (as some command implementations require this. Can be made protected
|
||||||
|
* if this changes).
|
||||||
|
*
|
||||||
|
* @throws {NotLoggedInError}
|
||||||
|
*/
|
||||||
|
public static async checkLoggedIn() {
|
||||||
|
await (await import('./utils/patterns')).checkLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw NotLoggedInError if not logged in when condition true.
|
||||||
|
*
|
||||||
|
* @param {boolean} doCheck - will check if true.
|
||||||
|
* @throws {NotLoggedInError}
|
||||||
|
*/
|
||||||
|
public static async checkLoggedInIf(doCheck: boolean) {
|
||||||
|
if (doCheck) {
|
||||||
|
await this.checkLoggedIn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read stdin contents and make available to command.
|
||||||
|
*
|
||||||
|
* This approach could be improved in the future to automatically set argument
|
||||||
|
* values from stdin based in configuration, minimising command implementation.
|
||||||
|
*/
|
||||||
|
protected async getStdin() {
|
||||||
|
this.stdin = await (await import('get-stdin'))();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a logger instance.
|
||||||
|
*/
|
||||||
|
protected static async getLogger() {
|
||||||
|
return (await import('./utils/logger')).getLogger();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async init() {
|
||||||
|
const ctr = this.constructor as typeof BalenaCommand;
|
||||||
|
|
||||||
|
if (ctr.root) {
|
||||||
|
await BalenaCommand.checkElevatedPrivileges();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctr.authenticated) {
|
||||||
|
await BalenaCommand.checkLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctr.readStdin) {
|
||||||
|
await this.getStdin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
85
lib/commands/api-key/generate.ts
Normal file
85
lib/commands/api-key/generate.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import Command from '../../command';
|
||||||
|
import { ExpectedError } from '../../errors';
|
||||||
|
import * as cf from '../../utils/common-flags';
|
||||||
|
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class GenerateCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
Generate a new balenaCloud API key.
|
||||||
|
|
||||||
|
Generate a new balenaCloud API key for the current user, with the given
|
||||||
|
name. The key will be logged to the console.
|
||||||
|
|
||||||
|
This key can be used to log into the CLI using 'balena login --token <key>',
|
||||||
|
or to authenticate requests to the API with an 'Authorization: Bearer <key>' header.
|
||||||
|
`;
|
||||||
|
public static examples = ['$ balena api-key generate "Jenkins Key"'];
|
||||||
|
|
||||||
|
public static args = [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
description: 'the API key name',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'api-key generate <name>';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
help: cf.help,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static authenticated = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params } = this.parse<FlagsDef, ArgsDef>(GenerateCmd);
|
||||||
|
|
||||||
|
let key;
|
||||||
|
try {
|
||||||
|
key = await getBalenaSdk().models.apiKey.create(params.name);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'BalenaNotLoggedIn') {
|
||||||
|
throw new ExpectedError(stripIndent`
|
||||||
|
This command cannot be run when logged in with an API key.
|
||||||
|
Please login again with 'balena login' and select an alternative method.
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(stripIndent`
|
||||||
|
Registered api key '${params.name}':
|
||||||
|
|
||||||
|
${key}
|
||||||
|
|
||||||
|
This key will not be shown again, so please save it now.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
104
lib/commands/app/create.ts
Normal file
104
lib/commands/app/create.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import Command from '../../command';
|
||||||
|
import { ExpectedError } from '../../errors';
|
||||||
|
import * as cf from '../../utils/common-flags';
|
||||||
|
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||||
|
import type * as BalenaSDK from 'balena-sdk';
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
type?: string; // application device type
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppCreateCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
Create an application.
|
||||||
|
|
||||||
|
Create a new balena application.
|
||||||
|
|
||||||
|
You can specify the application device type with the \`--type\` option.
|
||||||
|
Otherwise, an interactive dropdown will be shown for you to select from.
|
||||||
|
|
||||||
|
You can see a list of supported device types with:
|
||||||
|
|
||||||
|
$ balena devices supported
|
||||||
|
`;
|
||||||
|
public static examples = [
|
||||||
|
'$ balena app create MyApp',
|
||||||
|
'$ balena app create MyApp --type raspberry-pi',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static args = [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
description: 'application name',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'app create <name>';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
type: flags.string({
|
||||||
|
char: 't',
|
||||||
|
description:
|
||||||
|
'application device type (Check available types with `balena devices supported`)',
|
||||||
|
}),
|
||||||
|
help: cf.help,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static authenticated = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||||
|
AppCreateCmd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const balena = getBalenaSdk();
|
||||||
|
|
||||||
|
// Create application
|
||||||
|
const deviceType =
|
||||||
|
options.type ||
|
||||||
|
(await (await import('../../utils/patterns')).selectDeviceType());
|
||||||
|
let application: BalenaSDK.Application;
|
||||||
|
try {
|
||||||
|
application = await balena.models.application.create({
|
||||||
|
name: params.name,
|
||||||
|
deviceType,
|
||||||
|
organization: (await balena.auth.whoami())!,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// BalenaRequestError: Request error: Unique key constraint violated
|
||||||
|
if ((err.message || '').toLowerCase().includes('unique')) {
|
||||||
|
throw new ExpectedError(
|
||||||
|
`Error: application "${params.name}" already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
`Application created: ${application.slug} (${deviceType}, id ${application.id})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
85
lib/commands/app/index.ts
Normal file
85
lib/commands/app/index.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import Command from '../../command';
|
||||||
|
import * as cf from '../../utils/common-flags';
|
||||||
|
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
|
||||||
|
import type { Release } from 'balena-sdk';
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
Display information about a single application.
|
||||||
|
|
||||||
|
Display detailed information about a single balena application.
|
||||||
|
`;
|
||||||
|
public static examples = ['$ balena app MyApp'];
|
||||||
|
|
||||||
|
public static args = [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
description: 'application name or numeric ID',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'app <name>';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
help: cf.help,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static authenticated = true;
|
||||||
|
public static primary = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppCmd);
|
||||||
|
|
||||||
|
const { getApplication } = await import('../../utils/sdk');
|
||||||
|
|
||||||
|
const application = (await getApplication(getBalenaSdk(), params.name, {
|
||||||
|
$expand: {
|
||||||
|
is_for__device_type: { $select: 'slug' },
|
||||||
|
should_be_running__release: { $select: 'commit' },
|
||||||
|
},
|
||||||
|
})) as ApplicationWithDeviceType & {
|
||||||
|
should_be_running__release: [Release?];
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
application.device_type = application.is_for__device_type[0].slug;
|
||||||
|
// @ts-expect-error
|
||||||
|
application.commit = application.should_be_running__release[0]?.commit;
|
||||||
|
console.log(
|
||||||
|
getVisuals().table.vertical(application, [
|
||||||
|
`$${application.app_name}$`,
|
||||||
|
'id',
|
||||||
|
'device_type',
|
||||||
|
'slug',
|
||||||
|
'commit',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
82
lib/commands/app/purge.ts
Normal file
82
lib/commands/app/purge.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import Command from '../../command';
|
||||||
|
import * as cf from '../../utils/common-flags';
|
||||||
|
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||||
|
import { tryAsInteger } from '../../utils/validation';
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppRestartCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
Purge data from an application.
|
||||||
|
|
||||||
|
Purge data from all devices belonging to an application.
|
||||||
|
This will clear the application's /data directory.
|
||||||
|
`;
|
||||||
|
public static examples = ['$ balena app purge MyApp'];
|
||||||
|
|
||||||
|
public static args = [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
description: 'application name or numeric ID',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'app purge <name>';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
help: cf.help,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static authenticated = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
|
||||||
|
|
||||||
|
const balena = getBalenaSdk();
|
||||||
|
|
||||||
|
// balena.models.application.purge only accepts a numeric id
|
||||||
|
// so we must first fetch the app to get it's id, if we have been given a name
|
||||||
|
let nameOrId = tryAsInteger(params.name);
|
||||||
|
|
||||||
|
if (typeof nameOrId === 'string') {
|
||||||
|
const app = await balena.models.application.get(nameOrId);
|
||||||
|
nameOrId = app.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await balena.models.application.purge(nameOrId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.toLowerCase().includes('no online device(s) found')) {
|
||||||
|
// application.purge throws an error if no devices are online
|
||||||
|
// ignore in this case.
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
136
lib/commands/app/rename.ts
Normal file
136
lib/commands/app/rename.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import type { IArg } from '@oclif/parser/lib/args';
|
||||||
|
import Command from '../../command';
|
||||||
|
import * as cf from '../../utils/common-flags';
|
||||||
|
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
|
||||||
|
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
name: string;
|
||||||
|
newName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppRenameCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
Rename an application.
|
||||||
|
|
||||||
|
Rename an application.
|
||||||
|
|
||||||
|
Note, if the \`newName\` parameter is omitted, it will be
|
||||||
|
prompted for interactively.
|
||||||
|
`;
|
||||||
|
public static examples = [
|
||||||
|
'$ balena app rename OldName',
|
||||||
|
'$ balena app rename OldName NewName',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static args: Array<IArg<any>> = [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
description: 'application name or numeric ID',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'newName',
|
||||||
|
description: 'the new name for the application',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'app rename <name> [newName]';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
help: cf.help,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static authenticated = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRenameCmd);
|
||||||
|
|
||||||
|
const { ExpectedError, instanceOf } = await import('../../errors');
|
||||||
|
const { getApplication } = await import('../../utils/sdk');
|
||||||
|
const balena = getBalenaSdk();
|
||||||
|
|
||||||
|
// Get app
|
||||||
|
let app;
|
||||||
|
try {
|
||||||
|
app = await getApplication(balena, params.name, {
|
||||||
|
$expand: {
|
||||||
|
application_type: {
|
||||||
|
$select: ['is_legacy'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const { BalenaApplicationNotFound } = await import('balena-errors');
|
||||||
|
if (instanceOf(e, BalenaApplicationNotFound)) {
|
||||||
|
throw new ExpectedError(`Application ${params.name} not found.`);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check app supports renaming
|
||||||
|
const appType = (app.application_type as ApplicationType[])?.[0];
|
||||||
|
if (appType.is_legacy) {
|
||||||
|
throw new ExpectedError(
|
||||||
|
`Application ${params.name} is of 'legacy' type, and cannot be renamed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { validateApplicationName } = await import('../../utils/validation');
|
||||||
|
const newName =
|
||||||
|
params.newName ||
|
||||||
|
(await getCliForm().ask({
|
||||||
|
message: 'Please enter the new name for this application:',
|
||||||
|
type: 'input',
|
||||||
|
validate: validateApplicationName,
|
||||||
|
})) ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.renameApplication(balena, app.id, newName);
|
||||||
|
} catch (e) {
|
||||||
|
// BalenaRequestError: Request error: Unique key constraint violated
|
||||||
|
if ((e.message || '').toLowerCase().includes('unique')) {
|
||||||
|
throw new ExpectedError(
|
||||||
|
`Error: application ${params.name} already exists.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Application ${params.name} renamed to ${newName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameApplication(balena: BalenaSDK, id: number, newName: string) {
|
||||||
|
return balena.pine.patch<Application>({
|
||||||
|
resource: 'application',
|
||||||
|
id,
|
||||||
|
body: {
|
||||||
|
app_name: newName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
61
lib/commands/app/restart.ts
Normal file
61
lib/commands/app/restart.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import Command from '../../command';
|
||||||
|
import * as cf from '../../utils/common-flags';
|
||||||
|
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||||
|
import { tryAsInteger } from '../../utils/validation';
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppRestartCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
Restart an application.
|
||||||
|
|
||||||
|
Restart all devices belonging to an application.
|
||||||
|
`;
|
||||||
|
public static examples = ['$ balena app restart MyApp'];
|
||||||
|
|
||||||
|
public static args = [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
description: 'application name or numeric ID',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'app restart <name>';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
help: cf.help,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static authenticated = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
|
||||||
|
|
||||||
|
await getBalenaSdk().models.application.restart(tryAsInteger(params.name));
|
||||||
|
}
|
||||||
|
}
|
79
lib/commands/app/rm.ts
Normal file
79
lib/commands/app/rm.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import Command from '../../command';
|
||||||
|
import * as cf from '../../utils/common-flags';
|
||||||
|
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||||
|
import { tryAsInteger } from '../../utils/validation';
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
yes: boolean;
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppRmCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
Remove an application.
|
||||||
|
|
||||||
|
Permanently remove a balena application.
|
||||||
|
|
||||||
|
The --yes option may be used to avoid interactive confirmation.
|
||||||
|
`;
|
||||||
|
public static examples = [
|
||||||
|
'$ balena app rm MyApp',
|
||||||
|
'$ balena app rm MyApp --yes',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static args = [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
description: 'application name or numeric ID',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'app rm <name>';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
yes: cf.yes,
|
||||||
|
help: cf.help,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static authenticated = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||||
|
AppRmCmd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const patterns = await import('../../utils/patterns');
|
||||||
|
|
||||||
|
// Confirm
|
||||||
|
await patterns.confirm(
|
||||||
|
options.yes ?? false,
|
||||||
|
`Are you sure you want to delete application ${params.name}?`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
await getBalenaSdk().models.application.remove(tryAsInteger(params.name));
|
||||||
|
}
|
||||||
|
}
|
97
lib/commands/apps.ts
Normal file
97
lib/commands/apps.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import Command from '../command';
|
||||||
|
import * as cf from '../utils/common-flags';
|
||||||
|
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
|
||||||
|
import { isV12 } from '../utils/version';
|
||||||
|
|
||||||
|
interface ExtendedApplication extends ApplicationWithDeviceType {
|
||||||
|
device_count?: number;
|
||||||
|
online_devices?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
help: void;
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppsCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
List all applications.
|
||||||
|
|
||||||
|
list all your balena applications.
|
||||||
|
|
||||||
|
For detailed information on a particular application,
|
||||||
|
use \`balena app <name> instead\`.
|
||||||
|
`;
|
||||||
|
public static examples = ['$ balena apps'];
|
||||||
|
|
||||||
|
public static usage = 'apps';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
help: cf.help,
|
||||||
|
verbose: flags.boolean({
|
||||||
|
char: 'v',
|
||||||
|
description: isV12()
|
||||||
|
? 'No-op since release v12.0.0'
|
||||||
|
: 'add extra columns in the tabular output (SLUG)',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static authenticated = true;
|
||||||
|
public static primary = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { flags: options } = this.parse<FlagsDef, {}>(AppsCmd);
|
||||||
|
|
||||||
|
const balena = getBalenaSdk();
|
||||||
|
|
||||||
|
// Get applications
|
||||||
|
const applications = (await balena.models.application.getAll({
|
||||||
|
$select: ['id', 'app_name', 'slug'],
|
||||||
|
$expand: {
|
||||||
|
is_for__device_type: { $select: 'slug' },
|
||||||
|
owns__device: { $select: 'is_online' },
|
||||||
|
},
|
||||||
|
})) as ExtendedApplication[];
|
||||||
|
|
||||||
|
const _ = await import('lodash');
|
||||||
|
// Add extended properties
|
||||||
|
applications.forEach((application) => {
|
||||||
|
application.device_count = application.owns__device?.length ?? 0;
|
||||||
|
application.online_devices = _.sumBy(application.owns__device, (d) =>
|
||||||
|
d.is_online === true ? 1 : 0,
|
||||||
|
);
|
||||||
|
// @ts-expect-error
|
||||||
|
application.device_type = application.is_for__device_type[0].slug;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display
|
||||||
|
console.log(
|
||||||
|
getVisuals().table.horizontal(applications, [
|
||||||
|
'id',
|
||||||
|
'app_name',
|
||||||
|
options.verbose || isV12() ? 'slug' : '',
|
||||||
|
'device_type',
|
||||||
|
'online_devices',
|
||||||
|
'device_count',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
258
lib/commands/build.ts
Normal file
258
lib/commands/build.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 Balena Ltd.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { flags } from '@oclif/command';
|
||||||
|
import Command from '../command';
|
||||||
|
import { getBalenaSdk } from '../utils/lazy';
|
||||||
|
import * as compose from '../utils/compose';
|
||||||
|
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
|
||||||
|
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
|
||||||
|
import type { ComposeCliFlags, ComposeOpts } from '../utils/compose-types';
|
||||||
|
import { buildProject, composeCliFlags } from '../utils/compose_ts';
|
||||||
|
import type { BuildOpts, DockerCliFlags } from '../utils/docker';
|
||||||
|
import { dockerCliFlags } from '../utils/docker';
|
||||||
|
|
||||||
|
interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
|
||||||
|
arch?: string;
|
||||||
|
deviceType?: string;
|
||||||
|
application?: string;
|
||||||
|
source?: string; // Not part of command profile - source param copied here.
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BuildCmd extends Command {
|
||||||
|
public static description = `\
|
||||||
|
Build a project locally.
|
||||||
|
|
||||||
|
Use this command to build an image or a complete multicontainer project with
|
||||||
|
the provided docker daemon in your development machine or balena device.
|
||||||
|
(See also the \`balena push\` command for the option of building images in the
|
||||||
|
balenaCloud build servers.)
|
||||||
|
|
||||||
|
You must provide either an application or a device-type/architecture pair.
|
||||||
|
|
||||||
|
This command will look into the given source directory (or the current working
|
||||||
|
directory if one isn't specified) for a docker-compose.yml file, and if found,
|
||||||
|
each service defined in the compose file will be built. If a compose file isn't
|
||||||
|
found, it will look for a Dockerfile[.template] file (or alternative Dockerfile
|
||||||
|
specified with the \`--dockerfile\` option), and if no dockerfile is found, it
|
||||||
|
will try to generate one.
|
||||||
|
|
||||||
|
${registrySecretsHelp}
|
||||||
|
|
||||||
|
${dockerignoreHelp}
|
||||||
|
`;
|
||||||
|
public static examples = [
|
||||||
|
'$ balena build --application myApp',
|
||||||
|
'$ balena build ./source/ --application myApp',
|
||||||
|
'$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated',
|
||||||
|
'$ balena build --docker /var/run/docker.sock --application myApp # Linux, Mac',
|
||||||
|
'$ balena build --docker //./pipe/docker_engine --application myApp # Windows',
|
||||||
|
'$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem -a myApp',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static args = [
|
||||||
|
{
|
||||||
|
name: 'source',
|
||||||
|
description: 'path of project source directory',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'build [source]';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
arch: flags.string({
|
||||||
|
description: 'the architecture to build for',
|
||||||
|
char: 'A',
|
||||||
|
}),
|
||||||
|
deviceType: flags.string({
|
||||||
|
description: 'the type of device this build is for',
|
||||||
|
char: 'd',
|
||||||
|
}),
|
||||||
|
application: flags.string({
|
||||||
|
description: 'name of the target balena application this build is for',
|
||||||
|
char: 'a',
|
||||||
|
}),
|
||||||
|
...composeCliFlags,
|
||||||
|
...dockerCliFlags,
|
||||||
|
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
|
||||||
|
// Revisit this in future release.
|
||||||
|
help: flags.help({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static primary = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||||
|
BuildCmd,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Command.checkLoggedInIf(!!options.application);
|
||||||
|
|
||||||
|
// compositions with many services trigger misleading warnings
|
||||||
|
// @ts-ignore editing property that isn't typed but does exist
|
||||||
|
(await import('events')).defaultMaxListeners = 1000;
|
||||||
|
|
||||||
|
const sdk = getBalenaSdk();
|
||||||
|
|
||||||
|
const logger = await Command.getLogger();
|
||||||
|
logger.logDebug('Parsing input...');
|
||||||
|
|
||||||
|
// `build` accepts `source` as a parameter, but compose expects it as an option
|
||||||
|
options.source = params.source;
|
||||||
|
delete params.source;
|
||||||
|
|
||||||
|
await this.validateOptions(options, sdk);
|
||||||
|
|
||||||
|
const app = await this.getAppAndResolveArch(options);
|
||||||
|
|
||||||
|
const { docker, buildOpts, composeOpts } = await this.prepareBuild(options);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.buildProject(docker, logger, composeOpts, {
|
||||||
|
app,
|
||||||
|
arch: options.arch!,
|
||||||
|
deviceType: options.deviceType!,
|
||||||
|
buildEmulated: options.emulated,
|
||||||
|
buildOpts,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.logError('Build failed.');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.outputDeferredMessages();
|
||||||
|
logger.logSuccess('Build succeeded!');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async validateOptions(opts: FlagsDef, sdk: BalenaSDK) {
|
||||||
|
// Validate option combinations
|
||||||
|
if (
|
||||||
|
(opts.application == null &&
|
||||||
|
(opts.arch == null || opts.deviceType == null)) ||
|
||||||
|
(opts.application != null &&
|
||||||
|
(opts.arch != null || opts.deviceType != null))
|
||||||
|
) {
|
||||||
|
const { ExpectedError } = await import('../errors');
|
||||||
|
throw new ExpectedError(
|
||||||
|
'You must specify either an application or an arch/deviceType pair to build for',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate project directory
|
||||||
|
const { validateProjectDirectory } = await import('../utils/compose_ts');
|
||||||
|
const { dockerfilePath, registrySecrets } = await validateProjectDirectory(
|
||||||
|
sdk,
|
||||||
|
{
|
||||||
|
dockerfilePath: opts.dockerfile,
|
||||||
|
noParentCheck: opts['noparent-check'] || false,
|
||||||
|
projectPath: opts.source || '.',
|
||||||
|
registrySecretsPath: opts['registry-secrets'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
opts.dockerfile = dockerfilePath;
|
||||||
|
opts['registry-secrets'] = registrySecrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getAppAndResolveArch(opts: FlagsDef) {
|
||||||
|
if (opts.application) {
|
||||||
|
const { getAppWithArch } = await import('../utils/helpers');
|
||||||
|
const app = await getAppWithArch(opts.application);
|
||||||
|
opts.arch = app.arch;
|
||||||
|
opts.deviceType = app.is_for__device_type[0].slug;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async prepareBuild(options: FlagsDef) {
|
||||||
|
const { getDocker, generateBuildOpts } = await import('../utils/docker');
|
||||||
|
const [docker, buildOpts, composeOpts] = await Promise.all([
|
||||||
|
getDocker(options),
|
||||||
|
generateBuildOpts(options),
|
||||||
|
compose.generateOpts(options),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
docker,
|
||||||
|
buildOpts,
|
||||||
|
composeOpts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opts must be an object with the following keys:
|
||||||
|
* app: the app this build is for (optional)
|
||||||
|
* arch: the architecture to build for
|
||||||
|
* deviceType: the device type to build for
|
||||||
|
* buildEmulated
|
||||||
|
* buildOpts: arguments to forward to docker build command
|
||||||
|
*
|
||||||
|
* @param {DockerToolbelt} docker
|
||||||
|
* @param {Logger} logger
|
||||||
|
* @param {ComposeOpts} composeOpts
|
||||||
|
* @param opts
|
||||||
|
*/
|
||||||
|
protected async buildProject(
|
||||||
|
docker: import('docker-toolbelt'),
|
||||||
|
logger: import('../utils/logger'),
|
||||||
|
composeOpts: ComposeOpts,
|
||||||
|
opts: {
|
||||||
|
app?: Application;
|
||||||
|
arch: string;
|
||||||
|
deviceType: string;
|
||||||
|
buildEmulated: boolean;
|
||||||
|
buildOpts: BuildOpts;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { loadProject } = await import('../utils/compose_ts');
|
||||||
|
|
||||||
|
const project = await loadProject(logger, composeOpts);
|
||||||
|
|
||||||
|
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
|
||||||
|
if (
|
||||||
|
appType != null &&
|
||||||
|
project.descriptors.length > 1 &&
|
||||||
|
!appType.supports_multicontainer
|
||||||
|
) {
|
||||||
|
logger.logWarn(
|
||||||
|
'Target application does not support multiple containers.\n' +
|
||||||
|
'Continuing with build, but you will not be able to deploy.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await buildProject({
|
||||||
|
docker,
|
||||||
|
logger,
|
||||||
|
projectPath: project.path,
|
||||||
|
projectName: project.name,
|
||||||
|
composition: project.composition,
|
||||||
|
arch: opts.arch,
|
||||||
|
deviceType: opts.deviceType,
|
||||||
|
emulated: opts.buildEmulated,
|
||||||
|
buildOpts: opts.buildOpts,
|
||||||
|
inlineLogs: composeOpts.inlineLogs,
|
||||||
|
convertEol: composeOpts.convertEol,
|
||||||
|
dockerfilePath: composeOpts.dockerfilePath,
|
||||||
|
nogitignore: composeOpts.nogitignore,
|
||||||
|
multiDockerignore: composeOpts.multiDockerignore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user