Compare commits
810 Commits
v0.1.0
...
speech-inp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf07904789 | ||
|
|
4e576829b7 | ||
|
|
f9b5e2b529 | ||
|
|
cc2f6976f6 | ||
|
|
0ed19aeefb | ||
|
|
d9068ac8c6 | ||
|
|
51f8eff3f7 | ||
|
|
627ff2d42b | ||
|
|
0d9da40102 | ||
|
|
ff94c9714e | ||
|
|
429825f434 | ||
|
|
d836d2e62d | ||
|
|
f77fb1562e | ||
|
|
b33421a375 | ||
|
|
c64a9a03f9 | ||
|
|
0d215342e3 | ||
|
|
beb14ea0a2 | ||
|
|
6a4e548d2c | ||
|
|
ad943b2bd4 | ||
|
|
6dac8a6209 | ||
|
|
bec1af6523 | ||
|
|
1719802c0f | ||
|
|
3719dcecf8 | ||
|
|
3dae143830 | ||
|
|
f050273a8e | ||
|
|
8f955cf21c | ||
|
|
a893fca66e | ||
|
|
4f8aba5658 | ||
|
|
219e012c1b | ||
|
|
17716a730b | ||
|
|
c57170d122 | ||
|
|
24c1b7e8ad | ||
|
|
3c76f9776c | ||
|
|
80a02b68b9 | ||
|
|
c766b5ab62 | ||
|
|
133e937772 | ||
|
|
95df743339 | ||
|
|
cd6266757d | ||
|
|
ec0bffe0c2 | ||
|
|
ed322a16bf | ||
|
|
044e46cd6b | ||
|
|
38f75ab06d | ||
|
|
b6bf58ea8f | ||
|
|
2c27fc53ad | ||
|
|
4c5acefa07 | ||
|
|
224cab6a42 | ||
|
|
48b2d7c5ee | ||
|
|
594809538d | ||
|
|
13802537b4 | ||
|
|
ca2b3c232f | ||
|
|
c51e71c7a2 | ||
|
|
482313f662 | ||
|
|
9a4d378238 | ||
|
|
5d5fbfb5f2 | ||
|
|
d147ad49ff | ||
|
|
9b435e3621 | ||
|
|
ab9e188b02 | ||
|
|
2991de528a | ||
|
|
f1bd681618 | ||
|
|
b91dbb1a60 | ||
|
|
688b127c6d | ||
|
|
0f9c99e3bd | ||
|
|
1122070b9c | ||
|
|
57b81f00f8 | ||
|
|
362105fe78 | ||
|
|
5834d2df1b | ||
|
|
ef4c8ef425 | ||
|
|
5f755a7e1c | ||
|
|
8607fab5b5 | ||
|
|
0368fe8248 | ||
|
|
b970281fa7 | ||
|
|
8e5a7fc213 | ||
|
|
15f362e8b5 | ||
|
|
7bbd0a1787 | ||
|
|
f8aae56728 | ||
|
|
027d7fc97d | ||
|
|
e90aef4b3c | ||
|
|
e4e89008b2 | ||
|
|
90baefbb7e | ||
|
|
1c138f4489 | ||
|
|
d36e568ed0 | ||
|
|
d6462ef524 | ||
|
|
a06884ebce | ||
|
|
62bd88f6a4 | ||
|
|
6479561779 | ||
|
|
635237c258 | ||
|
|
33f0aa5714 | ||
|
|
7ca6285d58 | ||
|
|
14c60fef6c | ||
|
|
336de6a19e | ||
|
|
377c8e2249 | ||
|
|
697dea21f8 | ||
|
|
34d3f803d5 | ||
|
|
f824a063a5 | ||
|
|
96fe1b86dd | ||
|
|
5fabf286e8 | ||
|
|
e8947d61b1 | ||
|
|
1ccd14eae8 | ||
|
|
b162764ccb | ||
|
|
2124e540aa | ||
|
|
b5790998b7 | ||
|
|
9800afb785 | ||
|
|
3b73d9d5b9 | ||
|
|
f7ac30afe3 | ||
|
|
ce370d5100 | ||
|
|
c639e535b5 | ||
|
|
e84adebe61 | ||
|
|
d45a1ff078 | ||
|
|
b4121696bb | ||
|
|
f75c942162 | ||
|
|
127a1f628d | ||
|
|
859312ba3b | ||
|
|
4eaa711f01 | ||
|
|
c8ff858565 | ||
|
|
6de6ef5a4a | ||
|
|
4dee154490 | ||
|
|
ef388adc4f | ||
|
|
e8cfad1266 | ||
|
|
3f82dd21fe | ||
|
|
dc13d9a7d0 | ||
|
|
29557fba6d | ||
|
|
dea5079713 | ||
|
|
ddc58a2c3c | ||
|
|
eafd4d83af | ||
|
|
1a0734c6b1 | ||
|
|
e16c5752ed | ||
|
|
375f92410e | ||
|
|
53f1dd4150 | ||
|
|
b7f638f07d | ||
|
|
32113ea100 | ||
|
|
b31135f622 | ||
|
|
eb6701185b | ||
|
|
d948ad8e35 | ||
|
|
f58267dd30 | ||
|
|
95c747923c | ||
|
|
f3b9ee4e04 | ||
|
|
309a123c1f | ||
|
|
761e3d4268 | ||
|
|
265d497ef4 | ||
|
|
56a052086f | ||
|
|
9a4d205d97 | ||
|
|
ff71302969 | ||
|
|
4f6c8523c0 | ||
|
|
8c24a7daf3 | ||
|
|
682937e945 | ||
|
|
35ff359c0f | ||
|
|
5067db3dd0 | ||
|
|
c7195469bd | ||
|
|
1ef01da019 | ||
|
|
edd3ded1d8 | ||
|
|
e30ff6358d | ||
|
|
e9f281a69d | ||
|
|
36baac06b8 | ||
|
|
3678214e69 | ||
|
|
338e3d9d38 | ||
|
|
0c0f397db0 | ||
|
|
da70cc9944 | ||
|
|
ba418a8518 | ||
|
|
ffe991bbe4 | ||
|
|
3047a1e602 | ||
|
|
e6c568988a | ||
|
|
45fab91e7f | ||
|
|
d3484ec3af | ||
|
|
cb0d601b09 | ||
|
|
9ea4f6b5ef | ||
|
|
bf9ee76de5 | ||
|
|
6ed1e09180 | ||
|
|
54d4cf6604 | ||
|
|
359e89971f | ||
|
|
7f833747b0 | ||
|
|
ab3f228d85 | ||
|
|
67a530a83b | ||
|
|
612ec6af1b | ||
|
|
3382736f05 | ||
|
|
fd5941fb36 | ||
|
|
9b76521a90 | ||
|
|
ea92c0609d | ||
|
|
612e50808a | ||
|
|
2c24402742 | ||
|
|
d7c4bf1e45 | ||
|
|
5bfb09c73b | ||
|
|
fd499d95e6 | ||
|
|
204b2e020b | ||
|
|
d34e0163e3 | ||
|
|
a93252621a | ||
|
|
8ce7a9b4ee | ||
|
|
63ffb86ea7 | ||
|
|
bd9a8d9788 | ||
|
|
d291c2f074 | ||
|
|
16c2eeca3e | ||
|
|
d9d281af8c | ||
|
|
56a6364f99 | ||
|
|
ba20dd6f2f | ||
|
|
0d96a9f9ff | ||
|
|
ee9da95044 | ||
|
|
0511d92cbf | ||
|
|
e666ac333c | ||
|
|
8495dcd021 | ||
|
|
01ab2f2794 | ||
|
|
b59e85abda | ||
|
|
4eded9e204 | ||
|
|
90164aa507 | ||
|
|
f87c83cadd | ||
|
|
01300a81de | ||
|
|
d143faf8eb | ||
|
|
8c29741830 | ||
|
|
d360089b80 | ||
|
|
4279b25ff4 | ||
|
|
0e755b721c | ||
|
|
b244d9f98c | ||
|
|
9e3dbc5dfb | ||
|
|
4cf980fb97 | ||
|
|
5bde55f8d4 | ||
|
|
0d4a4ccad7 | ||
|
|
56a0e8aa6e | ||
|
|
2a5bb6304d | ||
|
|
322a880a02 | ||
|
|
ded31078d4 | ||
|
|
dcbe3475ed | ||
|
|
338a88fb5a | ||
|
|
7eb1551e4b | ||
|
|
0414f924e6 | ||
|
|
9456871271 | ||
|
|
5b4edef785 | ||
|
|
6b81d0d703 | ||
|
|
4097637169 | ||
|
|
9bd66e7297 | ||
|
|
883b0724e0 | ||
|
|
7b6ed88be4 | ||
|
|
e0bb867948 | ||
|
|
ca28f503b7 | ||
|
|
c83028abc2 | ||
|
|
60406ca8fb | ||
|
|
e878c3c83b | ||
|
|
bdd3fe8899 | ||
|
|
3cfaf689e7 | ||
|
|
b41da03e8a | ||
|
|
ef14b9acb6 | ||
|
|
99474955af | ||
|
|
6f73adaef6 | ||
|
|
e2ff758003 | ||
|
|
748a99c9c4 | ||
|
|
db2d764cce | ||
|
|
157fe9d6b4 | ||
|
|
6c42b64466 | ||
|
|
88605a4617 | ||
|
|
e8f8e7bd65 | ||
|
|
750a87ef45 | ||
|
|
8fda9aed71 | ||
|
|
7e1dab8384 | ||
|
|
5b24f0cd40 | ||
|
|
a6b1f4ba19 | ||
|
|
df02b7cdca | ||
|
|
06b0d03c31 | ||
|
|
fd22a5ed9d | ||
|
|
86db407c0b | ||
|
|
f1520be777 | ||
|
|
8a91e04ff9 | ||
|
|
76b1134c95 | ||
|
|
d98d519fd3 | ||
|
|
02407e0f7a | ||
|
|
0261154a5e | ||
|
|
d2b68159be | ||
|
|
aab0692403 | ||
|
|
17a3e43ac7 | ||
|
|
a2127a11ac | ||
|
|
ea4c687125 | ||
|
|
de20b3adf3 | ||
|
|
929e79befd | ||
|
|
3522d3dff5 | ||
|
|
1af01680ee | ||
|
|
67f5f830a3 | ||
|
|
81102cc6bf | ||
|
|
afa7243eab | ||
|
|
37b7c1e53c | ||
|
|
ba61ab79e2 | ||
|
|
37d075fbb3 | ||
|
|
2961d41be3 | ||
|
|
1bb5aedfdb | ||
|
|
0a793fb1c6 | ||
|
|
a401eeec11 | ||
|
|
d9bcc66930 | ||
|
|
01921e3454 | ||
|
|
158f6e25cf | ||
|
|
562c4b2637 | ||
|
|
51fd5d87f7 | ||
|
|
28fb56bfa1 | ||
|
|
c1052b36dc | ||
|
|
c62c9b1c78 | ||
|
|
feccbd13bd | ||
|
|
5b1e21345f | ||
|
|
33939f4096 | ||
|
|
96f5a0ab44 | ||
|
|
d9f7735c94 | ||
|
|
4aae8ab720 | ||
|
|
b83c69f002 | ||
|
|
c74e0b89f7 | ||
|
|
9ee7ff9509 | ||
|
|
74a21d6418 | ||
|
|
15f390ade7 | ||
|
|
bb4e3815d1 | ||
|
|
8fa0175b98 | ||
|
|
ee59622b98 | ||
|
|
a1452ad353 | ||
|
|
0c9284e57e | ||
|
|
0766185ff6 | ||
|
|
effb30d98e | ||
|
|
4da69b5a20 | ||
|
|
3d3337c7b8 | ||
|
|
f0b43dbc68 | ||
|
|
b0eb9aec64 | ||
|
|
8c48455ae5 | ||
|
|
292f695395 | ||
|
|
4ea710c735 | ||
|
|
f5d4cb6917 | ||
|
|
1e53e06424 | ||
|
|
2530cd4fc8 | ||
|
|
b25fb0073e | ||
|
|
c01846f7fd | ||
|
|
dfd397803f | ||
|
|
267f1592c4 | ||
|
|
668ac7fa88 | ||
|
|
43a476e967 | ||
|
|
adbfab5c25 | ||
|
|
02f1284f7f | ||
|
|
a014ce555a | ||
|
|
db3c13c463 | ||
|
|
7c0bf382ba | ||
|
|
6e9c5a88b4 | ||
|
|
0bf22a323f | ||
|
|
cc997576cf | ||
|
|
05f193df7b | ||
|
|
c9b5bb1b7a | ||
|
|
ba1013cd35 | ||
|
|
ec6428702b | ||
|
|
e08ebb2057 | ||
|
|
9683f90f7e | ||
|
|
06cb986aa6 | ||
|
|
a85c2f1700 | ||
|
|
bd2a0d1bec | ||
|
|
df9722cd16 | ||
|
|
dffa4907ec | ||
|
|
e567d35438 | ||
|
|
62f52fc534 | ||
|
|
69f221942c | ||
|
|
7749225f71 | ||
|
|
ae322c53cc | ||
|
|
37da426ab4 | ||
|
|
591f55bef9 | ||
|
|
aabaadbe1d | ||
|
|
3ab14e8de6 | ||
|
|
40634138bc | ||
|
|
b17087b610 | ||
|
|
71f58e7c5f | ||
|
|
927e4e1281 | ||
|
|
2e56a5e9f4 | ||
|
|
296d07a0d6 | ||
|
|
0d8a844af8 | ||
|
|
bf9cef4cd5 | ||
|
|
9dde33aba7 | ||
|
|
0fefff3b0a | ||
|
|
1122c19648 | ||
|
|
f06359a1fc | ||
|
|
72f420b6f6 | ||
|
|
147c9e3e4b | ||
|
|
ab38cdccac | ||
|
|
8168d52295 | ||
|
|
1081bfb276 | ||
|
|
38064b229c | ||
|
|
1a7aefcbae | ||
|
|
e50d9f461a | ||
|
|
d76cf8a3f7 | ||
|
|
c7370fe7bc | ||
|
|
3dfbe2a5b2 | ||
|
|
e30c8b0253 | ||
|
|
df9fc529f9 | ||
|
|
2e9f5b916c | ||
|
|
fd464f349a | ||
|
|
ff6d6f4f76 | ||
|
|
cb2966fb08 | ||
|
|
888e365d72 | ||
|
|
e9241a1b93 | ||
|
|
f01a06d85b | ||
|
|
a68285da68 | ||
|
|
c825ff066e | ||
|
|
f7ded37ea3 | ||
|
|
847faf1214 | ||
|
|
b1691add1c | ||
|
|
3b9a44779a | ||
|
|
62fd88cd3f | ||
|
|
ce2273fe57 | ||
|
|
0eee325777 | ||
|
|
f7c9db44ad | ||
|
|
1fcf89b945 | ||
|
|
f5682ea246 | ||
|
|
fa308696b4 | ||
|
|
ac8dfcc607 | ||
|
|
ac04d5daf7 | ||
|
|
7fe8fee295 | ||
|
|
31940f972f | ||
|
|
5954b332d5 | ||
|
|
eb89dfaf89 | ||
|
|
25bf313338 | ||
|
|
315abf21e6 | ||
|
|
f24e360d78 | ||
|
|
1a6f1fdbae | ||
|
|
e09ce0780e | ||
|
|
95fdad7523 | ||
|
|
06416a9eb3 | ||
|
|
2db62b1d17 | ||
|
|
1377bc6b91 | ||
|
|
fcb5998474 | ||
|
|
c2df32ec8b | ||
|
|
f01149ee9e | ||
|
|
eebfcb5628 | ||
|
|
4571a1dcf9 | ||
|
|
a041e1c6c3 | ||
|
|
abb8a9df19 | ||
|
|
3c450c076a | ||
|
|
4b05e698f8 | ||
|
|
a9524b3e30 | ||
|
|
154c5208b4 | ||
|
|
71479a59a7 | ||
|
|
3606d9aa50 | ||
|
|
3e4d51c9f2 | ||
|
|
2603b1d260 | ||
|
|
94aa469e90 | ||
|
|
dab1e0fa7a | ||
|
|
a14247f049 | ||
|
|
695a890e0a | ||
|
|
402d72d038 | ||
|
|
d32ec73c63 | ||
|
|
d0eac1e610 | ||
|
|
e947691aae | ||
|
|
575f987b8f | ||
|
|
28b66ed0af | ||
|
|
4060c4f60b | ||
|
|
8334e27294 | ||
|
|
722b523f92 | ||
|
|
b4663fb250 | ||
|
|
06be455358 | ||
|
|
450f5bf0b4 | ||
|
|
997d4f4129 | ||
|
|
ff5c698131 | ||
|
|
14497f2082 | ||
|
|
f3e1966b5d | ||
|
|
78592f229e | ||
|
|
c8161669ac | ||
|
|
8ec57da275 | ||
|
|
c00b29145a | ||
|
|
7d2a349e95 | ||
|
|
6c326b18ca | ||
|
|
09229259d1 | ||
|
|
b20bfc34b2 | ||
|
|
4e1f08bfcf | ||
|
|
ef4f8ac45f | ||
|
|
6a7255d9d2 | ||
|
|
f37fcaed3d | ||
|
|
d9fd22c29f | ||
|
|
3fcab5b80a | ||
|
|
4ed2361387 | ||
|
|
75b3699649 | ||
|
|
a6404f25d9 | ||
|
|
7591e5c1c9 | ||
|
|
5e8b3fd5c9 | ||
|
|
20b82496a1 | ||
|
|
542b59940a | ||
|
|
8d5c6b37e9 | ||
|
|
8155fc9956 | ||
|
|
cd4afb5314 | ||
|
|
557c2500c7 | ||
|
|
74f8b6c31f | ||
|
|
da517416a5 | ||
|
|
b8f93bf768 | ||
|
|
0110052758 | ||
|
|
0e0da1a142 | ||
|
|
da3b66a3bd | ||
|
|
088e5f1eea | ||
|
|
0da2e1d7bb | ||
|
|
90c6835ee7 | ||
|
|
92bef8bfb8 | ||
|
|
766be00ded | ||
|
|
ce5eaa1841 | ||
|
|
c323667729 | ||
|
|
67a12d6126 | ||
|
|
bd0cb04b78 | ||
|
|
d3706d2985 | ||
|
|
9769d7a46e | ||
|
|
783fb5c5b2 | ||
|
|
82ff1916b7 | ||
|
|
8204143810 | ||
|
|
e54f80f20e | ||
|
|
54a2917faa | ||
|
|
b72ead1bea | ||
|
|
7996228327 | ||
|
|
7aba3c1221 | ||
|
|
11dedd4446 | ||
|
|
8fcf757c5c | ||
|
|
5cf3c001b5 | ||
|
|
4ae54a1f7b | ||
|
|
81a9c28971 | ||
|
|
235b9338a7 | ||
|
|
642d5e22e6 | ||
|
|
67ff00d83e | ||
|
|
710938eef8 | ||
|
|
dc702b1fb2 | ||
|
|
92d16084db | ||
|
|
9b0e02f66f | ||
|
|
a2e5034c20 | ||
|
|
e3489b22e6 | ||
|
|
cd8948770d | ||
|
|
d4281f1d9c | ||
|
|
49214c60ca | ||
|
|
0a530a257f | ||
|
|
54f269e955 | ||
|
|
91ab2d5e2c | ||
|
|
72773546f5 | ||
|
|
2f58e8a1a9 | ||
|
|
d0cab51eca | ||
|
|
6f04d23b09 | ||
|
|
3e72b83393 | ||
|
|
87da8ee9f8 | ||
|
|
ec5c5c8c0f | ||
|
|
b9394fb467 | ||
|
|
de432106e5 | ||
|
|
1fbf51b7ae | ||
|
|
864d665049 | ||
|
|
c4a9c032a3 | ||
|
|
3373e23a41 | ||
|
|
b0650a283e | ||
|
|
52149f5543 | ||
|
|
2e5a904034 | ||
|
|
f1ad1400a7 | ||
|
|
bbd28404ff | ||
|
|
04f6e362b9 | ||
|
|
0b9cce6f86 | ||
|
|
d68cb6b1b8 | ||
|
|
e345dc1262 | ||
|
|
2b27790a81 | ||
|
|
2514fa94b4 | ||
|
|
522910ff64 | ||
|
|
971abe24d7 | ||
|
|
49143bd049 | ||
|
|
df52ed3035 | ||
|
|
617aac8fd8 | ||
|
|
6e82ecc97e | ||
|
|
636a19fc50 | ||
|
|
97f78bb337 | ||
|
|
0ca39d2fb0 | ||
|
|
aad1337111 | ||
|
|
6d7bc813ed | ||
|
|
1a0dd21540 | ||
|
|
7cf9c35375 | ||
|
|
f1c32253af | ||
|
|
4a8d13e2cd | ||
|
|
b0fd63ead5 | ||
|
|
94cb741c7f | ||
|
|
976430d61c | ||
|
|
8a8555d591 | ||
|
|
57c1605242 | ||
|
|
cfbd0bdffa | ||
|
|
58efb8bc3e | ||
|
|
b35bfe63c0 | ||
|
|
d7b5f53d59 | ||
|
|
168b782006 | ||
|
|
9e0fbd185d | ||
|
|
11be314f63 | ||
|
|
36ee301ef2 | ||
|
|
d6dd06b7d1 | ||
|
|
6a16dd8f98 | ||
|
|
78338f33c1 | ||
|
|
8c72d279df | ||
|
|
a9500276c8 | ||
|
|
f9ec757c64 | ||
|
|
f4c9385661 | ||
|
|
6ba50cadd2 | ||
|
|
8d5169cb39 | ||
|
|
fe8b4a9acd | ||
|
|
831e59cd77 | ||
|
|
7fde8afcf0 | ||
|
|
d07c2ec4a9 | ||
|
|
4306147990 | ||
|
|
c614da3e3c | ||
|
|
73b59d8266 | ||
|
|
a2d8ea0dfd | ||
|
|
52ee196103 | ||
|
|
1a1aee8f91 | ||
|
|
5384ff8e80 | ||
|
|
6d5836ce1f | ||
|
|
d3dc170e02 | ||
|
|
983c8cc4a3 | ||
|
|
757c587b17 | ||
|
|
5f9cf397b9 | ||
|
|
78ab17d148 | ||
|
|
e91923ad99 | ||
|
|
fd23ea54b6 | ||
|
|
1e7969eaba | ||
|
|
77bfe41a8e | ||
|
|
6d134e4dec | ||
|
|
9423326193 | ||
|
|
c5011e4ece | ||
|
|
66c270151a | ||
|
|
5ce41217e9 | ||
|
|
1e4d949d35 | ||
|
|
6bb9e8e414 | ||
|
|
1efc49b67b | ||
|
|
f0ed98222a | ||
|
|
ddd8ce341a | ||
|
|
b7721ba3e7 | ||
|
|
0554018980 | ||
|
|
ca18942bfd | ||
|
|
c9c1f69b82 | ||
|
|
aa0c31fa1e | ||
|
|
96b88dbcdc | ||
|
|
50676416ed | ||
|
|
f633d75005 | ||
|
|
4085f6d6b9 | ||
|
|
ae288833e1 | ||
|
|
f16e244265 | ||
|
|
b6e43c899b | ||
|
|
9fa436b0b8 | ||
|
|
ccd65fbc74 | ||
|
|
daa7e3a6d1 | ||
|
|
ff356ac5ea | ||
|
|
d68b92ff38 | ||
|
|
940216d98b | ||
|
|
69cd3cf545 | ||
|
|
042a45db0d | ||
|
|
cc45c16d73 | ||
|
|
91fb351a63 | ||
|
|
d9b149a7cb | ||
|
|
222a467a19 | ||
|
|
18513939f7 | ||
|
|
c123714271 | ||
|
|
5c82a2d653 | ||
|
|
435881529e | ||
|
|
700342670c | ||
|
|
2f40f5eedf | ||
|
|
54905c5626 | ||
|
|
1bf1a4761d | ||
|
|
755695a35a | ||
|
|
6a9a442948 | ||
|
|
3db9b0f673 | ||
|
|
4e0e5dcdca | ||
|
|
fad2809299 | ||
|
|
c77bfc2ee7 | ||
|
|
f1fa28dd2c | ||
|
|
91ace25333 | ||
|
|
b54db28fb1 | ||
|
|
f13feb3062 | ||
|
|
4622bdc7ea | ||
|
|
919127b6d9 | ||
|
|
27cd4515cd | ||
|
|
93a5c16cab | ||
|
|
16b76385e2 | ||
|
|
9313b2bd6c | ||
|
|
d25cb09714 | ||
|
|
0d0d1271c3 | ||
|
|
1fd3b2e75c | ||
|
|
bf32fcf136 | ||
|
|
48eb6b8982 | ||
|
|
797fafe854 | ||
|
|
b342660ed0 | ||
|
|
169d5ddeb9 | ||
|
|
38642b60e9 | ||
|
|
01effb8924 | ||
|
|
b434bfd3e9 | ||
|
|
ed769911d6 | ||
|
|
dd6efee900 | ||
|
|
48a16a6702 | ||
|
|
841b9daa1f | ||
|
|
1741e49568 | ||
|
|
8577b3d1e6 | ||
|
|
011533b3c4 | ||
|
|
002efad9ad | ||
|
|
3ce5569b82 | ||
|
|
d7c0c225b9 | ||
|
|
f4de0103a8 | ||
|
|
0a9b7fafed | ||
|
|
073604c9f5 | ||
|
|
4062b43380 | ||
|
|
00bd9f9c1c | ||
|
|
3edb0ac09e | ||
|
|
e9f3c4ee52 | ||
|
|
92420d9e02 | ||
|
|
3688be06ee | ||
|
|
8b2be441fc | ||
|
|
b2493a3a53 | ||
|
|
4eb3dbf492 | ||
|
|
adbe0399b2 | ||
|
|
e2461661f7 | ||
|
|
cc012094b4 | ||
|
|
968a6f3cab | ||
|
|
f0d8634a83 | ||
|
|
9f862d5afc | ||
|
|
08f3d75015 | ||
|
|
d9adab3022 | ||
|
|
9019f7622e | ||
|
|
631b5002e7 | ||
|
|
b1987008c7 | ||
|
|
3338109d51 | ||
|
|
c0616df704 | ||
|
|
ca4030e86e | ||
|
|
0a2d57624c | ||
|
|
dbbed94381 | ||
|
|
a088b948b4 | ||
|
|
87faa32c7c | ||
|
|
873be2d6c1 | ||
|
|
5fafed31d0 | ||
|
|
a763831b83 | ||
|
|
076aa4ff9a | ||
|
|
31cbb9cc53 | ||
|
|
ee6db23b14 | ||
|
|
9fe3dcdc6d | ||
|
|
ea644a7d0f | ||
|
|
2f7ddd57dd | ||
|
|
f2f108c14e | ||
|
|
731885f54a | ||
|
|
3c11a1bfcb | ||
|
|
166dd3f719 | ||
|
|
123da12468 | ||
|
|
2a7cdbae42 | ||
|
|
88952a140a | ||
|
|
b64ea411e6 | ||
|
|
44b2bb1b68 | ||
|
|
efabf83a26 | ||
|
|
ac540a18f2 | ||
|
|
4bad384ca0 | ||
|
|
0eb00901b9 | ||
|
|
459b950ab6 | ||
|
|
d7edd4cf4a | ||
|
|
e0bd5ccc92 | ||
|
|
5e82fc4e5d | ||
|
|
1b2c775348 | ||
|
|
16e9cb21da | ||
|
|
cacfbc24cc | ||
|
|
2052c5566e | ||
|
|
2486af2808 | ||
|
|
881afbba0a | ||
|
|
b6d48bfb69 | ||
|
|
d9596f7b4b | ||
|
|
6467bdfe7c | ||
|
|
4fdd299919 | ||
|
|
2de2d26043 | ||
|
|
70e6052dc8 | ||
|
|
2ff51c1866 | ||
|
|
d6fdef68d9 | ||
|
|
30b075e4ba | ||
|
|
3f46d73a31 | ||
|
|
038cf3c762 | ||
|
|
85c0632719 | ||
|
|
c4c2c92974 | ||
|
|
c5fd5694ee | ||
|
|
bc5423ce14 | ||
|
|
8fab34e356 | ||
|
|
d3ee15dcd7 | ||
|
|
45dca7a7f0 | ||
|
|
885059b0aa | ||
|
|
629d098add | ||
|
|
7e95005d8c | ||
|
|
7aa94e7a88 | ||
|
|
146eae5220 | ||
|
|
defa637dbc | ||
|
|
a43a004e23 | ||
|
|
a3f02befa7 | ||
|
|
40e8c90bab | ||
|
|
f53564bb06 | ||
|
|
719a9c9c74 | ||
|
|
08d81f8bb5 | ||
|
|
89bd32814f | ||
|
|
aa77ca2931 | ||
|
|
b46110937b | ||
|
|
28aa5da16d | ||
|
|
03237f6f79 | ||
|
|
492c6064f9 | ||
|
|
fa8eacde53 | ||
|
|
742c2d2c29 | ||
|
|
eb279cf251 | ||
|
|
6658c0b15a | ||
|
|
12044988d6 | ||
|
|
c4e76aaac4 | ||
|
|
2b6597ad00 | ||
|
|
cce5d1aba8 | ||
|
|
04db4fcf94 | ||
|
|
cb161e57a4 | ||
|
|
b92fbd93a8 | ||
|
|
1a0ccac634 | ||
|
|
cd9d7c2a39 | ||
|
|
941052acc8 | ||
|
|
5f67a01864 | ||
|
|
b80e332021 | ||
|
|
df625e0fe7 | ||
|
|
cd2bd3c636 | ||
|
|
6e7003c57c | ||
|
|
adee1e0383 | ||
|
|
efe7af6f77 | ||
|
|
6fa41d51be | ||
|
|
8431b9f8a2 | ||
|
|
541027c93e | ||
|
|
9f2edbb9db | ||
|
|
eced9b8124 | ||
|
|
68b6793bf3 | ||
|
|
d3b194c306 | ||
|
|
467cbf4b28 | ||
|
|
756f3d68cb | ||
|
|
7354f08abe | ||
|
|
db5bd9984e | ||
|
|
6fdd4947f9 | ||
|
|
b438702092 | ||
|
|
5faa06601a |
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or regression in CodeNomad
|
||||
labels:
|
||||
- bug
|
||||
title: "[Bug]: "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug report! Please review open issues before submitting a new one and provide as much detail as possible so we can reproduce the problem.
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: App Variant
|
||||
description: Which build are you running when this issue appears?
|
||||
multiple: false
|
||||
options:
|
||||
- Electron
|
||||
- Tauri
|
||||
- Server CLI
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: Operating System & Version
|
||||
description: Include the OS family and version (e.g., macOS 15.0, Ubuntu 24.04, Windows 11 23H2).
|
||||
placeholder: macOS 15.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: Issue Summary
|
||||
description: Briefly describe what is happening.
|
||||
placeholder: A quick one sentence problem statement
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: List the steps needed to reproduce the problem.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click ...
|
||||
3. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Describe what you expected to happen instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs & Screenshots
|
||||
description: Attach relevant logs, stack traces, or screenshots if available.
|
||||
placeholder: Paste logs here or drag-and-drop files onto the issue.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
768
.github/workflows/build-and-upload.yml
vendored
Normal file
@@ -0,0 +1,768 @@
|
||||
name: Build and Upload Binaries
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version:
|
||||
description: "Version to apply to workspace packages (release builds)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
tag:
|
||||
description: "Git tag to upload assets to (release builds)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_name:
|
||||
description: "Release name (unused here, for context)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
upload:
|
||||
description: "Upload built artifacts to the GitHub release"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
set_versions:
|
||||
description: "Run npm version to set workspace versions"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
# Permissions are intentionally omitted here so callers can choose
|
||||
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_FETCH_RETRIES: 5
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
|
||||
exit 0
|
||||
fi
|
||||
echo "npm version failed (attempt $attempt/3); retrying..." >&2
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||
|
||||
- name: Build macOS binaries (Electron)
|
||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
release_root="packages/electron-app/release"
|
||||
apps=()
|
||||
while IFS= read -r -d '' app; do
|
||||
apps+=("$app")
|
||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||
|
||||
if [ "${#apps[@]}" -eq 0 ]; then
|
||||
echo "No CodeNomad.app found under $release_root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# GitHub macOS runners typically have no signing identity. Without any signature,
|
||||
# the shipped .app can fail Gatekeeper with:
|
||||
# code has no resources but signature indicates they must be present
|
||||
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
|
||||
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
|
||||
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
|
||||
for app in "${apps[@]}"; do
|
||||
echo "codesign (adhoc): $app"
|
||||
codesign --force --deep --sign - "$app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
done
|
||||
else
|
||||
echo "macOS codesigning identity present; skipping ad-hoc signing"
|
||||
fi
|
||||
|
||||
- name: Repackage Electron macOS zips (ditto)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Prefer the workflow-provided version; fall back to package.json.
|
||||
VERSION_TO_USE="${VERSION:-}"
|
||||
if [ -z "$VERSION_TO_USE" ]; then
|
||||
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
|
||||
fi
|
||||
|
||||
release_root="packages/electron-app/release"
|
||||
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
|
||||
# Use find to locate built app bundles instead of ** globs.
|
||||
apps=()
|
||||
while IFS= read -r -d '' app; do
|
||||
apps+=("$app")
|
||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||
if [ "${#apps[@]}" -eq 0 ]; then
|
||||
echo "No CodeNomad.app found under $release_root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for app in "${apps[@]}"; do
|
||||
bundle_dir=$(basename "$(dirname "$app")")
|
||||
arch="x64"
|
||||
if [[ "$bundle_dir" == *"arm64"* ]]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
|
||||
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
|
||||
rm -f "$out_zip"
|
||||
echo "ditto -ck: $app -> $out_zip"
|
||||
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
|
||||
done
|
||||
|
||||
- name: Validate Electron macOS codesign (unzipped)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
|
||||
if [ "${#zips[@]}" -eq 0 ]; then
|
||||
echo "No Electron macOS zip artifacts found to validate" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for zip in "${zips[@]}"; do
|
||||
echo "Validating codesign for: $zip"
|
||||
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
|
||||
mkdir -p "$extract_dir"
|
||||
|
||||
# Use ditto for extraction as well to preserve bundle metadata.
|
||||
ditto -x -k "$zip" "$extract_dir"
|
||||
|
||||
app_path=""
|
||||
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
|
||||
if [ -d "$candidate" ]; then
|
||||
app_path="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$app_path" ]; then
|
||||
echo "No .app found after extracting $zip" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||
done
|
||||
|
||||
- name: Upload release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||
|
||||
- name: Build Windows binaries (Electron)
|
||||
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
|
||||
Write-Host "Uploading $($_.FullName)"
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build Linux binaries (Electron)
|
||||
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||
|
||||
- name: Prebuild (Tauri)
|
||||
run: npm run prebuild --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Ensure tauri native binary
|
||||
working-directory: packages/tauri-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build macOS bundle (Tauri)
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Tauri release assets (macOS)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-macos-arm64:
|
||||
runs-on: macos-26
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-arm64 --no-save
|
||||
|
||||
- name: Prebuild (Tauri)
|
||||
run: npm run prebuild --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Ensure tauri native binary
|
||||
working-directory: packages/tauri-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build macOS bundle (Tauri, arm64)
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Tauri release assets (macOS arm64)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||
|
||||
- name: Prebuild (Tauri)
|
||||
run: npm run prebuild --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Ensure tauri native binary
|
||||
shell: bash
|
||||
working-directory: packages/tauri-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build Windows bundle (Tauri)
|
||||
shell: bash
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
if: ${{ inputs.upload }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
$artifactDir = "packages/tauri-app/release-tauri"
|
||||
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $artifactDir | Out-Null
|
||||
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
|
||||
if ($null -ne $exe) {
|
||||
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
|
||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||
}
|
||||
|
||||
- name: Upload Tauri release assets (Windows)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path "packages/tauri-app/release-tauri") {
|
||||
Get-ChildItem -Path "packages/tauri-app/release-tauri" -Filter *.zip -File | ForEach-Object {
|
||||
Write-Host "Uploading $($_.FullName)"
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
}
|
||||
|
||||
build-tauri-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux build dependencies (Tauri)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Prebuild (Tauri)
|
||||
run: npm run prebuild --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Ensure tauri native binary
|
||||
working-directory: packages/tauri-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build Linux bundle (Tauri)
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
shopt -s nullglob globstar
|
||||
|
||||
find_one() {
|
||||
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
|
||||
}
|
||||
|
||||
appimage=$(find_one "*.AppImage")
|
||||
deb=$(find_one "*.deb")
|
||||
rpm=$(find_one "*.rpm")
|
||||
|
||||
if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then
|
||||
echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.AppImage"
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-linux-arm64:
|
||||
if: ${{ false }}
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: linux/arm64
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Install Linux build dependencies (Tauri)
|
||||
run: |
|
||||
sudo dpkg --add-architecture arm64
|
||||
sudo tee /etc/apt/sources.list.d/arm64.list >/dev/null <<'EOF'
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
|
||||
EOF
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
libgtk-3-dev:arm64 \
|
||||
libglib2.0-dev:arm64 \
|
||||
libwebkit2gtk-4.1-dev:arm64 \
|
||||
libsoup-3.0-dev:arm64 \
|
||||
libayatana-appindicator3-dev:arm64 \
|
||||
librsvg2-dev:arm64
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
|
||||
|
||||
- name: Build Linux bundle (Tauri arm64)
|
||||
env:
|
||||
TAURI_BUILD_TARGET: aarch64-unknown-linux-gnu
|
||||
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
|
||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
|
||||
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (Linux arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
shopt -s nullglob globstar
|
||||
first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" \) | head -n1)
|
||||
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
|
||||
if [ -n "$first_artifact" ]; then
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact"
|
||||
elif [ -f "$fallback_bin" ]; then
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin"
|
||||
else
|
||||
echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Tauri release assets (Linux arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
|
||||
build-linux-rpm:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install rpm packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install project dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build Linux RPM binaries
|
||||
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload RPM release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.rpm; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
80
.github/workflows/dev-release.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Develop Pre-Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Nightly build of dev (only if dev has new commits)
|
||||
- cron: "0 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: dev-prerelease
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run: ${{ steps.gate.outputs.run }}
|
||||
dev_sha: ${{ steps.gate.outputs.dev_sha }}
|
||||
version_suffix: ${{ steps.gate.outputs.version_suffix }}
|
||||
steps:
|
||||
- name: Decide whether to run
|
||||
id: gate
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
api() {
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"$1"
|
||||
}
|
||||
|
||||
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
|
||||
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
|
||||
echo "Failed to resolve dev head SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA8="${DEV_SHA::8}"
|
||||
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
|
||||
|
||||
SHOULD_RUN="false"
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||
SHOULD_RUN="true"
|
||||
else
|
||||
# Nightly: only run if dev has advanced since last successful dev-release build.
|
||||
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
|
||||
if [ -z "${LAST_SHA}" ]; then
|
||||
SHOULD_RUN="true"
|
||||
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
|
||||
SHOULD_RUN="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
|
||||
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
prerelease:
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.dev_sha }}
|
||||
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||
dist_tag: latest
|
||||
prerelease: true
|
||||
release_ui: false
|
||||
secrets: inherit
|
||||
110
.github/workflows/manual-npm-publish.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
name: Manual NPM Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 0.2.0-dev)"
|
||||
required: false
|
||||
type: string
|
||||
dist_tag:
|
||||
description: "npm dist-tag"
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
package_name:
|
||||
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
|
||||
required: false
|
||||
default: "@neuralnomads/codenomad"
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
package_name:
|
||||
required: false
|
||||
type: string
|
||||
default: "@neuralnomads/codenomad"
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Ensure npm >=11.5.1
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace packages/server
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_INPUT="${{ inputs.version }}"
|
||||
if [ -z "$VERSION_INPUT" ]; then
|
||||
VERSION_INPUT=$(node -p "require('./package.json').version")
|
||||
fi
|
||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Set server package name for publish
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
|
||||
|
||||
- name: Publish server package with provenance
|
||||
env:
|
||||
# Optional: when present, npm will use token auth.
|
||||
# When empty/unset, npm trusted publishing (OIDC) may be used if configured.
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
|
||||
echo "NPM_TOKEN not set; attempting npm trusted publishing (OIDC)"
|
||||
unset NODE_AUTH_TOKEN
|
||||
else
|
||||
echo "Using NPM_TOKEN authentication"
|
||||
fi
|
||||
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||
55
.github/workflows/release-ui.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Release UI
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
release-ui:
|
||||
# Automated via reusable call (main releases); manual runs allowed on dev/main.
|
||||
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Install Cloudflare worker deps
|
||||
run: npm ci
|
||||
working-directory: packages/cloudflare
|
||||
|
||||
- name: Build UI
|
||||
run: npm run build --workspace @codenomad/ui
|
||||
|
||||
- name: Publish UI zip + update manifest
|
||||
working-directory: packages/cloudflare
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CODENOMAD_R2_BUCKET: ${{ vars.CODENOMAD_R2_BUCKET }}
|
||||
run: npm run release:ui
|
||||
139
.github/workflows/release.yml
vendored
@@ -6,138 +6,13 @@ on:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
tag: ${{ steps.ensure_tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Read version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Ensure git tag
|
||||
id: ensure_tag
|
||||
env:
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
TAG="v${VERSION}"
|
||||
git fetch --tags
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag $TAG already exists"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Created tag $TAG"
|
||||
fi
|
||||
|
||||
- name: Ensure GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.ensure_tag.outputs.tag }}
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
|
||||
fi
|
||||
|
||||
build-macos:
|
||||
needs: prepare-release
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build macOS binaries
|
||||
run: npm run build:mac
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
|
||||
build-windows:
|
||||
needs: prepare-release
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Windows binaries
|
||||
run: npm run build:win
|
||||
|
||||
- name: Upload release assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
|
||||
build-linux:
|
||||
needs: prepare-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Linux binaries
|
||||
run: npm run build:linux
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
release:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
dist_tag: latest
|
||||
npm_package_name: "@neuralnomads/codenomad"
|
||||
secrets: inherit
|
||||
|
||||
120
.github/workflows/reusable-release.yml
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
name: Reusable Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version_suffix:
|
||||
description: "Suffix appended to package.json version"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
dist_tag:
|
||||
description: "npm dist-tag to publish under"
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
npm_package_name:
|
||||
description: "npm package name to publish (defaults to server package name)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Create GitHub prerelease"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_ui:
|
||||
description: "Publish remote UI + manifest"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.versions.outputs.version }}
|
||||
tag: ${{ steps.versions.outputs.tag }}
|
||||
release_name: ${{ steps.versions.outputs.release_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Compute release versions
|
||||
id: versions
|
||||
env:
|
||||
VERSION_SUFFIX: ${{ inputs.version_suffix }}
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
VERSION="${BASE_VERSION}${VERSION_SUFFIX}"
|
||||
TAG="v${VERSION}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.versions.outputs.tag }}
|
||||
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
if [ "${IS_PRERELEASE}" = "true" ]; then
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
fi
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: prepare-release
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||
secrets: inherit
|
||||
|
||||
release-ui:
|
||||
needs: prepare-release
|
||||
if: ${{ inputs.release_ui }}
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-ui.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs:
|
||||
- prepare-release
|
||||
- build-and-upload
|
||||
uses: ./.github/workflows/manual-npm-publish.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
package_name: ${{ inputs.npm_package_name }}
|
||||
secrets: inherit
|
||||
7
.gitignore
vendored
@@ -6,3 +6,10 @@ release/
|
||||
.vite/
|
||||
.electron-vite/
|
||||
out/
|
||||
.dir-locals.el
|
||||
.opencode/bashOutputs/
|
||||
|
||||
# Local runtime artifacts
|
||||
.codenomad/
|
||||
.tmp/
|
||||
packages/cloudflare/.wrangler/
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
description: Develops Web UI components.
|
||||
mode: all
|
||||
model: zai-coding-plan/glm-4.6
|
||||
---
|
||||
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.
|
||||
|
||||
7
.opencode/commands/release-notes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Creates release notes
|
||||
agent: build
|
||||
---
|
||||
|
||||
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
|
||||
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch
|
||||
49
AGENTS.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# AGENT NOTES
|
||||
|
||||
## Styling Guidelines
|
||||
- Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed.
|
||||
- Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`.
|
||||
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
|
||||
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
|
||||
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
|
||||
- Document any new styling conventions or directory additions in this file so future changes remain consistent.
|
||||
|
||||
## Coding Principles
|
||||
- Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need.
|
||||
- Uphold DRY: share helpers via dedicated modules before copy/pasting logic across stores, components, or scripts.
|
||||
- Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.).
|
||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
||||
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
||||
|
||||
## Multi-Language Support (i18n)
|
||||
|
||||
The UI uses a small custom i18n layer (no ICU/messageformat). When building features, never hardcode user-visible strings.
|
||||
|
||||
- **Runtime API:** use `useI18n()` in components (`const { t } = useI18n();`) and `tGlobal(...)` in stores/non-component code.
|
||||
- Implementation: `packages/ui/src/lib/i18n/index.tsx`
|
||||
- **Where messages live:** `packages/ui/src/lib/i18n/messages/<locale>/` as TypeScript objects (`"flat.dot.keys": "string"`).
|
||||
- Each locale has an `index.ts` that merges message parts; duplicate keys throw at build time.
|
||||
- Merge helper: `packages/ui/src/lib/i18n/messages/merge.ts`
|
||||
- **Adding a new string:** add it to the appropriate `.../messages/en/*.ts` part file, then add the same key to each other locale’s corresponding file.
|
||||
- Missing translations fall back to English (and finally to the key), so gaps can be easy to miss.
|
||||
- **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`.
|
||||
- **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code.
|
||||
- **Adding a new language:** add a new `messages/<locale>/` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`.
|
||||
- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`).
|
||||
- **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply.
|
||||
|
||||
## File Length Guidelines (Highlight Only)
|
||||
|
||||
We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits.
|
||||
|
||||
- Source files: warn after ~500 lines; target limit ~800 lines
|
||||
- Test files: highlight after ~1000 lines
|
||||
|
||||
Behavior for agents:
|
||||
- Do not refactor solely to satisfy these thresholds.
|
||||
- When a change touches a file that exceeds the warning/limit, mention it in your final response and include the file path and approximate line count.
|
||||
- When creating new files, aim to stay under the thresholds unless there's a clear reason.
|
||||
|
||||
## Tooling Preferences
|
||||
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
||||
- Use the `write` tool only when creating new files from scratch.
|
||||
10
BUILD.md
@@ -10,6 +10,12 @@ This guide explains how to build distributable binaries for CodeNomad.
|
||||
|
||||
## Quick Start
|
||||
|
||||
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
|
||||
|
||||
```bash
|
||||
npm run build --workspace @neuralnomads/codenomad-electron-app
|
||||
```
|
||||
|
||||
### Build for Current Platform (macOS default)
|
||||
|
||||
```bash
|
||||
@@ -71,8 +77,8 @@ bun run build:all
|
||||
|
||||
The build script performs these steps:
|
||||
|
||||
1. **Compile TypeScript** → Electron app (main, preload, renderer)
|
||||
2. **Bundle with Vite** → Optimized production build
|
||||
1. **Build @neuralnomads/codenomad** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves)
|
||||
2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/`
|
||||
3. **Package with electron-builder** → Platform-specific binaries
|
||||
|
||||
## Output
|
||||
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Neural Nomads
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
263
README.md
@@ -1,219 +1,128 @@
|
||||
# CodeNomad
|
||||
|
||||
A cross-platform desktop application for interacting with OpenCode servers, built with Electron and SolidJS.
|
||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
||||
|
||||
## Overview
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
||||
|
||||
CodeNomad provides a multi-instance, multi-session interface for working with AI-powered coding assistants. It manages OpenCode server processes, handles real-time message streaming, and provides an intuitive UI for coding with AI.
|
||||

|
||||
_Manage multiple OpenCode sessions side-by-side._
|
||||
|
||||
**🎯 MVP Focus:** This project prioritizes functionality over performance. Performance optimization is intentionally deferred to post-MVP phases. See [docs/MVP-PRINCIPLES.md](docs/MVP-PRINCIPLES.md) for details.
|
||||
<details>
|
||||
<summary>📸 More Screenshots</summary>
|
||||
|
||||
## Features
|
||||

|
||||
_Global command palette for keyboard-first control._
|
||||
|
||||
### Core Capabilities
|
||||

|
||||
_Rich media previews for images and assets._
|
||||
|
||||
- **Multi-Instance Management**: Work on multiple projects simultaneously
|
||||
- **Session Persistence**: Resume conversations across app restarts
|
||||
- **Real-time Streaming**: Live message updates via Server-Sent Events
|
||||
- **Tool Execution Visibility**: See bash commands, file edits, and other tool calls
|
||||
- **Agent & Model Switching**: Easily switch between different AI agents and models
|
||||
- **Markdown Rendering**: Beautiful code highlighting and formatting
|
||||

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
### Advanced Features (Planned)
|
||||
</details>
|
||||
|
||||
- Virtual scrolling for large conversations
|
||||
- Full-text search across sessions
|
||||
- Workspace management
|
||||
- Custom themes
|
||||
- Plugin system
|
||||
## Getting Started
|
||||
|
||||
## Architecture
|
||||
Choose the way that fits your workflow:
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
|
||||
### 🖥️ Desktop App (Recommended)
|
||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||
|
||||
### High-Level Overview
|
||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Run**: Install and launch like any other app.
|
||||
|
||||
```
|
||||
Electron App
|
||||
├── Main Process (Node.js)
|
||||
│ ├── Window management
|
||||
│ ├── OpenCode server spawning
|
||||
│ └── IPC communication
|
||||
├── Renderer Process (SolidJS)
|
||||
│ ├── UI components
|
||||
│ ├── State management (stores)
|
||||
│ └── SDK client communication
|
||||
└── Multiple OpenCode Servers
|
||||
└── One per instance/project folder
|
||||
```
|
||||
### 🦀 Tauri App (Experimental)
|
||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
||||
|
||||
## Prerequisites
|
||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
||||
|
||||
- Node.js 18+
|
||||
- Bun package manager
|
||||
- OpenCode CLI installed and in PATH
|
||||
|
||||
## Installation
|
||||
### 💻 CodeNomad Server
|
||||
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run in development mode
|
||||
bun run dev
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
|
||||
# Build distributable binaries
|
||||
bun run build:mac # macOS (Universal)
|
||||
bun run build:win # Windows (x64)
|
||||
bun run build:linux # Linux (x64)
|
||||
bun run build:all # All platforms
|
||||
|
||||
# See BUILD.md for more build options
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
## Development
|
||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||
- [packages/server/README.md](packages/server/README.md)
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── docs/ # Documentation
|
||||
├── tasks/ # Task management
|
||||
│ ├── todo/ # Pending tasks
|
||||
│ └── done/ # Completed tasks
|
||||
├── electron/ # Electron main process
|
||||
│ ├── main/ # Main process code
|
||||
│ ├── preload/ # Preload scripts
|
||||
│ └── resources/ # App icons, etc.
|
||||
└── src/ # Renderer (UI) code
|
||||
├── components/ # UI components
|
||||
├── stores/ # State management
|
||||
├── lib/ # Utilities
|
||||
├── hooks/ # SolidJS hooks
|
||||
└── types/ # TypeScript types
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Electron** - Desktop wrapper
|
||||
- **SolidJS** - Reactive UI framework
|
||||
- **TypeScript** - Type safety
|
||||
- **Vite** - Build tool
|
||||
- **TailwindCSS** - Styling
|
||||
- **Kobalte** - Accessible UI primitives
|
||||
- **OpenCode SDK** - API client
|
||||
|
||||
### Scripts
|
||||
To see all available options:
|
||||
|
||||
```bash
|
||||
bun run dev # Start dev server with hot reload
|
||||
bun run build # Build for production
|
||||
bun run typecheck # Run TypeScript type checking
|
||||
bun run preview # Preview production build
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
|
||||
## Usage
|
||||
### 🧪 Dev Releases
|
||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||
|
||||
### Starting an Instance
|
||||
```bash
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
1. Launch the app
|
||||
2. Click "Select Folder" or press Cmd/Ctrl+N
|
||||
3. Choose a project folder
|
||||
4. Wait for OpenCode server to start
|
||||
5. Select an existing session or create new one
|
||||
## Highlights
|
||||
|
||||
### Working with Sessions
|
||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||
|
||||
- **Switch sessions**: Click session tab at bottom
|
||||
- **Create session**: Click "+" button or Cmd/Ctrl+T
|
||||
- **Change agent**: Use agent dropdown
|
||||
- **Change model**: Use model dropdown
|
||||
## Requirements
|
||||
|
||||
### Sending Messages
|
||||
|
||||
- Type in the input box at bottom
|
||||
- Press Enter for new line (Cmd+Enter on macOS, Ctrl+Enter on Windows/Linux)
|
||||
- Use `/` for commands
|
||||
- Use `@` to mention files
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](docs/architecture.md) - System design and structure
|
||||
- [User Interface](docs/user-interface.md) - UI specifications
|
||||
- [Technical Implementation](docs/technical-implementation.md) - Implementation details
|
||||
- [Build Roadmap](docs/build-roadmap.md) - Development plan and phases
|
||||
- [Tasks](tasks/README.md) - Task breakdown and tracking
|
||||
|
||||
## Build Phases
|
||||
|
||||
The project is built in phases:
|
||||
|
||||
1. **Phase 1**: Foundation (Tasks 001-005)
|
||||
2. **Phase 2**: Core Chat (Tasks 006-010)
|
||||
3. **Phase 3**: Essential Features (Tasks 011-015)
|
||||
4. **Phase 4**: Multi-Instance (Tasks 016-020)
|
||||
5. **Phase 5**: Advanced Input (Tasks 021-025)
|
||||
6. **Phase 6**: Polish & UX (Tasks 026-030)
|
||||
7. **Phase 7**: System Integration (Tasks 031-035)
|
||||
8. **Phase 8**: Advanced Features (Tasks 036-040)
|
||||
|
||||
See [docs/build-roadmap.md](docs/build-roadmap.md) for detailed phase information.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Read the documentation in `docs/`
|
||||
2. Check `tasks/todo/` for available tasks
|
||||
3. Pick a task and create a feature branch
|
||||
4. Follow the task steps
|
||||
5. Submit PR when complete
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use TypeScript for all code
|
||||
- Follow existing patterns and conventions
|
||||
- Write clear, descriptive commit messages
|
||||
- Add comments for complex logic
|
||||
- Keep components small and focused
|
||||
|
||||
### Testing
|
||||
|
||||
- Test manually at minimum window size (800x600)
|
||||
- Test on multiple platforms (macOS, Windows, Linux)
|
||||
- Verify keyboard navigation works
|
||||
- Check accessibility with screen readers
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Won't Start
|
||||
### macOS says the app is damaged
|
||||
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
||||
|
||||
- Verify `opencode` is in PATH: `which opencode`
|
||||
- Check folder permissions
|
||||
- Review server logs in Logs tab
|
||||
- Try restarting the instance
|
||||
```bash
|
||||
xattr -l /Applications/CodeNomad.app
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
### Connection Issues
|
||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
||||
|
||||
- Check if server is running: `ps aux | grep opencode`
|
||||
- Verify port is correct in instance metadata
|
||||
- Check for firewall blocking localhost
|
||||
- Try killing and restarting server
|
||||
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
|
||||
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
|
||||
|
||||
### Performance Issues
|
||||
Try running with one of these environment variables:
|
||||
|
||||
- Check number of messages in session
|
||||
- Monitor memory usage in Activity Monitor
|
||||
- Consider enabling virtual scrolling (Phase 8)
|
||||
- Close unused instances
|
||||
```bash
|
||||
# Most reliable workaround (can reduce rendering performance)
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
|
||||
## License
|
||||
# Alternative for some Wayland setups
|
||||
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
|
||||
```
|
||||
|
||||
[License TBD]
|
||||
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
|
||||
|
||||
## Credits
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
|
||||
```
|
||||
|
||||
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
|
||||
|
||||
## Architecture & Development
|
||||
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||
|
||||
### Quick Build
|
||||
To build the Desktop App from source:
|
||||
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
|
||||
Built with ❤️ for the OpenCode project.
|
||||
|
||||
@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
||||
│ │ │ State Management (SolidJS Stores) │ │ │
|
||||
│ │ │ - instances[] │ │ │
|
||||
│ │ │ - sessions[] per instance │ │ │
|
||||
│ │ │ - messages[] per session │ │ │
|
||||
│ │ │ - normalized message store per session │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ UI Components │ │ │
|
||||
│ │ │ - InstanceTabs │ │ │
|
||||
│ │ │ - SessionTabs │ │ │
|
||||
│ │ │ - MessageStream │ │ │
|
||||
│ │ │ - MessageSection │ │ │
|
||||
│ │ │ - PromptInput │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
@@ -104,6 +104,12 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
||||
- Event type routing
|
||||
- Reconnection logic
|
||||
|
||||
**CLI Proxy Paths:**
|
||||
|
||||
- The CLI server terminates all HTTP/SSE traffic and forwards it to the correct OpenCode instance.
|
||||
- Each `WorkspaceDescriptor` exposes `proxyPath` (e.g., `/workspaces/<id>/instance`), which acts as the base URL for both REST and SSE calls.
|
||||
- The renderer never touches the random per-instance port directly; it only talks to `window.location.origin + proxyPath` so a single CLI port can front every session.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Instance Creation Flow
|
||||
@@ -144,6 +150,7 @@ instances: Map<instanceId, {
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
proxyPath: string // `/workspaces/:id/instance`
|
||||
status: 'starting' | 'ready' | 'error' | 'stopped'
|
||||
client: OpenCodeClient
|
||||
eventSource: EventSource
|
||||
82
dev-docs/solidjs-llms.txt
Normal file
@@ -0,0 +1,82 @@
|
||||
# SolidJS Documentation
|
||||
|
||||
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
|
||||
|
||||
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
|
||||
|
||||
Key principles:
|
||||
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
|
||||
- Compile-time optimization: JSX transforms into efficient DOM operations
|
||||
- Unidirectional data flow: Props are read-only, promoting predictable state management
|
||||
- Component lifecycle: Components run once, with reactive primitives handling updates
|
||||
|
||||
**Use your web fetch tool on any of the following links to understand the relevant concept**.
|
||||
|
||||
## Quick Start
|
||||
|
||||
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
|
||||
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
|
||||
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
|
||||
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
|
||||
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
|
||||
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
|
||||
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
|
||||
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
|
||||
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
|
||||
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
|
||||
|
||||
## Component APIs
|
||||
|
||||
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
|
||||
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
|
||||
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
|
||||
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
|
||||
|
||||
## Control Flow
|
||||
|
||||
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
|
||||
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
|
||||
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
|
||||
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
|
||||
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
|
||||
|
||||
## Derived Values
|
||||
|
||||
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
|
||||
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
|
||||
|
||||
## State Management
|
||||
|
||||
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
|
||||
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
|
||||
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
|
||||
|
||||
## Routing
|
||||
|
||||
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
|
||||
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
|
||||
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
|
||||
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
|
||||
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
|
||||
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
|
||||
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
|
||||
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
|
||||
|
||||
## Optional
|
||||
|
||||
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
|
||||
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
|
||||
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
|
||||
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options
|
||||
@@ -49,7 +49,7 @@ packages/opencode-client/
|
||||
│ ├── components/
|
||||
│ │ ├── instance-tabs.tsx # Level 1 tabs
|
||||
│ │ ├── session-tabs.tsx # Level 2 tabs
|
||||
│ │ ├── message-stream.tsx # Messages display
|
||||
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
|
||||
│ │ ├── message-item.tsx # Single message
|
||||
│ │ ├── tool-call.tsx # Tool execution display
|
||||
│ │ ├── prompt-input.tsx # Input with attachments
|
||||
@@ -153,16 +153,24 @@ interface Session {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
messages: Message[]
|
||||
status: SessionStatus
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
version: string
|
||||
time: { created: number; updated: number }
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Message content lives in the normalized message-v2 store
|
||||
// keyed by instanceId/sessionId/messageId
|
||||
|
||||
type SessionStatus =
|
||||
| "idle" // No activity
|
||||
| "streaming" // Assistant responding
|
||||
| "error" // Error occurred
|
||||
|
||||
```
|
||||
|
||||
### UI Store
|
||||
BIN
docs/screenshots/browser-support.png
Normal file
|
After Width: | Height: | Size: 845 KiB |
BIN
docs/screenshots/command-palette.png
Normal file
|
After Width: | Height: | Size: 835 KiB |
BIN
docs/screenshots/image-previews.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/screenshots/newSession.png
Normal file
|
After Width: | Height: | Size: 966 KiB |
@@ -1,53 +0,0 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { resolve } from "path"
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: "dist/main",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/main/main.ts"),
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: "dist/preload",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/preload/index.ts"),
|
||||
formats: ["cjs"],
|
||||
fileName: () => "index.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
output: {
|
||||
entryFileNames: "index.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: "./src/renderer",
|
||||
plugins: [solid()],
|
||||
css: {
|
||||
postcss: "./postcss.config.js",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
outDir: "dist/renderer",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,243 +0,0 @@
|
||||
import { ipcMain, BrowserWindow, dialog } from "electron"
|
||||
import { processManager } from "./process-manager"
|
||||
import { randomBytes } from "crypto"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { spawn } from "child_process"
|
||||
import ignore from "ignore"
|
||||
|
||||
interface Instance {
|
||||
id: string
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
status: "starting" | "ready" | "error" | "stopped"
|
||||
error?: string
|
||||
}
|
||||
|
||||
const instances = new Map<string, Instance>()
|
||||
|
||||
function generateId(): string {
|
||||
return randomBytes(16).toString("hex")
|
||||
}
|
||||
|
||||
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(binaryPath, ["-v"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error("Version check timed out"))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Binary exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
processManager.setMainWindow(mainWindow)
|
||||
|
||||
ipcMain.handle("dialog:selectFolder", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: "Select Project Folder",
|
||||
properties: ["openDirectory"],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"instance:create",
|
||||
async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) => {
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
}
|
||||
|
||||
instances.set(id, instance)
|
||||
|
||||
try {
|
||||
const {
|
||||
pid,
|
||||
port,
|
||||
binaryPath: actualBinaryPath,
|
||||
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
|
||||
|
||||
instance.port = port
|
||||
instance.pid = pid
|
||||
instance.status = "ready"
|
||||
|
||||
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
|
||||
|
||||
const meta = processManager.getAllProcesses().get(pid)
|
||||
if (meta) {
|
||||
meta.childProcess.on("exit", (code, signal) => {
|
||||
instance.status = "stopped"
|
||||
mainWindow.webContents.send("instance:stopped", { id })
|
||||
})
|
||||
}
|
||||
|
||||
return { id, port, pid, binaryPath: actualBinaryPath }
|
||||
} catch (error) {
|
||||
instance.status = "error"
|
||||
instance.error = error instanceof Error ? error.message : String(error)
|
||||
|
||||
mainWindow.webContents.send("instance:error", {
|
||||
id,
|
||||
error: instance.error,
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("instance:stop", async (event, pid: number) => {
|
||||
await processManager.kill(pid)
|
||||
|
||||
for (const [id, instance] of instances.entries()) {
|
||||
if (instance.pid === pid) {
|
||||
instance.status = "stopped"
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:status", async (event, pid: number) => {
|
||||
return processManager.getStatus(pid)
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:list", async () => {
|
||||
return Array.from(instances.values())
|
||||
})
|
||||
|
||||
ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => {
|
||||
const ig = ignore()
|
||||
ig.add([".git", "node_modules"])
|
||||
|
||||
const gitignorePath = path.join(workspaceFolder, ".gitignore")
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
const content = fs.readFileSync(gitignorePath, "utf-8")
|
||||
ig.add(content)
|
||||
}
|
||||
|
||||
function scanDir(dirPath: string, baseDir: string): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
const relativePath = path.relative(baseDir, fullPath)
|
||||
|
||||
if (ig.ignores(relativePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const dirWithSlash = relativePath + "/"
|
||||
if (!ig.ignores(dirWithSlash)) {
|
||||
results.push(dirWithSlash)
|
||||
const subFiles = scanDir(fullPath, baseDir)
|
||||
results.push(...subFiles)
|
||||
}
|
||||
} else {
|
||||
results.push(relativePath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error scanning ${dirPath}:`, error)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
return scanDir(workspaceFolder, workspaceFolder)
|
||||
})
|
||||
|
||||
// OpenCode binary operations
|
||||
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: "Select OpenCode Binary",
|
||||
filters: [
|
||||
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
|
||||
try {
|
||||
// Special handling for system PATH binary
|
||||
const isSystemPath = binaryPath === "opencode"
|
||||
|
||||
if (!isSystemPath) {
|
||||
// Check if file exists and is executable for custom paths
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return { valid: false, error: "File does not exist" }
|
||||
}
|
||||
|
||||
const stats = fs.statSync(binaryPath)
|
||||
if (!stats.isFile()) {
|
||||
return { valid: false, error: "Path is not a file" }
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get version once via -v flag
|
||||
try {
|
||||
const version = await runBinaryVersion(binaryPath)
|
||||
return { valid: true, version }
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
|
||||
import { join } from "path"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupInstanceIPC } from "./ipc"
|
||||
import { setupStorageIPC } from "./storage"
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
}
|
||||
|
||||
// Setup IPC handlers before creating windows
|
||||
setupStorageIPC()
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function getIconPath() {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "icon.png")
|
||||
}
|
||||
|
||||
return join(app.getAppPath(), "electron/resources/icon.png")
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const prefersDark = true //nativeTheme.shouldUseDarkColors
|
||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||
const iconPath = getIconPath()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
// Disable macOS spell server to avoid input lag
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.loadURL("http://localhost:3000")
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupInstanceIPC(mainWindow)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
if (isMac) {
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
|
||||
if (app.dock) {
|
||||
const dockIcon = nativeImage.createFromPath(getIconPath())
|
||||
if (!dockIcon.isEmpty()) {
|
||||
app.dock.setIcon(dockIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
@@ -1,281 +0,0 @@
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
binaryPath: string
|
||||
}
|
||||
|
||||
interface ProcessMeta {
|
||||
pid: number
|
||||
port: number
|
||||
folder: string
|
||||
startTime: number
|
||||
childProcess: ChildProcess
|
||||
logs: string[]
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
class ProcessManager {
|
||||
private processes = new Map<number, ProcessMeta>()
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
|
||||
setMainWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window
|
||||
}
|
||||
|
||||
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
|
||||
const upperMessage = message.toUpperCase()
|
||||
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
|
||||
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
|
||||
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
|
||||
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
|
||||
return "info"
|
||||
}
|
||||
|
||||
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
|
||||
if (this.mainWindow && message.trim()) {
|
||||
const parsedLevel = this.parseLogLevel(message)
|
||||
this.mainWindow.webContents.send("instance:log", {
|
||||
id: instanceId,
|
||||
entry: {
|
||||
timestamp: Date.now(),
|
||||
level: parsedLevel,
|
||||
message: message.trim(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async spawn(
|
||||
folder: string,
|
||||
instanceId: string,
|
||||
binaryPath?: string,
|
||||
environmentVariables?: Record<string, string>,
|
||||
): Promise<ProcessInfo> {
|
||||
this.validateFolder(folder)
|
||||
const actualBinaryPath =
|
||||
binaryPath && binaryPath !== "opencode" ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary()
|
||||
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Starting OpenCode server for ${folder} using ${binaryPath || "opencode"} (${actualBinaryPath})...`,
|
||||
)
|
||||
|
||||
// Merge environment variables with process environment
|
||||
const env = { ...process.env }
|
||||
if (environmentVariables) {
|
||||
Object.assign(env, environmentVariables)
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
|
||||
)
|
||||
|
||||
// Log each environment variable
|
||||
for (const [key, value] of Object.entries(environmentVariables)) {
|
||||
this.sendLog(instanceId, "info", ` ${key}=${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
|
||||
cwd: folder,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
|
||||
reject(new Error("Server startup timeout (10s exceeded)"))
|
||||
}, 10000)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
let stderrBuffer = ""
|
||||
let portFound = false
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stdoutBuffer += text
|
||||
|
||||
const lines = stdoutBuffer.split("\n")
|
||||
stdoutBuffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
this.sendLog(instanceId, "info", line)
|
||||
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
|
||||
if (portMatch && !portFound) {
|
||||
portFound = true
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
clearTimeout(timeout)
|
||||
|
||||
const meta: ProcessMeta = {
|
||||
pid: child.pid!,
|
||||
port,
|
||||
folder,
|
||||
startTime: Date.now(),
|
||||
childProcess: child,
|
||||
logs: [line],
|
||||
instanceId,
|
||||
}
|
||||
|
||||
this.processes.set(child.pid!, meta)
|
||||
resolve({ pid: child.pid!, port, binaryPath: actualBinaryPath })
|
||||
}
|
||||
|
||||
const meta = this.processes.get(child.pid!)
|
||||
if (meta) {
|
||||
meta.logs.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stderrBuffer += text
|
||||
|
||||
const lines = stderrBuffer.split("\n")
|
||||
stderrBuffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
this.sendLog(instanceId, "error", line)
|
||||
|
||||
const meta = this.processes.get(child.pid!)
|
||||
if (meta) {
|
||||
meta.logs.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
if (error.message.includes("ENOENT")) {
|
||||
reject(new Error("opencode binary not found in PATH"))
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
this.processes.delete(child.pid!)
|
||||
|
||||
if (!portFound) {
|
||||
const errorMsg = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(errorMsg))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async kill(pid: number): Promise<void> {
|
||||
const meta = this.processes.get(pid)
|
||||
if (!meta) {
|
||||
// Treat unknown processes as already stopped so tabs close cleanly
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = meta.childProcess
|
||||
|
||||
const killTimeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
}, 2000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.processes.delete(pid)
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(pid: number): "running" | "stopped" | "unknown" {
|
||||
if (!this.processes.has(pid)) {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return "running"
|
||||
} catch {
|
||||
return "stopped"
|
||||
}
|
||||
}
|
||||
|
||||
getAllProcesses(): Map<number, ProcessMeta> {
|
||||
return new Map(this.processes)
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
|
||||
await Promise.all(killPromises)
|
||||
}
|
||||
|
||||
private validateFolder(folder: string): void {
|
||||
if (!existsSync(folder)) {
|
||||
throw new Error(`Folder does not exist: ${folder}`)
|
||||
}
|
||||
|
||||
const stats = statSync(folder)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${folder}`)
|
||||
}
|
||||
}
|
||||
|
||||
private validateOpenCodeBinary(): string {
|
||||
const command = process.platform === "win32" ? "where opencode" : "which opencode"
|
||||
try {
|
||||
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
|
||||
const paths = output.trim().split("\n")
|
||||
return paths[0].trim()
|
||||
} catch {
|
||||
throw new Error(
|
||||
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validateCustomBinary(binaryPath: string): string {
|
||||
if (!existsSync(binaryPath)) {
|
||||
throw new Error(`OpenCode binary not found: ${binaryPath}`)
|
||||
}
|
||||
|
||||
const stats = statSync(binaryPath)
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${binaryPath}`)
|
||||
}
|
||||
|
||||
// Check if executable (on Unix systems)
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
|
||||
} catch {
|
||||
throw new Error(`Binary is not executable: ${binaryPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
return binaryPath
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager()
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await processManager.cleanup()
|
||||
app.exit(0)
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
|
||||
export interface ElectronAPI {
|
||||
selectFolder: () => Promise<string | null>
|
||||
createInstance: (
|
||||
id: string,
|
||||
folder: string,
|
||||
binaryPath?: string,
|
||||
environmentVariables?: Record<string, string>,
|
||||
) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
|
||||
stopInstance: (pid: number) => Promise<void>
|
||||
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
|
||||
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
|
||||
onInstanceStopped: (callback: (data: { id: string }) => void) => void
|
||||
onInstanceLog: (
|
||||
callback: (data: {
|
||||
id: string
|
||||
entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string }
|
||||
}) => void,
|
||||
) => void
|
||||
onNewInstance: (callback: () => void) => void
|
||||
scanDirectory: (workspaceFolder: string) => Promise<string[]>
|
||||
// OpenCode binary operations
|
||||
selectOpenCodeBinary: () => Promise<string | null>
|
||||
validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }>
|
||||
// Storage operations
|
||||
getConfigPath: () => Promise<string>
|
||||
getInstancesDir: () => Promise<string>
|
||||
readConfigFile: () => Promise<string>
|
||||
writeConfigFile: (content: string) => Promise<void>
|
||||
readInstanceFile: (instanceId: string) => Promise<string>
|
||||
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
|
||||
deleteInstanceFile: (instanceId: string) => Promise<void>
|
||||
onConfigChanged: (callback: () => void) => () => void
|
||||
}
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
|
||||
createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
|
||||
ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
|
||||
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
|
||||
onInstanceStarted: (callback) => {
|
||||
ipcRenderer.on("instance:started", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceError: (callback) => {
|
||||
ipcRenderer.on("instance:error", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceStopped: (callback) => {
|
||||
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceLog: (callback) => {
|
||||
ipcRenderer.on("instance:log", (_, data) => callback(data))
|
||||
},
|
||||
onNewInstance: (callback) => {
|
||||
ipcRenderer.on("menu:newInstance", () => callback())
|
||||
},
|
||||
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
|
||||
// OpenCode binary operations
|
||||
selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"),
|
||||
validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path),
|
||||
// Storage operations
|
||||
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
|
||||
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
|
||||
readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"),
|
||||
writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content),
|
||||
readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename),
|
||||
writeInstanceFile: (filename: string, content: string) =>
|
||||
ipcRenderer.invoke("storage:writeInstanceFile", filename, content),
|
||||
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
|
||||
onConfigChanged: (callback: () => void) => {
|
||||
ipcRenderer.on("storage:configChanged", () => callback())
|
||||
return () => ipcRenderer.removeAllListeners("storage:configChanged")
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
8569
package-lock.json
generated
178
package.json
@@ -1,158 +1,34 @@
|
||||
{
|
||||
"name": "@opencode-ai/client",
|
||||
"version": "0.1.0",
|
||||
"description": "CodeNomad desktop client - multi-instance, multi-session AI coding interface",
|
||||
"author": "OpenCode Team",
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.2",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/server",
|
||||
"packages/ui",
|
||||
"packages/electron-app",
|
||||
"packages/tauri-app"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:electron": "NODE_ENV=development electron .",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
||||
"preview": "electron-vite preview",
|
||||
"build:binaries": "node scripts/build.js",
|
||||
"build:mac": "node scripts/build.js mac",
|
||||
"build:mac-x64": "node scripts/build.js mac-x64",
|
||||
"build:mac-arm64": "node scripts/build.js mac-arm64",
|
||||
"build:win": "node scripts/build.js win",
|
||||
"build:win-arm64": "node scripts/build.js win-arm64",
|
||||
"build:linux": "node scripts/build.js linux",
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"package:win": "electron-builder --win",
|
||||
"package:linux": "electron-builder --linux"
|
||||
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
|
||||
"dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
|
||||
"dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
|
||||
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:tauri": "npm run build --workspace @codenomad/tauri-app",
|
||||
"build:ui": "npm run build --workspace @codenomad/ui",
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||
},
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "0.15.13",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"ignore": "7.0.5",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "^1.0.9",
|
||||
"autoprefixer": "10.4.21",
|
||||
"electron": "39.0.0",
|
||||
"png2icons": "^2.0.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.opencode.client",
|
||||
"productName": "CodeNomad",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "electron/resources"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64",
|
||||
"universal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64",
|
||||
"universal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "tar.gz",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Development",
|
||||
"icon": "electron/resources/icon.png"
|
||||
}
|
||||
},
|
||||
"private": true
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/cloudflare/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist/
|
||||
1515
packages/cloudflare/package-lock.json
generated
Normal file
15
packages/cloudflare/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@codenomad/ui-host-worker",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||
"release:ui": "node ./scripts/release-ui.mjs",
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
4
packages/cloudflare/release-config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.11.4",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createHash } from "crypto"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const repoRoot = path.resolve(root, "..", "..")
|
||||
|
||||
const releaseConfigPath = path.join(root, "release-config.json")
|
||||
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||
const serverPackageJsonPath = path.join(repoRoot, "packages/server/package.json")
|
||||
|
||||
const distDir = path.join(root, "dist")
|
||||
const manifestPath = path.join(distDir, "version.json")
|
||||
|
||||
const args = new Set(process.argv.slice(2))
|
||||
|
||||
function getArgValue(flag) {
|
||||
const idx = process.argv.indexOf(flag)
|
||||
if (idx === -1) return null
|
||||
return process.argv[idx + 1] ?? null
|
||||
}
|
||||
|
||||
const zipPath = getArgValue("--zip")
|
||||
|
||||
if (!zipPath) {
|
||||
console.error("Usage: node scripts/build-manifest.mjs --zip <path-to-ui-zip>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const resolvedZipPath = path.resolve(process.cwd(), zipPath)
|
||||
if (!fs.existsSync(resolvedZipPath)) {
|
||||
console.error(`Zip not found: ${resolvedZipPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const releaseConfig = JSON.parse(fs.readFileSync(releaseConfigPath, "utf-8"))
|
||||
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||
const serverPackageJson = JSON.parse(fs.readFileSync(serverPackageJsonPath, "utf-8"))
|
||||
|
||||
const bucket = process.env.CODENOMAD_R2_BUCKET
|
||||
|
||||
if (!bucket) {
|
||||
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const uiVersion = uiPackageJson.version
|
||||
const serverVersion = serverPackageJson.version
|
||||
|
||||
if (!uiVersion || !serverVersion) {
|
||||
console.error("Missing version fields in package.json")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sha256 = createHash("sha256").update(fs.readFileSync(resolvedZipPath)).digest("hex")
|
||||
|
||||
const uiPackageURL = `https://download.codenomad.neuralnomads.ai/ui/ui-${uiVersion}.zip`
|
||||
|
||||
const manifest = {
|
||||
minServerVersion: releaseConfig.minServerVersion,
|
||||
latestUIVersion: uiVersion,
|
||||
uiPackageURL,
|
||||
sha256,
|
||||
latestServerVersion: serverVersion,
|
||||
latestServerUrl: releaseConfig.latestServerUrl,
|
||||
}
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true })
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
|
||||
|
||||
const headersPath = path.join(distDir, "_headers")
|
||||
fs.writeFileSync(
|
||||
headersPath,
|
||||
"/version.json\n Cache-Control: no-cache\n Content-Type: application/json; charset=utf-8\n",
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
console.log(`Wrote ${manifestPath}`)
|
||||
console.log(`Wrote ${headersPath}`)
|
||||
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import { execFileSync } from "child_process"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const repoRoot = path.resolve(root, "..", "..")
|
||||
|
||||
const r2Bucket = process.env.CODENOMAD_R2_BUCKET
|
||||
|
||||
if (!r2Bucket) {
|
||||
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||
const uiVersion = uiPackageJson.version
|
||||
|
||||
if (!uiVersion) {
|
||||
console.error("Missing packages/ui/package.json version")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist")
|
||||
if (!fs.existsSync(uiBuildDir)) {
|
||||
console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-release-"))
|
||||
const zipPath = path.join(tmpDir, `ui-${uiVersion}.zip`)
|
||||
|
||||
try {
|
||||
// Zip the CONTENTS of the dist dir (so index.html is at zip root).
|
||||
execFileSync("/usr/bin/zip", ["-q", "-r", zipPath, "."], { cwd: uiBuildDir, stdio: "inherit" })
|
||||
|
||||
// Upload to R2.
|
||||
const objectKey = `ui/ui-${uiVersion}.zip`
|
||||
console.log(`[release-ui] Uploading ${zipPath} -> r2://${r2Bucket}/${objectKey}`)
|
||||
|
||||
execFileSync(
|
||||
"npx",
|
||||
["wrangler", "r2", "object", "put", "--remote", `${r2Bucket}/${objectKey}`, "--file", zipPath],
|
||||
{ cwd: root, stdio: "inherit" },
|
||||
)
|
||||
|
||||
// Generate version.json into packages/cloudflare/dist
|
||||
console.log("[release-ui] Generating version.json")
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(root, "scripts/build-manifest.mjs"), "--zip", zipPath],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CODENOMAD_R2_BUCKET: r2Bucket,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
console.log("[release-ui] Deploying worker")
|
||||
execFileSync("npx", ["wrangler", "deploy"], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN,
|
||||
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
console.log("[release-ui] Done")
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
9
packages/cloudflare/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Env {
|
||||
ASSETS: { fetch: (request: Request) => Promise<Response> }
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
return env.ASSETS.fetch(request)
|
||||
},
|
||||
}
|
||||
14
packages/cloudflare/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
name = "codenomad-ui-host"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-01-22"
|
||||
|
||||
# Custom domain for the manifest host.
|
||||
# Note: Custom domains apply to all paths on the hostname.
|
||||
[[routes]]
|
||||
pattern = "ui.codenomad.neuralnomads.ai"
|
||||
custom_domain = true
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "404-page"
|
||||
4
packages/electron-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
40
packages/electron-app/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# CodeNomad App
|
||||
|
||||
This package contains the native desktop application shell for CodeNomad, built with [Electron](https://www.electronjs.org/).
|
||||
|
||||
## Overview
|
||||
|
||||
The Electron app wraps the CodeNomad UI and Server into a standalone executable. It provides deeper system integration, such as:
|
||||
- Native window management
|
||||
- Global keyboard shortcuts
|
||||
- Application menu integration
|
||||
|
||||
## Development
|
||||
|
||||
To run the Electron app in development mode:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will start the renderer (UI) and the main process with hot reloading.
|
||||
|
||||
## Building
|
||||
|
||||
To build the application for your current platform:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
To build for specific platforms (requires appropriate build tools):
|
||||
|
||||
- **macOS**: `npm run build:mac`
|
||||
- **Windows**: `npm run build:win`
|
||||
- **Linux**: `npm run build:linux`
|
||||
|
||||
## Structure
|
||||
|
||||
- `electron/main`: Main process code (window creation, IPC).
|
||||
- `electron/preload`: Preload scripts for secure bridge between main and renderer.
|
||||
- `electron/resources`: Static assets like icons.
|
||||
99
packages/electron-app/electron.vite.config.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { resolve } from "path"
|
||||
import { copyMonacoPublicAssets } from "../ui/scripts/monaco-public-assets.js"
|
||||
|
||||
const uiRoot = resolve(__dirname, "../ui")
|
||||
const uiSrc = resolve(uiRoot, "src")
|
||||
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||
|
||||
function prepareMonacoPublicAssets() {
|
||||
return {
|
||||
name: "prepare-monaco-public-assets",
|
||||
configureServer(server: any) {
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: uiRendererRoot,
|
||||
warn: (msg: string) => server.config.logger.warn(msg),
|
||||
sourceRoots: [
|
||||
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||
],
|
||||
})
|
||||
},
|
||||
buildStart(this: any) {
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: uiRendererRoot,
|
||||
warn: (msg: string) => this.warn(msg),
|
||||
sourceRoots: [
|
||||
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||
],
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: "dist/main",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/main/main.ts"),
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: "dist/preload",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/preload/index.cjs"),
|
||||
formats: ["cjs"],
|
||||
fileName: () => "index.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
output: {
|
||||
entryFileNames: "index.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: uiRendererRoot,
|
||||
plugins: [solid(), prepareMonacoPublicAssets()],
|
||||
css: {
|
||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": uiSrc,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
minify: false,
|
||||
cssMinify: false,
|
||||
sourcemap: true,
|
||||
outDir: resolve(__dirname, "dist/renderer"),
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: uiRendererEntry,
|
||||
loading: uiRendererLoadingEntry,
|
||||
},
|
||||
output: {
|
||||
compact: false,
|
||||
minifyInternalExports: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
132
packages/electron-app/electron/main/ipc.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
|
||||
interface DialogOpenRequest {
|
||||
mode: "directory" | "file"
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: Array<{ name?: string; extensions: string[] }>
|
||||
}
|
||||
|
||||
interface DialogOpenResult {
|
||||
canceled: boolean
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||
cliManager.on("status", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:status", status)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("ready", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:ready", status)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("error", (error: Error) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:error", { message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
||||
|
||||
ipcMain.handle("cli:restart", async () => {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
await cliManager.stop()
|
||||
return cliManager.start({ dev: devMode })
|
||||
})
|
||||
|
||||
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||
const properties: OpenDialogOptions["properties"] =
|
||||
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
|
||||
|
||||
const filters = request.filters?.map((filter) => ({
|
||||
name: filter.name ?? "Files",
|
||||
extensions: filter.extensions,
|
||||
}))
|
||||
|
||||
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
|
||||
const dialogOptions: OpenDialogOptions = {
|
||||
title: request.title,
|
||||
defaultPath: request.defaultPath,
|
||||
properties,
|
||||
filters,
|
||||
}
|
||||
const result = windowTarget
|
||||
? await dialog.showOpenDialog(windowTarget, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions)
|
||||
|
||||
return { canceled: result.canceled, paths: result.filePaths }
|
||||
})
|
||||
|
||||
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
||||
if (!Array.isArray(paths)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const directories = paths.filter((value): value is string => {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return fs.statSync(value).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return directories
|
||||
})
|
||||
|
||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) {
|
||||
return { enabled: true }
|
||||
}
|
||||
try {
|
||||
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
|
||||
} catch {
|
||||
wakeLockId = null
|
||||
return { enabled: false }
|
||||
}
|
||||
return { enabled: true }
|
||||
}
|
||||
|
||||
if (wakeLockId !== null) {
|
||||
try {
|
||||
if (powerSaveBlocker.isStarted(wakeLockId)) {
|
||||
powerSaveBlocker.stop(wakeLockId)
|
||||
}
|
||||
} finally {
|
||||
wakeLockId = null
|
||||
}
|
||||
}
|
||||
return { enabled: false }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"notifications:show",
|
||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||
if (!Notification.isSupported()) {
|
||||
return { ok: false, reason: "unsupported" }
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
|
||||
const body = typeof payload?.body === "string" ? payload.body : ""
|
||||
try {
|
||||
const notification = new Notification({ title, body })
|
||||
notification.show()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
522
packages/electron-app/electron/main/main.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import { existsSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = dirname(mainFilename)
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
const cliManager = new CliProcessManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
let pendingCliUrl: string | null = null
|
||||
let pendingBootstrapToken: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
}
|
||||
|
||||
function getIconPath() {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "icon.png")
|
||||
}
|
||||
|
||||
return join(mainDirname, "../resources/icon.png")
|
||||
}
|
||||
|
||||
type LoadingTarget =
|
||||
| { type: "url"; source: string }
|
||||
| { type: "file"; source: string }
|
||||
|
||||
function resolveDevLoadingUrl(): string | null {
|
||||
if (app.isPackaged) {
|
||||
return null
|
||||
}
|
||||
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
|
||||
if (!devBase) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
|
||||
return new URL("loading.html", normalized).toString()
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to construct dev loading URL", devBase, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLoadingTarget(): LoadingTarget {
|
||||
const devUrl = resolveDevLoadingUrl()
|
||||
if (devUrl) {
|
||||
return { type: "url", source: devUrl }
|
||||
}
|
||||
const filePath = resolveLoadingFilePath()
|
||||
return { type: "file", source: filePath }
|
||||
}
|
||||
|
||||
function resolveLoadingFilePath() {
|
||||
const candidates = [
|
||||
join(app.getAppPath(), "dist/renderer/loading.html"),
|
||||
join(process.resourcesPath, "dist/renderer/loading.html"),
|
||||
join(mainDirname, "../dist/renderer/loading.html"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return join(app.getAppPath(), "dist/renderer/loading.html")
|
||||
}
|
||||
|
||||
function loadLoadingScreen(window: BrowserWindow) {
|
||||
const target = resolveLoadingTarget()
|
||||
const loader =
|
||||
target.type === "url"
|
||||
? window.loadURL(target.source)
|
||||
: window.loadFile(target.source)
|
||||
|
||||
loader.catch((error) => {
|
||||
console.error("[cli] failed to load loading screen:", error)
|
||||
})
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
const origins = new Set<string>()
|
||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
origins.add(new URL(candidate).origin)
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to parse origin for", candidate, error)
|
||||
}
|
||||
}
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setupNavigationGuards(window: BrowserWindow) {
|
||||
const handleExternal = (url: string) => {
|
||||
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
return { action: "allow" }
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
event.preventDefault()
|
||||
handleExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
return cachedPreloadPath
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
join(process.resourcesPath, "preload/index.js"),
|
||||
join(mainDirname, "../preload/index.js"),
|
||||
join(mainDirname, "../preload/index.cjs"),
|
||||
join(mainDirname, "../../preload/index.cjs"),
|
||||
join(mainDirname, "../../electron/preload/index.cjs"),
|
||||
join(app.getAppPath(), "preload/index.cjs"),
|
||||
join(app.getAppPath(), "electron/preload/index.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
cachedPreloadPath = candidate
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return join(mainDirname, "../preload/index.js")
|
||||
}
|
||||
|
||||
function destroyPreloadingView(target?: BrowserView | null) {
|
||||
const view = target ?? preloadingView
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = view.webContents as any
|
||||
contents?.destroy?.()
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to destroy preloading view", error)
|
||||
}
|
||||
|
||||
if (!target || view === preloadingView) {
|
||||
preloadingView = null
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const prefersDark = true
|
||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||
const iconPath = getIconPath()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
showingLoadingScreen = false
|
||||
})
|
||||
|
||||
if (pendingCliUrl) {
|
||||
const url = pendingCliUrl
|
||||
pendingCliUrl = null
|
||||
startCliPreload(url)
|
||||
}
|
||||
}
|
||||
|
||||
function showLoadingScreen(force = false) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (showingLoadingScreen && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
destroyPreloadingView()
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
}
|
||||
|
||||
function isBootstrapTokenUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function startCliPreload(url: string) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
return
|
||||
}
|
||||
|
||||
if (currentCliUrl === url && !showingLoadingScreen) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingCliUrl = url
|
||||
destroyPreloadingView()
|
||||
|
||||
if (!showingLoadingScreen) {
|
||||
showLoadingScreen(true)
|
||||
}
|
||||
|
||||
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
|
||||
// consuming the token in the hidden view and then failing in the main window.
|
||||
if (isBootstrapTokenUrl(url)) {
|
||||
finalizeCliSwap(url)
|
||||
return
|
||||
}
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
preloadingView = view
|
||||
|
||||
view.webContents.once("did-finish-load", () => {
|
||||
if (preloadingView !== view) {
|
||||
destroyPreloadingView(view)
|
||||
return
|
||||
}
|
||||
finalizeCliSwap(url)
|
||||
})
|
||||
|
||||
view.webContents.loadURL(url).catch((error) => {
|
||||
console.error("[cli] failed to preload CLI view:", error)
|
||||
if (preloadingView === view) {
|
||||
destroyPreloadingView(view)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function finalizeCliSwap(url: string) {
|
||||
destroyPreloadingView()
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
return
|
||||
}
|
||||
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
pendingCliUrl = null
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||
let bootstrapExchangeInFlight = false
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
|
||||
if (!raw) return null
|
||||
|
||||
const first = raw.split(";")[0] ?? ""
|
||||
const index = first.indexOf("=")
|
||||
if (index < 0) return null
|
||||
|
||||
const key = first.slice(0, index).trim()
|
||||
const value = first.slice(index + 1).trim()
|
||||
if (key !== name || !value) return null
|
||||
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
const transport = target.protocol === "https:" ? https : http
|
||||
|
||||
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
|
||||
const req = transport.request(
|
||||
target,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
|
||||
},
|
||||
)
|
||||
|
||||
req.on("error", reject)
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
|
||||
if (result.statusCode !== 200) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function startCli() {
|
||||
try {
|
||||
// In desktop dev workflows we always want the CLI to run in dev mode so it:
|
||||
// - uses plain HTTP
|
||||
// - proxies UI requests to the renderer dev server
|
||||
// Monaco's AMD assets are served from that dev server.
|
||||
const devMode = !app.isPackaged
|
||||
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||
await cliManager.start({ dev: devMode })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error("[cli] start failed:", message)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:error", { message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeExchangeAndNavigate(baseUrl: string) {
|
||||
if (bootstrapExchangeInFlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = pendingBootstrapToken
|
||||
if (!token) {
|
||||
startCliPreload(baseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
bootstrapExchangeInFlight = true
|
||||
|
||||
try {
|
||||
const ok = await exchangeBootstrapToken(baseUrl, token)
|
||||
pendingBootstrapToken = null
|
||||
|
||||
if (!ok) {
|
||||
startCliPreload(`${baseUrl}/login`)
|
||||
return
|
||||
}
|
||||
|
||||
startCliPreload(baseUrl)
|
||||
} catch (error) {
|
||||
console.error("[cli] bootstrap token exchange failed:", error)
|
||||
pendingBootstrapToken = null
|
||||
startCliPreload(`${baseUrl}/login`)
|
||||
} finally {
|
||||
bootstrapExchangeInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
cliManager.on("bootstrapToken", (token) => {
|
||||
pendingBootstrapToken = token
|
||||
|
||||
const status = cliManager.getStatus()
|
||||
if (status.url) {
|
||||
void maybeExchangeAndNavigate(status.url)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("ready", (status) => {
|
||||
if (!status.url) {
|
||||
return
|
||||
}
|
||||
|
||||
void maybeExchangeAndNavigate(status.url)
|
||||
})
|
||||
|
||||
cliManager.on("status", (status) => {
|
||||
if (status.state !== "ready") {
|
||||
showLoadingScreen()
|
||||
}
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Required for Windows notifications / taskbar grouping.
|
||||
// Keep in sync with desktop app identifier.
|
||||
try {
|
||||
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startCli()
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
|
||||
if (app.dock) {
|
||||
const dockIcon = nativeImage.createFromPath(getIconPath())
|
||||
if (!dockIcon.isEmpty()) {
|
||||
app.dock.setIcon(dockIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await cliManager.stop().catch(() => {})
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// CodeNomad supports a single window; closing it should quit the app on all platforms.
|
||||
app.quit()
|
||||
})
|
||||
522
packages/electron-app/electron/main/process-manager.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { parse as parseYaml } from "yaml"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
|
||||
export interface CliStatus {
|
||||
state: CliState
|
||||
pid?: number
|
||||
port?: number
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CliLogEntry {
|
||||
stream: "stdout" | "stderr"
|
||||
message: string
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
dev: boolean
|
||||
}
|
||||
|
||||
interface CliEntryResolution {
|
||||
entry: string
|
||||
runner: "node" | "tsx"
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase()
|
||||
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||
}
|
||||
|
||||
function isJsonPath(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith(".json")
|
||||
}
|
||||
|
||||
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
|
||||
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
|
||||
const resolved = resolveConfigPath(target)
|
||||
|
||||
if (isYamlPath(resolved)) {
|
||||
const baseDir = path.dirname(resolved)
|
||||
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
|
||||
}
|
||||
|
||||
if (isJsonPath(resolved)) {
|
||||
const baseDir = path.dirname(resolved)
|
||||
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
|
||||
}
|
||||
|
||||
// Treat as directory.
|
||||
return {
|
||||
configYamlPath: path.join(resolved, "config.yaml"),
|
||||
legacyJsonPath: path.join(resolved, "config.json"),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(configPath?: string): string {
|
||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||
if (target.startsWith("~/")) {
|
||||
return path.join(os.homedir(), target.slice(2))
|
||||
}
|
||||
return path.resolve(target)
|
||||
}
|
||||
|
||||
function resolveHostForMode(mode: ListeningMode): string {
|
||||
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
|
||||
}
|
||||
|
||||
function readListeningModeFromConfig(): ListeningMode {
|
||||
try {
|
||||
const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
|
||||
|
||||
let parsed: any = null
|
||||
if (existsSync(configYamlPath)) {
|
||||
const content = readFileSync(configYamlPath, "utf-8")
|
||||
parsed = parseYaml(content)
|
||||
} else if (existsSync(legacyJsonPath)) {
|
||||
const content = readFileSync(legacyJsonPath, "utf-8")
|
||||
parsed = JSON.parse(content)
|
||||
} else {
|
||||
return "local"
|
||||
}
|
||||
|
||||
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
||||
if (mode === "local" || mode === "all") {
|
||||
return mode
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to read listening mode from config", error)
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
export declare interface CliProcessManager {
|
||||
on(event: "status", listener: (status: CliStatus) => void): this
|
||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||
on(event: "bootstrapToken", listener: (token: string) => void): this
|
||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||
on(event: "error", listener: (error: Error) => void): this
|
||||
}
|
||||
|
||||
export class CliProcessManager extends EventEmitter {
|
||||
private child?: ChildProcess
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
await this.stop()
|
||||
}
|
||||
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
if (!child.pid) {
|
||||
console.error("[cli] spawn failed: no pid")
|
||||
}
|
||||
|
||||
this.child = child
|
||||
this.updateStatus({ pid: child.pid ?? undefined })
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
this.handleStream(data.toString(), "stdout")
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
this.handleStream(data.toString(), "stderr")
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
|
||||
return new Promise<CliStatus>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.handleTimeout()
|
||||
reject(new Error("CLI startup timeout"))
|
||||
}, 60000)
|
||||
|
||||
this.once("ready", (status) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(status)
|
||||
})
|
||||
|
||||
this.once("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child
|
||||
if (!child) {
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
// Negative PID targets the process group (POSIX).
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryKillSinglePid = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryTaskkill = (force: boolean) => {
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync("taskkill", args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("not found") || combined.includes("no running instance")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
|
||||
const groupOk = tryKillPosixGroup(signal)
|
||||
if (!groupOk) {
|
||||
tryKillSinglePid(signal)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
})
|
||||
|
||||
if (isAlreadyExited()) {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
sendStopSignal("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(): CliStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
const pid = this.child.pid
|
||||
if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} catch {
|
||||
this.child.kill("SIGKILL")
|
||||
}
|
||||
} else {
|
||||
this.child.kill("SIGKILL")
|
||||
}
|
||||
this.child = undefined
|
||||
}
|
||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||
this.emit("error", new Error("CLI did not start in time"))
|
||||
}
|
||||
|
||||
private handleStream(chunk: string, stream: "stdout" | "stderr") {
|
||||
if (stream === "stdout") {
|
||||
this.stdoutBuffer += chunk
|
||||
this.processBuffer("stdout")
|
||||
} else {
|
||||
this.stderrBuffer += chunk
|
||||
this.processBuffer("stderr")
|
||||
}
|
||||
}
|
||||
|
||||
private processBuffer(stream: "stdout" | "stderr") {
|
||||
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
|
||||
const lines = buffer.split("\n")
|
||||
const trailing = lines.pop() ?? ""
|
||||
|
||||
if (stream === "stdout") {
|
||||
this.stdoutBuffer = trailing
|
||||
} else {
|
||||
this.stderrBuffer = trailing
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
||||
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
||||
if (token && !this.bootstrapToken) {
|
||||
this.bootstrapToken = token
|
||||
this.emit("bootstrapToken", token)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
console.info(`[cli][${stream}] ${trimmed}`)
|
||||
this.emit("log", { stream, message: trimmed })
|
||||
|
||||
const localUrl = this.extractLocalUrl(trimmed)
|
||||
if (localUrl && this.status.state === "starting") {
|
||||
let port: number | undefined
|
||||
try {
|
||||
port = Number(new URL(localUrl).port) || undefined
|
||||
} catch {
|
||||
port = undefined
|
||||
}
|
||||
console.info(`[cli] ready on ${localUrl}`)
|
||||
this.updateStatus({ state: "ready", port, url: localUrl })
|
||||
this.emit("ready", this.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractLocalUrl(line: string): string | null {
|
||||
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return match[1] ?? null
|
||||
}
|
||||
|
||||
private updateStatus(patch: Partial<CliStatus>) {
|
||||
this.status = { ...this.status, ...patch }
|
||||
this.emit("status", this.status)
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--generate-token"]
|
||||
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
args.push("--https", "false", "--http", "true")
|
||||
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
|
||||
// by forcing an ephemeral port in dev.
|
||||
args.push("--http-port", "0")
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https", "true", "--http", "true")
|
||||
}
|
||||
|
||||
if (options.dev) {
|
||||
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
|
||||
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
|
||||
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||
const parts = [JSON.stringify(process.execPath)]
|
||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||
}
|
||||
parts.push(JSON.stringify(cliEntry.entry))
|
||||
args.forEach((arg) => parts.push(JSON.stringify(arg)))
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||
if (cliEntry.runner === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
}
|
||||
|
||||
return { command: process.execPath, args: [cliEntry.entry, ...args] }
|
||||
}
|
||||
|
||||
private resolveCliEntry(options: StartOptions): CliEntryResolution {
|
||||
if (options.dev) {
|
||||
const tsxPath = this.resolveTsx()
|
||||
if (!tsxPath) {
|
||||
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
|
||||
}
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
}
|
||||
|
||||
private resolveTsx(): string | null {
|
||||
const candidates: Array<string | (() => string)> = [
|
||||
() => nodeRequire.resolve("tsx/cli"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
|
||||
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const resolved = typeof candidate === "function" ? candidate() : candidate
|
||||
if (resolved && existsSync(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveDevEntry(): string {
|
||||
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private resolveProdEntry(): string {
|
||||
try {
|
||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||
if (existsSync(entry)) {
|
||||
return entry
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { join } from "path"
|
||||
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const CONFIG_DIR = join(app.getPath("home"), ".config", "opencode-client")
|
||||
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
|
||||
const INSTANCES_DIR = join(CONFIG_DIR, "instances")
|
||||
|
||||
@@ -59,7 +59,7 @@ export function setupStorageIPC() {
|
||||
return await readConfigWithCache()
|
||||
} catch (error) {
|
||||
// Return empty config if file doesn't exist
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
139
packages/electron-app/electron/main/user-shell.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { spawn, spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
interface ShellCommand {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
|
||||
function getDefaultShellPath(): string {
|
||||
if (process.env.SHELL && process.env.SHELL.trim().length > 0) {
|
||||
return process.env.SHELL
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return "/bin/zsh"
|
||||
}
|
||||
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
function wrapCommandForShell(command: string, shellPath: string): string {
|
||||
const shellName = path.basename(shellPath)
|
||||
|
||||
if (shellName.includes("bash")) {
|
||||
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
if (shellName.includes("zsh")) {
|
||||
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
function buildShellArgs(shellPath: string): string[] {
|
||||
const shellName = path.basename(shellPath)
|
||||
if (shellName.includes("zsh")) {
|
||||
return ["-l", "-i", "-c"]
|
||||
}
|
||||
return ["-l", "-c"]
|
||||
}
|
||||
|
||||
function sanitizeShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const cleaned = { ...env }
|
||||
delete cleaned.npm_config_prefix
|
||||
delete cleaned.NPM_CONFIG_PREFIX
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export function supportsUserShell(): boolean {
|
||||
return !isWindows
|
||||
}
|
||||
|
||||
export function buildUserShellCommand(userCommand: string): ShellCommand {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const shellPath = getDefaultShellPath()
|
||||
const script = wrapCommandForShell(userCommand, shellPath)
|
||||
const args = buildShellArgs(shellPath)
|
||||
|
||||
return {
|
||||
command: shellPath,
|
||||
args: [...args, script],
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserShellEnv(): NodeJS.ProcessEnv {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
return sanitizeShellEnv(process.env)
|
||||
}
|
||||
|
||||
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
|
||||
if (!supportsUserShell()) {
|
||||
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function runUserShellCommandSync(userCommand: string): string {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
const result = spawnSync(command, args, { encoding: "utf-8", env })
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || "").toString().trim()
|
||||
throw new Error(stderr || "Shell command failed")
|
||||
}
|
||||
|
||||
return (result.stdout || "").toString().trim()
|
||||
}
|
||||
27
packages/electron-app/electron/preload/index.cjs
Normal file
@@ -0,0 +1,27 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
},
|
||||
onCliError: (callback) => {
|
||||
ipcRenderer.on("cli:error", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:error")
|
||||
},
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.node.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
147
packages/electron-app/package.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.2",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||
},
|
||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||
"scripts": {
|
||||
"dev": "npm run dev:info",
|
||||
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
|
||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
"build:binaries": "node scripts/build.js",
|
||||
"build:mac": "node scripts/build.js mac",
|
||||
"build:mac-x64": "node scripts/build.js mac-x64",
|
||||
"build:mac-arm64": "node scripts/build.js mac-arm64",
|
||||
"build:win": "node scripts/build.js win",
|
||||
"build:win-arm64": "node scripts/build.js win-arm64",
|
||||
"build:linux": "node scripts/build.js linux",
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"package:win": "electron-builder --win",
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neuralnomads/codenomad": "file:../server",
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
"png2icons": "^2.0.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.opencode.client",
|
||||
"productName": "CodeNomad",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "electron/resources"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources",
|
||||
"to": "",
|
||||
"filter": [
|
||||
"!icon.icns",
|
||||
"!icon.ico"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../server/dist/opencode-config",
|
||||
"to": "opencode-config"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Development",
|
||||
"icon": "electron/resources/icon.png"
|
||||
}
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -2,20 +2,22 @@
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import path, { join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const appDir = join(__dirname, "..")
|
||||
const workspaceRoot = join(appDir, "..", "..")
|
||||
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
const nodeModulesPath = join(appDir, "node_modules")
|
||||
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
args: ["--mac", "--x64", "--arm64", "--universal"],
|
||||
description: "macOS (Intel, Apple Silicon, Universal)",
|
||||
args: ["--mac", "--x64", "--arm64"],
|
||||
description: "macOS (Intel & Apple Silicon)",
|
||||
},
|
||||
"mac-x64": {
|
||||
args: ["--mac", "--x64"],
|
||||
@@ -41,6 +43,10 @@ const platforms = {
|
||||
args: ["--linux", "--arm64"],
|
||||
description: "Linux (ARM64)",
|
||||
},
|
||||
"linux-rpm": {
|
||||
args: ["--linux", "rpm", "--x64", "--arm64"],
|
||||
description: "Linux RPM packages (x64 & ARM64)",
|
||||
},
|
||||
all: {
|
||||
args: ["--mac", "--win", "--linux", "--x64", "--arm64"],
|
||||
description: "All platforms (macOS, Windows, Linux)",
|
||||
@@ -49,11 +55,22 @@ const platforms = {
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env = { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }
|
||||
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
|
||||
|
||||
const binPaths = [
|
||||
join(nodeModulesPath, ".bin"),
|
||||
join(workspaceNodeModulesPath, ".bin"),
|
||||
]
|
||||
|
||||
env[pathKey] = `${binPaths.join(path.delimiter)}${path.delimiter}${env[pathKey] ?? ""}`
|
||||
|
||||
const spawnOptions = {
|
||||
cwd: appDir,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
|
||||
env,
|
||||
}
|
||||
|
||||
const child = spawn(command, args, spawnOptions)
|
||||
@@ -88,16 +105,22 @@ async function build(platform) {
|
||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||
|
||||
try {
|
||||
console.log("📦 Step 1/2: Building Electron app...\n")
|
||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
console.log("\n📦 Step 2/2: Packaging binaries...\n")
|
||||
console.log("\n📦 Step 3/3: Packaging binaries...\n")
|
||||
const distPath = join(appDir, "dist")
|
||||
if (!existsSync(distPath)) {
|
||||
throw new Error("dist/ directory not found. Build failed.")
|
||||
}
|
||||
|
||||
await run(npxCmd, ["electron-builder", ...config.args])
|
||||
await run(npxCmd, ["electron-builder", "--publish=never", ...config.args])
|
||||
|
||||
console.log("\n✅ Build complete!")
|
||||
console.log(`📁 Binaries available in: ${join(appDir, "release")}\n`)
|
||||
32
packages/opencode-config/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# opencode-config
|
||||
|
||||
## TLDR
|
||||
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
|
||||
|
||||
## What it is
|
||||
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
|
||||
|
||||
## How it works
|
||||
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
|
||||
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
|
||||
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
|
||||
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
|
||||
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
|
||||
|
||||
## Expectations
|
||||
- Local-only bridge (no auth/token yet).
|
||||
- Plugin must fail startup if it cannot connect after 3 retries.
|
||||
- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
|
||||
- Keep event shapes small and explicit; use `type` + `properties` only.
|
||||
|
||||
## Ideas
|
||||
- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
|
||||
- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
|
||||
- Promote stable event shapes and version tags once the protocol settles.
|
||||
|
||||
## Pointers
|
||||
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
|
||||
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
|
||||
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
|
||||
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
|
||||
- Workspace env injection: `packages/server/src/workspaces/manager.ts`
|
||||
3
packages/opencode-config/opencode.jsonc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
9
packages/opencode-config/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@codenomad/opencode-config",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.14"
|
||||
}
|
||||
}
|
||||
32
packages/opencode-config/plugin/codenomad.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||
|
||||
export async function CodeNomadPlugin(input: PluginInput) {
|
||||
const config = getCodeNomadConfig()
|
||||
const client = createCodeNomadClient(config)
|
||||
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
|
||||
|
||||
await client.startEvents((event) => {
|
||||
if (event.type === "codenomad.ping") {
|
||||
void client.postEvent({
|
||||
type: "codenomad.pong",
|
||||
properties: {
|
||||
ts: Date.now(),
|
||||
pingTs: (event.properties as any)?.ts,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
tool: {
|
||||
...backgroundProcessTools,
|
||||
},
|
||||
async event(input: { event: any }) {
|
||||
const opencodeEvent = input?.event
|
||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||
|
||||
},
|
||||
}
|
||||
}
|
||||
253
packages/opencode-config/plugin/lib/background-process.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import path from "path"
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
|
||||
|
||||
type BackgroundProcess = {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
status: "running" | "stopped" | "error"
|
||||
startedAt: string
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
type BackgroundProcessOptions = {
|
||||
baseDir: string
|
||||
}
|
||||
|
||||
type ParsedCommand = {
|
||||
head: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
||||
const requester = createCodeNomadRequester(config)
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
return requester.requestJson<T>(`/background-processes${path}`, init)
|
||||
}
|
||||
|
||||
return {
|
||||
run_background_process: tool({
|
||||
description:
|
||||
"Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
|
||||
args: {
|
||||
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
||||
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
||||
},
|
||||
async execute(args) {
|
||||
assertCommandWithinBase(args.command, options.baseDir)
|
||||
const process = await request<BackgroundProcess>("", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: args.title, command: args.command }),
|
||||
})
|
||||
|
||||
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
||||
},
|
||||
}),
|
||||
list_background_processes: tool({
|
||||
description: "List background processes running for this workspace.",
|
||||
args: {},
|
||||
async execute() {
|
||||
const response = await request<{ processes: BackgroundProcess[] }>("")
|
||||
if (response.processes.length === 0) {
|
||||
return "No background processes running."
|
||||
}
|
||||
|
||||
return response.processes
|
||||
.map((process) => {
|
||||
const status = process.status === "running" ? "running" : process.status
|
||||
const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
|
||||
const size =
|
||||
typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
|
||||
return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
|
||||
})
|
||||
.join("\n")
|
||||
},
|
||||
}),
|
||||
read_background_process_output: tool({
|
||||
description: "Read output from a background process. Use full, grep, head, or tail.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
method: tool.schema
|
||||
.enum(["full", "grep", "head", "tail"])
|
||||
.default("full")
|
||||
.describe("Method to read output"),
|
||||
pattern: tool.schema.string().optional().describe("Pattern for grep method"),
|
||||
lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
|
||||
},
|
||||
async execute(args) {
|
||||
if (args.method === "grep" && !args.pattern) {
|
||||
return "Pattern is required for grep method."
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ method: args.method })
|
||||
if (args.pattern) {
|
||||
params.set("pattern", args.pattern)
|
||||
}
|
||||
if (args.lines) {
|
||||
params.set("lines", String(args.lines))
|
||||
}
|
||||
|
||||
const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
|
||||
`/${args.id}/output?${params.toString()}`,
|
||||
)
|
||||
|
||||
const header = response.truncated
|
||||
? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
|
||||
: `Output (${Math.round(response.sizeBytes / 1024)}KB):`
|
||||
|
||||
return `${header}\n\n${response.content}`
|
||||
},
|
||||
}),
|
||||
stop_background_process: tool({
|
||||
description: "Stop a background process (SIGTERM) but keep its output and entry.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
|
||||
return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
|
||||
},
|
||||
}),
|
||||
terminate_background_process: tool({
|
||||
description: "Terminate a background process and delete its output + entry.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
await request<void>(`/${args.id}/terminate`, { method: "POST" })
|
||||
return `Terminated background process ${args.id} and removed its output.`
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"])
|
||||
const EXPANSION_CHARS = /[~*$?\[\]`$]/
|
||||
|
||||
function assertCommandWithinBase(command: string, baseDir: string) {
|
||||
const normalizedBase = path.resolve(baseDir)
|
||||
const commands = splitCommands(command)
|
||||
|
||||
for (const item of commands) {
|
||||
if (!FILE_COMMANDS.has(item.head)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const arg of item.args) {
|
||||
if (!arg) continue
|
||||
if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue
|
||||
|
||||
const literalArg = unquote(arg)
|
||||
if (EXPANSION_CHARS.test(literalArg)) {
|
||||
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
|
||||
}
|
||||
|
||||
const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg)
|
||||
if (!isWithinBase(normalizedBase, resolved)) {
|
||||
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitCommands(command: string): ParsedCommand[] {
|
||||
const tokens = tokenize(command)
|
||||
const commands: ParsedCommand[] = []
|
||||
let current: string[] = []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (isSeparator(token)) {
|
||||
if (current.length > 0) {
|
||||
commands.push({ head: current[0], args: current.slice(1) })
|
||||
current = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.push(token)
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
commands.push({ head: current[0], args: current.slice(1) })
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function tokenize(input: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let current = ""
|
||||
let quote: "'" | '"' | null = null
|
||||
let escape = false
|
||||
|
||||
const flush = () => {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current)
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index]
|
||||
|
||||
if (escape) {
|
||||
current += char
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "\\" && quote !== "'") {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
current += char
|
||||
if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char
|
||||
current += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === " " || char === "\n" || char === "\t") {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "|" || char === "&" || char === ";") {
|
||||
flush()
|
||||
tokens.push(char)
|
||||
continue
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
flush()
|
||||
return tokens
|
||||
}
|
||||
|
||||
function isSeparator(token: string): boolean {
|
||||
return token === "|" || token === "&" || token === ";"
|
||||
}
|
||||
|
||||
function unquote(token: string): string {
|
||||
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||||
return token.slice(1, -1)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
function isWithinBase(base: string, candidate: string): boolean {
|
||||
const relative = path.relative(base, candidate)
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
}
|
||||
133
packages/opencode-config/plugin/lib/client.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||
|
||||
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||
|
||||
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||
const requester = createCodeNomadRequester(config)
|
||||
|
||||
return {
|
||||
postEvent: (event: PluginEvent) =>
|
||||
requester.requestVoid("/event", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(event),
|
||||
}),
|
||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function startPluginEvents(
|
||||
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||
onEvent: (event: PluginEvent) => void,
|
||||
) {
|
||||
// Fail plugin startup if we cannot establish the initial connection.
|
||||
const initialBody = await connectWithRetries(requester, 3)
|
||||
|
||||
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||
void consumeWithReconnect(requester, onEvent, initialBody)
|
||||
}
|
||||
|
||||
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await requester.requestSseBody("/events")
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await delay(500 * attempt)
|
||||
}
|
||||
}
|
||||
|
||||
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||
const url = requester.buildUrl("/events")
|
||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
|
||||
}
|
||||
|
||||
async function consumeWithReconnect(
|
||||
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||
onEvent: (event: PluginEvent) => void,
|
||||
initialBody: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
let consecutiveFailures = 0
|
||||
let body: ReadableStream<Uint8Array> | null = initialBody
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
if (!body) {
|
||||
body = await connectWithRetries(requester, 3)
|
||||
}
|
||||
|
||||
await consumeSseBody(body, onEvent)
|
||||
body = null
|
||||
consecutiveFailures = 0
|
||||
} catch (error) {
|
||||
body = null
|
||||
consecutiveFailures += 1
|
||||
if (consecutiveFailures >= 3) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
|
||||
}
|
||||
await delay(500 * consecutiveFailures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !value) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let separatorIndex = buffer.indexOf("\n\n")
|
||||
while (separatorIndex >= 0) {
|
||||
const chunk = buffer.slice(0, separatorIndex)
|
||||
buffer = buffer.slice(separatorIndex + 2)
|
||||
separatorIndex = buffer.indexOf("\n\n")
|
||||
|
||||
const event = parseSseChunk(chunk)
|
||||
if (event) {
|
||||
onEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("SSE stream ended")
|
||||
}
|
||||
|
||||
function parseSseChunk(chunk: string): PluginEvent | null {
|
||||
const lines = chunk.split(/\r?\n/)
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(":")) continue
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) return null
|
||||
|
||||
const payload = dataLines.join("\n").trim()
|
||||
if (!payload) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payload)
|
||||
if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
|
||||
return null
|
||||
}
|
||||
return parsed as PluginEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
214
packages/opencode-config/plugin/lib/request.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import http from "http"
|
||||
import https from "https"
|
||||
import { Readable } from "stream"
|
||||
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
return {
|
||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const rawBaseUrl = (config.baseUrl ?? "").trim()
|
||||
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
|
||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||
const authorization = buildInstanceAuthorizationHeader()
|
||||
|
||||
const buildUrl = (path: string) => {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
}
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||
return `${pluginBase}${normalized}`
|
||||
}
|
||||
|
||||
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
|
||||
const output: Record<string, string> = normalizeHeaders(headers)
|
||||
output.Authorization = authorization
|
||||
if (hasBody) {
|
||||
output["Content-Type"] = output["Content-Type"] ?? "application/json"
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
|
||||
const url = buildUrl(path)
|
||||
const hasBody = init?.body !== undefined
|
||||
const headers = buildHeaders(init?.headers, hasBody)
|
||||
|
||||
// The CodeNomad plugin only talks to the local CodeNomad server.
|
||||
// Use a single request implementation that tolerates custom/self-signed certs
|
||||
// without disabling TLS verification for the whole Node process.
|
||||
return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
|
||||
}
|
||||
|
||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await fetchWithAuth(path, init)
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
|
||||
const response = await fetchWithAuth(path, init)
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
|
||||
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`SSE unavailable (${response.status})`)
|
||||
}
|
||||
return response.body as ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
fetch: fetchWithAuth,
|
||||
requestJson,
|
||||
requestVoid,
|
||||
requestSseBody,
|
||||
}
|
||||
}
|
||||
|
||||
async function nodeFetch(
|
||||
url: string,
|
||||
init: RequestInit & { headers?: Record<string, string> },
|
||||
tls: { rejectUnauthorized: boolean },
|
||||
): Promise<Response> {
|
||||
const parsed = new URL(url)
|
||||
const isHttps = parsed.protocol === "https:"
|
||||
const requestFn = isHttps ? https.request : http.request
|
||||
|
||||
const method = (init.method ?? "GET").toUpperCase()
|
||||
const headers = init.headers ?? {}
|
||||
const body = init.body
|
||||
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
const req = requestFn(
|
||||
{
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method,
|
||||
headers,
|
||||
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
|
||||
},
|
||||
(res) => {
|
||||
const responseHeaders = new Headers()
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
responseHeaders.set(key, value.join(", "))
|
||||
} else {
|
||||
responseHeaders.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Node stream -> Web ReadableStream for Response.
|
||||
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
|
||||
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
|
||||
},
|
||||
)
|
||||
|
||||
const signal = init.signal
|
||||
const abort = () => {
|
||||
const err = new Error("Request aborted")
|
||||
;(err as any).name = "AbortError"
|
||||
req.destroy(err)
|
||||
reject(err)
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
req.once("close", () => signal.removeEventListener("abort", abort))
|
||||
}
|
||||
|
||||
req.once("error", reject)
|
||||
|
||||
if (body === undefined || body === null) {
|
||||
req.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
req.end(body)
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof Uint8Array) {
|
||||
req.end(Buffer.from(body))
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
req.end(Buffer.from(new Uint8Array(body)))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for less common BodyInit types.
|
||||
req.end(String(body))
|
||||
})
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function buildInstanceAuthorizationHeader(): string {
|
||||
const username = requireEnv("OPENCODE_SERVER_USERNAME")
|
||||
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
|
||||
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||
return `Basic ${token}`
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
}
|
||||
4
packages/server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
public/
|
||||
|
||||
# Local developer config (may contain secrets)
|
||||
config-*.json
|
||||
5
packages/server/.npmignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
scripts/
|
||||
src/
|
||||
tsconfig.json
|
||||
*.tsbuildinfo
|
||||
173
packages/server/README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# CodeNomad Server
|
||||
|
||||
**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace.
|
||||
|
||||
## Features & Capabilities
|
||||
|
||||
### 🌍 Deployment Freedom
|
||||
|
||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||
|
||||
### ⚡️ Workspace Power
|
||||
|
||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- Node.js 18+ and npm (for running or building from source).
|
||||
- A workspace folder on disk you want to serve.
|
||||
- Optional: a Chromium-based browser if you want `--launch` to open the UI automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
### Run via npx (Recommended)
|
||||
|
||||
You can run CodeNomad directly without installing it:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
To list all CLI options:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
|
||||
On startup, CodeNomad prints two URLs:
|
||||
|
||||
- `Local Connection URL : ...` (used by desktop shells)
|
||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||
|
||||
### Install Globally
|
||||
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
```sh
|
||||
npm install -g @neuralnomads/codenomad
|
||||
codenomad --launch
|
||||
```
|
||||
|
||||
### Install Locally (per-project)
|
||||
|
||||
If you prefer to install CodeNomad into a project and run the local binary:
|
||||
|
||||
```sh
|
||||
npm install @neuralnomads/codenomad
|
||||
npx codenomad --launch
|
||||
```
|
||||
|
||||
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||
|
||||
### Common Flags
|
||||
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
|------|--------------|-------------|
|
||||
| `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
|
||||
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
|
||||
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
|
||||
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
|
||||
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
|
||||
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
|
||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. |
|
||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
|
||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
||||
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
|
||||
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
||||
|
||||
### Dev Releases (Advanced)
|
||||
|
||||
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
These environment variables control how CodeNomad checks for dev updates:
|
||||
|
||||
| Env Variable | Description |
|
||||
|-------------|-------------|
|
||||
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
|
||||
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
|
||||
|
||||
### HTTP vs HTTPS
|
||||
|
||||
- Default: `--https=true --http=false` (HTTPS only).
|
||||
- To run plain HTTP only (useful for development):
|
||||
|
||||
```sh
|
||||
codenomad --https=false --http=true
|
||||
```
|
||||
|
||||
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
|
||||
|
||||
```sh
|
||||
codenomad --https=true --http=true
|
||||
```
|
||||
|
||||
### Remote Access Binding Rules
|
||||
|
||||
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
|
||||
- HTTP listens on `127.0.0.1` only.
|
||||
- HTTPS listens on `--host` (LAN/all interfaces).
|
||||
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
|
||||
- Both HTTP and HTTPS listen on `127.0.0.1`.
|
||||
|
||||
### Self-Signed Certificates
|
||||
|
||||
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
|
||||
|
||||
- `~/.config/codenomad/tls/ca-cert.pem`
|
||||
- `~/.config/codenomad/tls/server-cert.pem`
|
||||
|
||||
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
|
||||
|
||||
```sh
|
||||
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
|
||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||
|
||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||
2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad".
|
||||
3. The app will open in a standalone window and appear in your OS app list.
|
||||
|
||||
> **TLS requirement**
|
||||
> Browsers require a secure (`https://`) connection for PWA installation.
|
||||
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
|
||||
### Data Storage
|
||||
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
2103
packages/server/package-lock.json
generated
Normal file
50
packages/server/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
|
||||
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
|
||||
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)
|
||||
61
packages/server/scripts/copy-opencode-config.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "child_process"
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const sourceDir = path.resolve(cliRoot, "../opencode-config")
|
||||
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
|
||||
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
|
||||
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!existsSync(nodeModulesDir)) {
|
||||
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
|
||||
|
||||
const npmArgs = [
|
||||
"install",
|
||||
"--prefix",
|
||||
sourceDir,
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--fund=false",
|
||||
"--audit=false",
|
||||
"--package-lock=false",
|
||||
"--workspaces=false",
|
||||
]
|
||||
|
||||
const env = { ...process.env, npm_config_workspaces: "false" }
|
||||
|
||||
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||
const result = npmCli
|
||||
? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env })
|
||||
: spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
console.error("[copy-opencode-config] npm install failed to start", result.error)
|
||||
}
|
||||
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
// npm can create a self-referential link for scoped packages on Windows.
|
||||
// That link causes recursive copies (ELOOP) during bundling.
|
||||
rmSync(selfLinkDir, { recursive: true, force: true })
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||
21
packages/server/scripts/copy-ui-dist.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const uiDistDir = path.resolve(cliRoot, "../ui/src/renderer/dist")
|
||||
const targetDir = path.resolve(cliRoot, "public")
|
||||
|
||||
if (!existsSync(uiDistDir)) {
|
||||
console.error(`[copy-ui-dist] Expected UI build artifacts at ${uiDistDir}. Run the UI build before bundling the CLI.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
cpSync(uiDistDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-ui-dist] Copied UI bundle from ${uiDistDir} -> ${targetDir}`)
|
||||
355
packages/server/src/api-types.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import type {
|
||||
AgentModelSelection,
|
||||
AgentModelSelections,
|
||||
ModelPreference,
|
||||
OpenCodeBinary,
|
||||
Preferences,
|
||||
RecentFolder,
|
||||
} from "./config/schema"
|
||||
|
||||
/**
|
||||
* Canonical HTTP/SSE contract for the CLI server.
|
||||
* These types are consumed by both the CLI implementation and any UI clients.
|
||||
*/
|
||||
|
||||
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
|
||||
|
||||
export interface WorkspaceDescriptor {
|
||||
id: string
|
||||
/** Absolute path on the server host. */
|
||||
path: string
|
||||
name?: string
|
||||
status: WorkspaceStatus
|
||||
/** PID/port are populated when the workspace is running. */
|
||||
pid?: number
|
||||
port?: number
|
||||
/** Canonical proxy path the CLI exposes for this instance. */
|
||||
proxyPath: string
|
||||
/** Identifier of the binary resolved from config. */
|
||||
binaryId: string
|
||||
binaryLabel: string
|
||||
binaryVersion?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** Present when `status` is "error". */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WorkspaceCreateRequest {
|
||||
path: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type WorkspaceCreateResponse = WorkspaceDescriptor
|
||||
export type WorkspaceListResponse = WorkspaceDescriptor[]
|
||||
export type WorkspaceDetailResponse = WorkspaceDescriptor
|
||||
|
||||
export interface WorkspaceDeleteResponse {
|
||||
id: string
|
||||
status: WorkspaceStatus
|
||||
}
|
||||
|
||||
export type WorktreeKind = "root" | "worktree"
|
||||
|
||||
export interface WorktreeDescriptor {
|
||||
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
|
||||
slug: string
|
||||
/** Absolute directory path on the server host. */
|
||||
directory: string
|
||||
kind: WorktreeKind
|
||||
/** Optional VCS branch name when available. */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeListResponse {
|
||||
worktrees: WorktreeDescriptor[]
|
||||
/** True when the workspace folder resolves to a Git repository. */
|
||||
isGitRepo?: boolean
|
||||
}
|
||||
|
||||
export interface WorktreeCreateRequest {
|
||||
slug: string
|
||||
/** Optional branch name (defaults to slug). */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeMap {
|
||||
version: 1
|
||||
/** Default worktree to use for new sessions and as fallback. */
|
||||
defaultWorktreeSlug: string
|
||||
/** Mapping of *parent* session IDs to a worktree slug. */
|
||||
parentSessionWorktreeSlug: Record<string, string>
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
export interface WorkspaceLogEntry {
|
||||
workspaceId: string
|
||||
timestamp: string
|
||||
level: LogLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface FileSystemEntry {
|
||||
name: string
|
||||
/** Path relative to the CLI server root ("." represents the root itself). */
|
||||
path: string
|
||||
/** Absolute path when available (unrestricted listings). */
|
||||
absolutePath?: string
|
||||
type: "file" | "directory"
|
||||
size?: number
|
||||
/** ISO timestamp of last modification when available. */
|
||||
modifiedAt?: string
|
||||
}
|
||||
|
||||
export type FileSystemScope = "restricted" | "unrestricted"
|
||||
export type FileSystemPathKind = "relative" | "absolute" | "drives"
|
||||
|
||||
export interface FileSystemListingMetadata {
|
||||
scope: FileSystemScope
|
||||
/** Canonical identifier of the current view ("." for restricted roots, absolute paths otherwise). */
|
||||
currentPath: string
|
||||
/** Optional parent path if navigation upward is allowed. */
|
||||
parentPath?: string
|
||||
/** Absolute path representing the root or origin point for this listing. */
|
||||
rootPath: string
|
||||
/** Absolute home directory of the CLI host (useful defaults for unrestricted mode). */
|
||||
homePath: string
|
||||
/** Human-friendly label for the current path. */
|
||||
displayPath: string
|
||||
/** Indicates whether entry paths are relative, absolute, or represent drive roots. */
|
||||
pathKind: FileSystemPathKind
|
||||
}
|
||||
|
||||
export interface FileSystemListResponse {
|
||||
entries: FileSystemEntry[]
|
||||
metadata: FileSystemListingMetadata
|
||||
}
|
||||
|
||||
export interface FileSystemCreateFolderRequest {
|
||||
/**
|
||||
* Path identifier for the currently browsed directory.
|
||||
* Matches the `path` parameter used for `/api/filesystem`.
|
||||
*/
|
||||
parentPath?: string
|
||||
/** Single folder name (no separators). */
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface FileSystemCreateFolderResponse {
|
||||
/**
|
||||
* Path identifier that can be passed back to `/api/filesystem` to browse the new folder.
|
||||
* Relative for restricted listings, absolute for unrestricted.
|
||||
*/
|
||||
path: string
|
||||
/** Absolute folder path on the server host. */
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||
|
||||
export interface WorkspaceFileResponse {
|
||||
workspaceId: string
|
||||
relativePath: string
|
||||
/** UTF-8 file contents; binary files should be base64 encoded by the caller. */
|
||||
contents: string
|
||||
}
|
||||
|
||||
export type WorkspaceFileSearchResponse = FileSystemEntry[]
|
||||
|
||||
export interface InstanceData {
|
||||
messageHistory: string[]
|
||||
agentModelSelections: AgentModelSelection
|
||||
}
|
||||
|
||||
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||
|
||||
export interface InstanceStreamEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
id: string
|
||||
path: string
|
||||
label: string
|
||||
version?: string
|
||||
|
||||
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||
isDefault: boolean
|
||||
lastValidatedAt?: string
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export type SettingsOwner = string
|
||||
export type SettingsBucket = Record<string, unknown>
|
||||
export type SettingsDoc = Record<string, unknown>
|
||||
|
||||
export interface BinaryListResponse {
|
||||
binaries: BinaryRecord[]
|
||||
}
|
||||
|
||||
export interface BinaryCreateRequest {
|
||||
path: string
|
||||
label?: string
|
||||
makeDefault?: boolean
|
||||
}
|
||||
|
||||
export interface BinaryUpdateRequest {
|
||||
label?: string
|
||||
makeDefault?: boolean
|
||||
}
|
||||
|
||||
export interface BinaryValidationResult {
|
||||
valid: boolean
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SpeechSegment {
|
||||
startMs: number
|
||||
endMs: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SpeechCapabilitiesResponse {
|
||||
available: boolean
|
||||
configured: boolean
|
||||
provider: string
|
||||
supportsStt: boolean
|
||||
supportsTts: boolean
|
||||
baseUrl?: string
|
||||
sttModel: string
|
||||
ttsModel: string
|
||||
ttsVoice: string
|
||||
}
|
||||
|
||||
export interface SpeechTranscriptionResponse {
|
||||
text: string
|
||||
language?: string
|
||||
durationMs?: number
|
||||
segments?: SpeechSegment[]
|
||||
}
|
||||
|
||||
export interface SpeechSynthesisResponse {
|
||||
audioBase64: string
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "storage.configChanged"
|
||||
| "storage.stateChanged"
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
|
||||
export interface NetworkAddress {
|
||||
ip: string
|
||||
family: "ipv4" | "ipv6"
|
||||
scope: "external" | "internal" | "loopback"
|
||||
/** Remote URL using the server's remote protocol/port for this IP. */
|
||||
remoteUrl: string
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
version: string
|
||||
tag: string
|
||||
url: string
|
||||
channel: "stable" | "dev"
|
||||
publishedAt?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface UiMeta {
|
||||
version?: string
|
||||
source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||
}
|
||||
|
||||
export interface SupportMeta {
|
||||
supported: boolean
|
||||
message?: string
|
||||
minServerVersion?: string
|
||||
latestServerVersion?: string
|
||||
latestServerUrl?: string
|
||||
}
|
||||
|
||||
export interface ServerMeta {
|
||||
/** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
|
||||
localUrl: string
|
||||
/** URL remote clients should use (prefers HTTPS when enabled). */
|
||||
remoteUrl?: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||
host: string
|
||||
/** Listening mode derived from host binding. */
|
||||
listeningMode: "local" | "all"
|
||||
/** Actual local port in use after binding. */
|
||||
localPort: number
|
||||
/** Actual remote port in use after binding (when remoteUrl is set). */
|
||||
remotePort?: number
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
workspaceRoot: string
|
||||
/** Reachable addresses for this server, external first. */
|
||||
addresses: NetworkAddress[]
|
||||
serverVersion?: string
|
||||
ui?: UiMeta
|
||||
support?: SupportMeta
|
||||
/** Optional update info (dev channel only). */
|
||||
update?: LatestReleaseInfo | null
|
||||
}
|
||||
|
||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||
|
||||
export interface BackgroundProcess {
|
||||
id: string
|
||||
workspaceId: string
|
||||
title: string
|
||||
command: string
|
||||
cwd: string
|
||||
status: BackgroundProcessStatus
|
||||
pid?: number
|
||||
startedAt: string
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
export interface BackgroundProcessListResponse {
|
||||
processes: BackgroundProcess[]
|
||||
}
|
||||
|
||||
export interface BackgroundProcessOutputResponse {
|
||||
id: string
|
||||
content: string
|
||||
truncated: boolean
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export type {
|
||||
Preferences,
|
||||
ModelPreference,
|
||||
AgentModelSelections,
|
||||
RecentFolder,
|
||||
OpenCodeBinary,
|
||||
}
|
||||
175
packages/server/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import type { Logger } from "../logger"
|
||||
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||
|
||||
export interface AuthFile {
|
||||
version: 1
|
||||
username: string
|
||||
password: PasswordHashRecord
|
||||
userProvided: boolean
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
username: string
|
||||
passwordUserProvided: boolean
|
||||
}
|
||||
|
||||
export class AuthStore {
|
||||
private cachedFile: AuthFile | null = null
|
||||
private overrideAuth: AuthFile | null = null
|
||||
private bootstrapUsername: string | null = null
|
||||
|
||||
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
|
||||
|
||||
getAuthFilePath() {
|
||||
return this.authFilePath
|
||||
}
|
||||
|
||||
load(): AuthFile | null {
|
||||
if (this.overrideAuth) {
|
||||
return this.overrideAuth
|
||||
}
|
||||
|
||||
if (this.cachedFile) {
|
||||
return this.cachedFile
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(this.authFilePath)) {
|
||||
return null
|
||||
}
|
||||
const raw = fs.readFileSync(this.authFilePath, "utf-8")
|
||||
const parsed = JSON.parse(raw) as AuthFile
|
||||
if (!parsed || parsed.version !== 1) {
|
||||
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
|
||||
return null
|
||||
}
|
||||
this.cachedFile = parsed
|
||||
return parsed
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ensureInitialized(params: {
|
||||
username: string
|
||||
password?: string
|
||||
allowBootstrapWithoutPassword: boolean
|
||||
}): void {
|
||||
const password = params.password?.trim()
|
||||
if (password) {
|
||||
const now = new Date().toISOString()
|
||||
const runtime: AuthFile = {
|
||||
version: 1,
|
||||
username: params.username,
|
||||
password: hashPassword(password),
|
||||
userProvided: true,
|
||||
updatedAt: now,
|
||||
}
|
||||
this.overrideAuth = runtime
|
||||
this.cachedFile = null
|
||||
this.bootstrapUsername = null
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
|
||||
return
|
||||
}
|
||||
|
||||
const existing = this.load()
|
||||
if (existing) {
|
||||
if (existing.username !== params.username) {
|
||||
// Keep existing username unless explicitly overridden later.
|
||||
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
|
||||
}
|
||||
this.bootstrapUsername = null
|
||||
return
|
||||
}
|
||||
|
||||
if (params.allowBootstrapWithoutPassword) {
|
||||
this.bootstrapUsername = params.username
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
|
||||
)
|
||||
}
|
||||
|
||||
validateCredentials(username: string, password: string): boolean {
|
||||
const auth = this.load()
|
||||
if (!auth) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (username !== auth.username) {
|
||||
return false
|
||||
}
|
||||
|
||||
return verifyPassword(password, auth.password)
|
||||
}
|
||||
|
||||
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
|
||||
if (this.overrideAuth) {
|
||||
throw new Error(
|
||||
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
|
||||
)
|
||||
}
|
||||
|
||||
const current = this.load()
|
||||
|
||||
if (!current) {
|
||||
if (!this.bootstrapUsername) {
|
||||
throw new Error("Auth is not initialized")
|
||||
}
|
||||
|
||||
const created: AuthFile = {
|
||||
version: 1,
|
||||
username: this.bootstrapUsername,
|
||||
password: hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.persist(created)
|
||||
this.bootstrapUsername = null
|
||||
return { username: created.username, passwordUserProvided: created.userProvided }
|
||||
}
|
||||
|
||||
const next: AuthFile = {
|
||||
...current,
|
||||
password: hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.persist(next)
|
||||
return { username: next.username, passwordUserProvided: next.userProvided }
|
||||
}
|
||||
|
||||
getStatus(): AuthStatus {
|
||||
const current = this.load()
|
||||
if (current) {
|
||||
return { username: current.username, passwordUserProvided: current.userProvided }
|
||||
}
|
||||
|
||||
if (this.bootstrapUsername) {
|
||||
return { username: this.bootstrapUsername, passwordUserProvided: false }
|
||||
}
|
||||
|
||||
throw new Error("Auth is not initialized")
|
||||
}
|
||||
|
||||
private persist(auth: AuthFile) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
|
||||
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
|
||||
this.cachedFile = auth
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/server/src/auth/http-auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
|
||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!header) return result
|
||||
|
||||
const parts = header.split(";")
|
||||
for (const part of parts) {
|
||||
const index = part.indexOf("=")
|
||||
if (index < 0) continue
|
||||
const key = part.slice(0, index).trim()
|
||||
const value = part.slice(index + 1).trim()
|
||||
if (!key) continue
|
||||
result[key] = decodeURIComponent(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
|
||||
if (!remoteAddress) return false
|
||||
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
|
||||
if (remoteAddress === "::ffff:127.0.0.1") return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function wantsHtml(request: FastifyRequest): boolean {
|
||||
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
|
||||
return accept.includes("text/html") || accept.includes("application/xhtml")
|
||||
}
|
||||
|
||||
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
163
packages/server/src/auth/manager.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import path from "path"
|
||||
import type { Logger } from "../logger"
|
||||
import { AuthStore } from "./auth-store"
|
||||
import { TokenManager } from "./token-manager"
|
||||
import { SessionManager } from "./session-manager"
|
||||
import { isLoopbackAddress, parseCookies } from "./http-auth"
|
||||
|
||||
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
|
||||
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
|
||||
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
|
||||
|
||||
export interface AuthManagerInit {
|
||||
configPath: string
|
||||
username: string
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore | null
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
private readonly authEnabled: boolean
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||
|
||||
if (!this.authEnabled) {
|
||||
this.authStore = null
|
||||
this.tokenManager = null
|
||||
return
|
||||
}
|
||||
|
||||
const authFilePath = resolveAuthFilePath(init.configPath)
|
||||
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
|
||||
|
||||
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
|
||||
this.authStore.ensureInitialized({
|
||||
username: init.username,
|
||||
password: init.password,
|
||||
allowBootstrapWithoutPassword: init.generateToken,
|
||||
})
|
||||
|
||||
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||
}
|
||||
|
||||
isAuthEnabled(): boolean {
|
||||
return this.authEnabled
|
||||
}
|
||||
|
||||
getCookieName(): string {
|
||||
return this.cookieName
|
||||
}
|
||||
|
||||
isTokenBootstrapEnabled(): boolean {
|
||||
return Boolean(this.tokenManager)
|
||||
}
|
||||
|
||||
issueBootstrapToken(): string | null {
|
||||
if (!this.tokenManager) return null
|
||||
return this.tokenManager.generate()
|
||||
}
|
||||
|
||||
consumeBootstrapToken(token: string): boolean {
|
||||
if (!this.tokenManager) return false
|
||||
return this.tokenManager.consume(token)
|
||||
}
|
||||
|
||||
validateLogin(username: string, password: string): boolean {
|
||||
if (!this.authEnabled) {
|
||||
return true
|
||||
}
|
||||
return this.requireAuthStore().validateCredentials(username, password)
|
||||
}
|
||||
|
||||
createSession(username: string) {
|
||||
if (!this.authEnabled) {
|
||||
return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username }
|
||||
}
|
||||
return this.sessionManager.createSession(username)
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
if (!this.authEnabled) {
|
||||
return { username: this.init.username, passwordUserProvided: false }
|
||||
}
|
||||
return this.requireAuthStore().getStatus()
|
||||
}
|
||||
|
||||
setPassword(password: string) {
|
||||
if (!this.authEnabled) {
|
||||
throw new Error("Internal authentication is disabled")
|
||||
}
|
||||
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
|
||||
}
|
||||
|
||||
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||
return isLoopbackAddress(request.socket.remoteAddress)
|
||||
}
|
||||
|
||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||
if (!this.authEnabled) {
|
||||
// When auth is disabled, treat all requests as authenticated.
|
||||
// We still return a stable username so callers can display it.
|
||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers.cookie)
|
||||
const sessionId = cookies[this.cookieName]
|
||||
const session = this.sessionManager.getSession(sessionId)
|
||||
if (!session) return null
|
||||
return { username: session.username, sessionId: session.id }
|
||||
}
|
||||
|
||||
setSessionCookie(reply: FastifyReply, sessionId: string) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||
}
|
||||
|
||||
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
|
||||
}
|
||||
|
||||
clearSessionCookie(reply: FastifyReply) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||
}
|
||||
|
||||
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
|
||||
}
|
||||
|
||||
private requireAuthStore(): AuthStore {
|
||||
if (!this.authStore) {
|
||||
throw new Error("Auth store is unavailable")
|
||||
}
|
||||
return this.authStore
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuthFilePath(configPath: string) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
}
|
||||
|
||||
function resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||
if (options?.secure) {
|
||||
parts.push("Secure")
|
||||
}
|
||||
if (options?.maxAgeSeconds !== undefined) {
|
||||
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||
}
|
||||
return parts.join("; ")
|
||||
}
|
||||
49
packages/server/src/auth/password-hash.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface PasswordHashRecord {
|
||||
algorithm: "scrypt"
|
||||
saltBase64: string
|
||||
hashBase64: string
|
||||
keyLength: number
|
||||
params: {
|
||||
N: number
|
||||
r: number
|
||||
p: number
|
||||
maxmem: number
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SCRYPT_PARAMS = {
|
||||
N: 16384,
|
||||
r: 8,
|
||||
p: 1,
|
||||
maxmem: 32 * 1024 * 1024,
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): PasswordHashRecord {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const params = DEFAULT_SCRYPT_PARAMS
|
||||
const keyLength = 64
|
||||
const derived = crypto.scryptSync(password, salt, keyLength, params)
|
||||
return {
|
||||
algorithm: "scrypt",
|
||||
saltBase64: salt.toString("base64"),
|
||||
hashBase64: Buffer.from(derived).toString("base64"),
|
||||
keyLength,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
|
||||
if (record.algorithm !== "scrypt") {
|
||||
return false
|
||||
}
|
||||
|
||||
const salt = Buffer.from(record.saltBase64, "base64")
|
||||
const expected = Buffer.from(record.hashBase64, "base64")
|
||||
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
|
||||
if (expected.length !== derived.length) {
|
||||
return false
|
||||
}
|
||||
return crypto.timingSafeEqual(expected, Buffer.from(derived))
|
||||
}
|
||||
23
packages/server/src/auth/session-manager.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
createdAt: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessions = new Map<string, SessionInfo>()
|
||||
|
||||
createSession(username: string): SessionInfo {
|
||||
const id = crypto.randomBytes(32).toString("base64url")
|
||||
const info: SessionInfo = { id, createdAt: Date.now(), username }
|
||||
this.sessions.set(id, info)
|
||||
return info
|
||||
}
|
||||
|
||||
getSession(id: string | undefined): SessionInfo | undefined {
|
||||
if (!id) return undefined
|
||||
return this.sessions.get(id)
|
||||
}
|
||||
}
|
||||
32
packages/server/src/auth/token-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface BootstrapToken {
|
||||
token: string
|
||||
createdAt: number
|
||||
consumed: boolean
|
||||
}
|
||||
|
||||
export class TokenManager {
|
||||
private token: BootstrapToken | null = null
|
||||
|
||||
constructor(private readonly ttlMs: number) {}
|
||||
|
||||
generate(): string {
|
||||
const token = crypto.randomBytes(32).toString("base64url")
|
||||
this.token = { token, createdAt: Date.now(), consumed: false }
|
||||
return token
|
||||
}
|
||||
|
||||
consume(token: string): boolean {
|
||||
if (!this.token) return false
|
||||
if (this.token.consumed) return false
|
||||
if (Date.now() - this.token.createdAt > this.ttlMs) return false
|
||||
if (token !== this.token.token) return false
|
||||
this.token.consumed = true
|
||||
return true
|
||||
}
|
||||
|
||||
peek(): string | null {
|
||||
return this.token?.token ?? null
|
||||
}
|
||||
}
|
||||
519
packages/server/src/background-processes/manager.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { createWriteStream, existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { randomBytes } from "crypto"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
|
||||
|
||||
const ROOT_DIR = ".codenomad/background_processes"
|
||||
const INDEX_FILE = "index.json"
|
||||
const OUTPUT_FILE = "output.txt"
|
||||
const STOP_TIMEOUT_MS = 2000
|
||||
const EXIT_WAIT_TIMEOUT_MS = 5000
|
||||
const MAX_OUTPUT_BYTES = 20 * 1024
|
||||
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
||||
|
||||
interface ManagerDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface RunningProcess {
|
||||
id: string
|
||||
child: ChildProcess
|
||||
outputPath: string
|
||||
exitPromise: Promise<void>
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export class BackgroundProcessManager {
|
||||
private readonly running = new Map<string, RunningProcess>()
|
||||
|
||||
constructor(private readonly deps: ManagerDeps) {
|
||||
this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId))
|
||||
this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id))
|
||||
}
|
||||
|
||||
async list(workspaceId: string): Promise<BackgroundProcess[]> {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const enriched = await Promise.all(
|
||||
records.map(async (record) => ({
|
||||
...record,
|
||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||
})),
|
||||
)
|
||||
return enriched
|
||||
}
|
||||
|
||||
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
|
||||
const id = this.generateId()
|
||||
const processDir = await this.ensureProcessDir(workspaceId, id)
|
||||
const outputPath = path.join(processDir, OUTPUT_FILE)
|
||||
|
||||
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
||||
|
||||
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
|
||||
|
||||
const child = spawn(shellCommand, shellArgs, {
|
||||
cwd: workspace.path,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
...spawnOptions,
|
||||
})
|
||||
|
||||
child.on("exit", () => {
|
||||
this.killProcessTree(child, "SIGTERM")
|
||||
})
|
||||
|
||||
const record: BackgroundProcess = {
|
||||
|
||||
id,
|
||||
workspaceId,
|
||||
title,
|
||||
command,
|
||||
cwd: workspace.path,
|
||||
status: "running",
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
outputSizeBytes: 0,
|
||||
}
|
||||
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
child.on("close", async (code) => {
|
||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||
this.running.delete(id)
|
||||
|
||||
record.status = this.statusFromExit(code)
|
||||
record.exitCode = code === null ? undefined : code
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
||||
|
||||
let lastPublishAt = 0
|
||||
const maybePublishSize = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
lastPublishAt = now
|
||||
this.publishUpdate(workspaceId, record)
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
outputStream.write(data)
|
||||
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
|
||||
maybePublishSize()
|
||||
})
|
||||
child.stderr?.on("data", (data) => {
|
||||
outputStream.write(data)
|
||||
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
|
||||
maybePublishSize()
|
||||
})
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
return record
|
||||
}
|
||||
|
||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
const record = await this.findProcess(workspaceId, processId)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
|
||||
if (record.status === "running") {
|
||||
record.status = "stopped"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||
const record = await this.findProcess(workspaceId, processId)
|
||||
if (!record) return
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
|
||||
await this.removeFromIndex(workspaceId, processId)
|
||||
await this.removeProcessDir(workspaceId, processId)
|
||||
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.removed", properties: { processId } },
|
||||
})
|
||||
}
|
||||
|
||||
async readOutput(
|
||||
workspaceId: string,
|
||||
processId: string,
|
||||
options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
|
||||
) {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
return { id: processId, content: "", truncated: false, sizeBytes: 0 }
|
||||
}
|
||||
|
||||
const stats = await fs.stat(outputPath)
|
||||
const sizeBytes = stats.size
|
||||
const method = options.method ?? "full"
|
||||
const lineCount = options.lines ?? 10
|
||||
|
||||
const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes)
|
||||
let content = raw
|
||||
|
||||
switch (method) {
|
||||
case "head":
|
||||
content = this.headLines(raw, lineCount)
|
||||
break
|
||||
case "tail":
|
||||
content = this.tailLines(raw, lineCount)
|
||||
break
|
||||
case "grep":
|
||||
if (!options.pattern) {
|
||||
throw new Error("Pattern is required for grep output")
|
||||
}
|
||||
content = this.grepLines(raw, options.pattern)
|
||||
break
|
||||
default:
|
||||
content = raw
|
||||
}
|
||||
|
||||
const effectiveMaxBytes = options.maxBytes
|
||||
return {
|
||||
id: processId,
|
||||
content,
|
||||
truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes,
|
||||
sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
async streamOutput(workspaceId: string, processId: string, reply: any) {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
reply.code(404).send({ error: "Output not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const file = await fs.open(outputPath, "r")
|
||||
let position = (await file.stat()).size
|
||||
|
||||
const tick = async () => {
|
||||
const stats = await file.stat()
|
||||
if (stats.size <= position) return
|
||||
|
||||
const length = stats.size - position
|
||||
const buffer = Buffer.alloc(length)
|
||||
await file.read(buffer, 0, length, position)
|
||||
position = stats.size
|
||||
|
||||
const content = buffer.toString("utf-8")
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`)
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
tick().catch((error) => {
|
||||
this.deps.logger.warn({ err: error }, "Failed to stream background process output")
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
const close = () => {
|
||||
clearInterval(interval)
|
||||
file.close().catch(() => undefined)
|
||||
reply.raw.end?.()
|
||||
}
|
||||
|
||||
reply.raw.on("close", close)
|
||||
reply.raw.on("error", close)
|
||||
}
|
||||
|
||||
private async cleanupWorkspace(workspaceId: string) {
|
||||
for (const [, running] of this.running.entries()) {
|
||||
if (running.workspaceId !== workspaceId) continue
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
|
||||
await this.removeWorkspaceDir(workspaceId)
|
||||
}
|
||||
|
||||
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
|
||||
const pid = child.pid
|
||||
if (!pid) return
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const args = this.buildWindowsTaskkillArgs(pid, signal)
|
||||
try {
|
||||
spawnSync("taskkill", args, { stdio: "ignore" })
|
||||
return
|
||||
} catch {
|
||||
// Fall back to killing the direct child.
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return
|
||||
} catch {
|
||||
// Fall back to killing the direct child.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForExit(running: RunningProcess) {
|
||||
let exited = false
|
||||
const exitPromise = running.exitPromise.finally(() => {
|
||||
exited = true
|
||||
})
|
||||
|
||||
const killTimeout = setTimeout(() => {
|
||||
if (!exited) {
|
||||
this.killProcessTree(running.child, "SIGKILL")
|
||||
}
|
||||
}, STOP_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
exitPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
|
||||
if (!exited) {
|
||||
this.killProcessTree(running.child, "SIGKILL")
|
||||
this.running.delete(running.id)
|
||||
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit")
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(killTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
|
||||
if (process.platform === "win32") {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
return {
|
||||
shellCommand: comspec,
|
||||
shellArgs: ["/d", "/s", "/c", command],
|
||||
spawnOptions: { windowsVerbatimArguments: true },
|
||||
}
|
||||
}
|
||||
|
||||
// Keep bash for macOS/Linux.
|
||||
return { shellCommand: "bash", shellArgs: ["-c", command] }
|
||||
}
|
||||
|
||||
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
|
||||
// Default to graceful termination (no /F), then force kill when we escalate.
|
||||
const force = signal === "SIGKILL"
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||
if (code === null) return "stopped"
|
||||
if (code === 0) return "stopped"
|
||||
return "error"
|
||||
}
|
||||
|
||||
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
||||
if (maxBytes === undefined || sizeBytes <= maxBytes) {
|
||||
return await fs.readFile(outputPath, "utf-8")
|
||||
}
|
||||
|
||||
const start = Math.max(0, sizeBytes - maxBytes)
|
||||
const file = await fs.open(outputPath, "r")
|
||||
const buffer = Buffer.alloc(sizeBytes - start)
|
||||
await file.read(buffer, 0, buffer.length, start)
|
||||
await file.close()
|
||||
return buffer.toString("utf-8")
|
||||
}
|
||||
|
||||
private headLines(input: string, lines: number): string {
|
||||
const parts = input.split(/\r?\n/)
|
||||
return parts.slice(0, Math.max(0, lines)).join("\n")
|
||||
}
|
||||
|
||||
private tailLines(input: string, lines: number): string {
|
||||
const parts = input.split(/\r?\n/)
|
||||
return parts.slice(Math.max(0, parts.length - lines)).join("\n")
|
||||
}
|
||||
|
||||
private grepLines(input: string, pattern: string): string {
|
||||
let matcher: RegExp
|
||||
try {
|
||||
matcher = new RegExp(pattern)
|
||||
} catch {
|
||||
throw new Error("Invalid grep pattern")
|
||||
}
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => matcher.test(line))
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
private async ensureProcessDir(workspaceId: string, processId: string) {
|
||||
const root = await this.ensureWorkspaceDir(workspaceId)
|
||||
const processDir = path.join(root, processId)
|
||||
await fs.mkdir(processDir, { recursive: true })
|
||||
return processDir
|
||||
}
|
||||
|
||||
private async ensureWorkspaceDir(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
const root = path.join(workspace.path, ROOT_DIR, workspaceId)
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
return root
|
||||
}
|
||||
|
||||
private getOutputPath(workspaceId: string, processId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
||||
}
|
||||
|
||||
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
return records.find((entry) => entry.id === processId) ?? null
|
||||
}
|
||||
|
||||
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
if (!existsSync(indexPath)) return []
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const index = records.findIndex((entry) => entry.id === record.id)
|
||||
if (index >= 0) {
|
||||
records[index] = record
|
||||
} else {
|
||||
records.push(record)
|
||||
}
|
||||
await this.writeIndex(workspaceId, records)
|
||||
}
|
||||
|
||||
private async removeFromIndex(workspaceId: string, processId: string) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const next = records.filter((entry) => entry.id !== processId)
|
||||
await this.writeIndex(workspaceId, next)
|
||||
}
|
||||
|
||||
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
||||
}
|
||||
|
||||
private async getIndexPath(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE)
|
||||
}
|
||||
|
||||
private async removeProcessDir(workspaceId: string, processId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId)
|
||||
await fs.rm(processDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
private async removeWorkspaceDir(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId)
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
private async getOutputSize(workspaceId: string, processId: string): Promise<number> {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
return 0
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(outputPath)
|
||||
return stats.size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.updated", properties: { process: record } },
|
||||
})
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||
const random = randomBytes(3).toString("hex")
|
||||
return `proc_${timestamp}_${random}`
|
||||
}
|
||||
}
|
||||
29
packages/server/src/bin.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliEntry = path.join(__dirname, "index.js")
|
||||
const loaderFileUrl = pathToFileURL(path.join(__dirname, "loader.js")).href
|
||||
const registerScript = `import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("${encodeURI(loaderFileUrl)}", pathToFileURL("./"));`
|
||||
const loaderArg = `data:text/javascript,${registerScript}`
|
||||
|
||||
const child = spawn(process.execPath, ["--import", loaderArg, cliEntry, ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("Failed to launch CLI runtime", error)
|
||||
process.exit(1)
|
||||
})
|
||||
78
packages/server/src/config/location.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
export interface ConfigLocation {
|
||||
/** Resolved absolute base directory containing all persisted server data. */
|
||||
baseDir: string
|
||||
/** Canonical YAML config file path (may be custom when input points to a YAML file). */
|
||||
configYamlPath: string
|
||||
/** Canonical YAML state file path (always in baseDir). */
|
||||
stateYamlPath: string
|
||||
/** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */
|
||||
legacyJsonPath: string
|
||||
/** Directory for per-instance persisted data (chat history etc.). */
|
||||
instancesDir: string
|
||||
}
|
||||
|
||||
function resolvePath(inputPath: string): string {
|
||||
if (inputPath.startsWith("~/")) {
|
||||
return path.join(os.homedir(), inputPath.slice(2))
|
||||
}
|
||||
return path.resolve(inputPath)
|
||||
}
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase()
|
||||
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||
}
|
||||
|
||||
function isJsonPath(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith(".json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve CodeNomad's config location into a stable base directory + derived file paths.
|
||||
*
|
||||
* Supported inputs:
|
||||
* - Directory: "~/.config/codenomad"
|
||||
* - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml)
|
||||
* - Legacy JSON file: "~/.config/codenomad/config.json"
|
||||
*/
|
||||
export function resolveConfigLocation(raw: string): ConfigLocation {
|
||||
const trimmed = (raw ?? "").trim()
|
||||
const fallback = "~/.config/codenomad/config.json"
|
||||
const input = trimmed.length > 0 ? trimmed : fallback
|
||||
|
||||
const resolvedInput = resolvePath(input)
|
||||
|
||||
if (isYamlPath(resolvedInput)) {
|
||||
const baseDir = path.dirname(resolvedInput)
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: resolvedInput,
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
|
||||
if (isJsonPath(resolvedInput)) {
|
||||
const baseDir = path.dirname(resolvedInput)
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: resolvedInput,
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
|
||||
const baseDir = resolvedInput
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
104
packages/server/src/config/schema.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const ModelPreferenceSchema = z.object({
|
||||
providerId: z.string(),
|
||||
modelId: z.string(),
|
||||
})
|
||||
|
||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||
|
||||
const PreferencesSchema = z
|
||||
.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showTimelineTools: z.boolean().default(true),
|
||||
promptSubmitOnEnter: z.boolean().default(false),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
modelFavorites: z.array(ModelPreferenceSchema).default([]),
|
||||
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: z.boolean().default(false),
|
||||
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
||||
notifyOnNeedsInput: z.boolean().default(true),
|
||||
notifyOnIdle: z.boolean().default(true),
|
||||
})
|
||||
// Preserve unknown preference keys so newer configs survive older binaries.
|
||||
.passthrough()
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
path: z.string(),
|
||||
lastAccessed: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
const OpenCodeBinarySchema = z.object({
|
||||
path: z.string(),
|
||||
version: z.string().optional(),
|
||||
lastUsed: z.number().nonnegative(),
|
||||
label: z.string().optional(),
|
||||
})
|
||||
|
||||
const ConfigFileSchema = z
|
||||
.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
// Preserve unknown top-level keys so optional future features survive downgrades.
|
||||
.passthrough()
|
||||
|
||||
// On-disk config.yaml only stores stable configuration (not volatile state like recent folders).
|
||||
const ConfigYamlSchema = z
|
||||
.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client).
|
||||
const StateFileSchema = z
|
||||
.object({
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
|
||||
const DEFAULT_STATE = StateFileSchema.parse({})
|
||||
|
||||
export {
|
||||
ModelPreferenceSchema,
|
||||
AgentModelSelectionSchema,
|
||||
AgentModelSelectionsSchema,
|
||||
PreferencesSchema,
|
||||
RecentFolderSchema,
|
||||
OpenCodeBinarySchema,
|
||||
ConfigFileSchema,
|
||||
ConfigYamlSchema,
|
||||
StateFileSchema,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CONFIG_YAML,
|
||||
DEFAULT_STATE,
|
||||
}
|
||||
|
||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||
export type AgentModelSelection = z.infer<typeof AgentModelSelectionSchema>
|
||||
export type AgentModelSelections = z.infer<typeof AgentModelSelectionsSchema>
|
||||
export type Preferences = z.infer<typeof PreferencesSchema>
|
||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||
export type ConfigYamlFile = z.infer<typeof ConfigYamlSchema>
|
||||
export type StateFile = z.infer<typeof StateFileSchema>
|
||||
45
packages/server/src/events/bus.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { WorkspaceEventPayload } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export class EventBus extends EventEmitter {
|
||||
constructor(private readonly logger?: Logger) {
|
||||
super()
|
||||
}
|
||||
|
||||
publish(event: WorkspaceEventPayload): boolean {
|
||||
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
||||
this.logger?.debug({ type: event.type }, "Publishing workspace event")
|
||||
if (this.logger?.isLevelEnabled("trace")) {
|
||||
this.logger.trace({ event }, "Workspace event payload")
|
||||
}
|
||||
}
|
||||
return super.emit(event.type, event)
|
||||
}
|
||||
|
||||
onEvent(listener: (event: WorkspaceEventPayload) => void) {
|
||||
const handler = (event: WorkspaceEventPayload) => listener(event)
|
||||
this.on("workspace.created", handler)
|
||||
this.on("workspace.started", handler)
|
||||
this.on("workspace.error", handler)
|
||||
this.on("workspace.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("storage.configChanged", handler)
|
||||
this.on("storage.stateChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("storage.configChanged", handler)
|
||||
this.off("storage.stateChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { beforeEach, describe, it } from "node:test"
|
||||
import type { FileSystemEntry } from "../../api-types"
|
||||
import {
|
||||
clearWorkspaceSearchCache,
|
||||
getWorkspaceCandidates,
|
||||
refreshWorkspaceCandidates,
|
||||
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
} from "../search-cache"
|
||||
|
||||
describe("workspace search cache", () => {
|
||||
beforeEach(() => {
|
||||
clearWorkspaceSearchCache()
|
||||
})
|
||||
|
||||
it("expires cached candidates after the TTL", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
const startTime = 1_000
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
|
||||
|
||||
const beforeExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
|
||||
)
|
||||
assert.ok(beforeExpiry)
|
||||
assert.equal(beforeExpiry.length, 1)
|
||||
assert.equal(beforeExpiry[0].name, "file-a")
|
||||
|
||||
const afterExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
|
||||
)
|
||||
assert.equal(afterExpiry, undefined)
|
||||
})
|
||||
|
||||
it("replaces cached entries when manually refreshed", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
|
||||
const initial = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(initial)
|
||||
assert.equal(initial[0].name, "file-a")
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
|
||||
const refreshed = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(refreshed)
|
||||
assert.equal(refreshed[0].name, "file-b")
|
||||
})
|
||||
})
|
||||
|
||||
function createEntry(name: string): FileSystemEntry {
|
||||
return {
|
||||
name,
|
||||
path: name,
|
||||
absolutePath: `/tmp/${name}`,
|
||||
type: "file",
|
||||
size: 1,
|
||||
modifiedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
353
packages/server/src/filesystem/browser.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import {
|
||||
FileSystemCreateFolderResponse,
|
||||
FileSystemEntry,
|
||||
FileSystemListResponse,
|
||||
FileSystemListingMetadata,
|
||||
WINDOWS_DRIVES_ROOT,
|
||||
} from "../api-types"
|
||||
|
||||
interface FileSystemBrowserOptions {
|
||||
rootDir: string
|
||||
unrestricted?: boolean
|
||||
}
|
||||
|
||||
interface DirectoryReadOptions {
|
||||
includeFiles: boolean
|
||||
formatPath: (entryName: string) => string
|
||||
formatAbsolutePath: (entryName: string) => string
|
||||
}
|
||||
|
||||
const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))
|
||||
|
||||
export class FileSystemBrowser {
|
||||
private readonly root: string
|
||||
private readonly unrestricted: boolean
|
||||
private readonly homeDir: string
|
||||
private readonly isWindows: boolean
|
||||
|
||||
constructor(options: FileSystemBrowserOptions) {
|
||||
this.root = path.resolve(options.rootDir)
|
||||
this.unrestricted = Boolean(options.unrestricted)
|
||||
this.homeDir = os.homedir()
|
||||
this.isWindows = process.platform === "win32"
|
||||
}
|
||||
|
||||
list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("Relative listing is unavailable when running with unrestricted root")
|
||||
}
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
const normalizedPath = this.normalizeRelativePath(relativePath)
|
||||
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
||||
return this.readDirectoryEntries(absolutePath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
||||
})
|
||||
}
|
||||
|
||||
browse(targetPath?: string, options: { includeFiles?: boolean } = {}): FileSystemListResponse {
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
if (this.unrestricted) {
|
||||
return this.listUnrestricted(targetPath, includeFiles)
|
||||
}
|
||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
||||
}
|
||||
|
||||
createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
|
||||
const name = this.normalizeFolderName(folderName)
|
||||
|
||||
if (this.unrestricted) {
|
||||
const resolvedParent = this.resolveUnrestrictedPath(parentPath)
|
||||
if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
|
||||
throw new Error("Cannot create folders at drive root")
|
||||
}
|
||||
this.assertDirectoryExists(resolvedParent)
|
||||
const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
|
||||
fs.mkdirSync(absolutePath)
|
||||
return { path: absolutePath, absolutePath }
|
||||
}
|
||||
|
||||
const normalizedParent = this.normalizeRelativePath(parentPath)
|
||||
const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
|
||||
this.assertDirectoryExists(parentAbsolute)
|
||||
|
||||
const relativePath = this.buildRelativePath(normalizedParent, name)
|
||||
const absolutePath = this.toRestrictedAbsolute(relativePath)
|
||||
fs.mkdirSync(absolutePath)
|
||||
return { path: relativePath, absolutePath }
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
}
|
||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||
return fs.readFileSync(resolved, "utf-8")
|
||||
}
|
||||
|
||||
private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse {
|
||||
const normalizedPath = this.normalizeRelativePath(relativePath)
|
||||
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
||||
const entries = this.readDirectoryEntries(absolutePath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
||||
})
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "restricted",
|
||||
currentPath: normalizedPath,
|
||||
parentPath: normalizedPath === "." ? undefined : this.getRestrictedParent(normalizedPath),
|
||||
rootPath: this.root,
|
||||
homePath: this.homeDir,
|
||||
displayPath: this.resolveRestrictedAbsolute(normalizedPath),
|
||||
pathKind: "relative",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private listUnrestricted(targetPath: string | undefined, includeFiles: boolean): FileSystemListResponse {
|
||||
const resolvedPath = this.resolveUnrestrictedPath(targetPath)
|
||||
|
||||
if (this.isWindows && resolvedPath === WINDOWS_DRIVES_ROOT) {
|
||||
return this.listWindowsDrives()
|
||||
}
|
||||
|
||||
const entries = this.readDirectoryEntries(resolvedPath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
|
||||
})
|
||||
|
||||
const parentPath = this.getUnrestrictedParent(resolvedPath)
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "unrestricted",
|
||||
currentPath: resolvedPath,
|
||||
parentPath,
|
||||
rootPath: this.homeDir,
|
||||
homePath: this.homeDir,
|
||||
displayPath: resolvedPath,
|
||||
pathKind: "absolute",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private listWindowsDrives(): FileSystemListResponse {
|
||||
if (!this.isWindows) {
|
||||
throw new Error("Drive listing is only supported on Windows hosts")
|
||||
}
|
||||
|
||||
const entries: FileSystemEntry[] = []
|
||||
for (const letter of WINDOWS_DRIVE_LETTERS) {
|
||||
const drivePath = `${letter}:\\`
|
||||
try {
|
||||
if (fs.existsSync(drivePath)) {
|
||||
entries.push({
|
||||
name: `${letter}:`,
|
||||
path: drivePath,
|
||||
absolutePath: drivePath,
|
||||
type: "directory",
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore inaccessible drives
|
||||
}
|
||||
}
|
||||
|
||||
// Provide a generic UNC root entry so users can navigate to network shares manually.
|
||||
entries.push({
|
||||
name: "UNC Network",
|
||||
path: "\\\\",
|
||||
absolutePath: "\\\\",
|
||||
type: "directory",
|
||||
})
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "unrestricted",
|
||||
currentPath: WINDOWS_DRIVES_ROOT,
|
||||
parentPath: undefined,
|
||||
rootPath: this.homeDir,
|
||||
homePath: this.homeDir,
|
||||
displayPath: "Drives",
|
||||
pathKind: "drives",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private normalizeFolderName(input: string): string {
|
||||
const name = input.trim()
|
||||
if (!name) {
|
||||
throw new Error("Folder name is required")
|
||||
}
|
||||
|
||||
if (name === "." || name === "..") {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
if (name.startsWith("~")) {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
throw new Error("Folder name must not include path separators")
|
||||
}
|
||||
|
||||
if (name.includes("\u0000")) {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
private assertDirectoryExists(directory: string) {
|
||||
if (!fs.existsSync(directory)) {
|
||||
throw new Error(`Directory does not exist: ${directory}`)
|
||||
}
|
||||
const stats = fs.statSync(directory)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${directory}`)
|
||||
}
|
||||
}
|
||||
|
||||
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
for (const entry of dirents) {
|
||||
const absoluteEntryPath = path.join(directory, entry.name)
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
|
||||
// are treated as directories in directory-only listings.
|
||||
stats = fs.statSync(absoluteEntryPath)
|
||||
} catch {
|
||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = stats.isDirectory()
|
||||
if (!options.includeFiles && !isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: options.formatPath(entry.name),
|
||||
absolutePath: options.formatAbsolutePath(entry.name),
|
||||
type: isDirectory ? "directory" : "file",
|
||||
size: isDirectory ? undefined : stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
private normalizeRelativePath(input: string | undefined) {
|
||||
if (!input || input === "." || input === "./" || input === "/") {
|
||||
return "."
|
||||
}
|
||||
let normalized = input.replace(/\\+/g, "/")
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.replace(/^\/+/g, "")
|
||||
}
|
||||
return normalized === "" ? "." : normalized
|
||||
}
|
||||
|
||||
private buildRelativePath(parent: string, child: string) {
|
||||
if (!parent || parent === ".") {
|
||||
return this.normalizeRelativePath(child)
|
||||
}
|
||||
return this.normalizeRelativePath(`${parent}/${child}`)
|
||||
}
|
||||
|
||||
private resolveRestrictedAbsolute(relativePath: string) {
|
||||
return this.toRestrictedAbsolute(relativePath)
|
||||
}
|
||||
|
||||
private resolveRestrictedAbsoluteChild(parent: string, child: string) {
|
||||
const normalized = this.buildRelativePath(parent, child)
|
||||
return this.toRestrictedAbsolute(normalized)
|
||||
}
|
||||
|
||||
private toRestrictedAbsolute(relativePath: string) {
|
||||
const normalized = this.normalizeRelativePath(relativePath)
|
||||
const target = path.resolve(this.root, normalized)
|
||||
const relativeToRoot = path.relative(this.root, target)
|
||||
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") {
|
||||
throw new Error("Access outside of root is not allowed")
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private resolveUnrestrictedPath(input: string | undefined): string {
|
||||
if (!input || input === "." || input === "./") {
|
||||
return this.homeDir
|
||||
}
|
||||
|
||||
if (this.isWindows) {
|
||||
if (input === WINDOWS_DRIVES_ROOT) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
const normalized = path.win32.normalize(input)
|
||||
if (/^[a-zA-Z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||
return normalized
|
||||
}
|
||||
return path.win32.resolve(this.homeDir, normalized)
|
||||
}
|
||||
|
||||
if (input.startsWith("/")) {
|
||||
return path.posix.normalize(input)
|
||||
}
|
||||
|
||||
return path.posix.resolve(this.homeDir, input)
|
||||
}
|
||||
|
||||
private resolveAbsoluteChild(parent: string, child: string) {
|
||||
if (this.isWindows) {
|
||||
return path.win32.normalize(path.win32.join(parent, child))
|
||||
}
|
||||
return path.posix.normalize(path.posix.join(parent, child))
|
||||
}
|
||||
|
||||
private getRestrictedParent(relativePath: string) {
|
||||
const normalized = this.normalizeRelativePath(relativePath)
|
||||
if (normalized === ".") {
|
||||
return undefined
|
||||
}
|
||||
const segments = normalized.split("/")
|
||||
segments.pop()
|
||||
return segments.length === 0 ? "." : segments.join("/")
|
||||
}
|
||||
|
||||
private getUnrestrictedParent(currentPath: string) {
|
||||
if (this.isWindows) {
|
||||
const normalized = path.win32.normalize(currentPath)
|
||||
const parsed = path.win32.parse(normalized)
|
||||
if (normalized === WINDOWS_DRIVES_ROOT) {
|
||||
return undefined
|
||||
}
|
||||
if (normalized === parsed.root) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
return path.win32.dirname(normalized)
|
||||
}
|
||||
|
||||
const normalized = path.posix.normalize(currentPath)
|
||||
if (normalized === "/") {
|
||||
return undefined
|
||||
}
|
||||
return path.posix.dirname(normalized)
|
||||
}
|
||||
}
|
||||
66
packages/server/src/filesystem/search-cache.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import path from "path"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
|
||||
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
|
||||
|
||||
interface WorkspaceCandidateCacheEntry {
|
||||
expiresAt: number
|
||||
candidates: FileSystemEntry[]
|
||||
}
|
||||
|
||||
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
|
||||
|
||||
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
|
||||
const key = normalizeKey(rootDir)
|
||||
const cached = workspaceCandidateCache.get(key)
|
||||
if (!cached) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (cached.expiresAt <= now) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return cloneEntries(cached.candidates)
|
||||
}
|
||||
|
||||
export function refreshWorkspaceCandidates(
|
||||
rootDir: string,
|
||||
builder: () => FileSystemEntry[],
|
||||
now = Date.now(),
|
||||
): FileSystemEntry[] {
|
||||
const key = normalizeKey(rootDir)
|
||||
const freshCandidates = builder()
|
||||
|
||||
if (!freshCandidates || freshCandidates.length === 0) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return []
|
||||
}
|
||||
|
||||
const storedCandidates = cloneEntries(freshCandidates)
|
||||
workspaceCandidateCache.set(key, {
|
||||
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
candidates: storedCandidates,
|
||||
})
|
||||
|
||||
return cloneEntries(storedCandidates)
|
||||
}
|
||||
|
||||
export function clearWorkspaceSearchCache(rootDir?: string) {
|
||||
if (typeof rootDir === "undefined") {
|
||||
workspaceCandidateCache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeKey(rootDir)
|
||||
workspaceCandidateCache.delete(key)
|
||||
}
|
||||
|
||||
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
|
||||
return entries.map((entry) => ({ ...entry }))
|
||||
}
|
||||
|
||||
function normalizeKey(rootDir: string) {
|
||||
return path.resolve(rootDir)
|
||||
}
|
||||
184
packages/server/src/filesystem/search.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 200
|
||||
const MAX_CANDIDATES = 8000
|
||||
const IGNORED_DIRECTORIES = new Set(
|
||||
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
|
||||
(name) => name.toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
||||
export type WorkspaceFileSearchType = "all" | "file" | "directory"
|
||||
|
||||
export interface WorkspaceFileSearchOptions {
|
||||
limit?: number
|
||||
type?: WorkspaceFileSearchType
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
interface CandidateEntry {
|
||||
entry: FileSystemEntry
|
||||
key: string
|
||||
}
|
||||
|
||||
export function searchWorkspaceFiles(
|
||||
rootDir: string,
|
||||
query: string,
|
||||
options: WorkspaceFileSearchOptions = {},
|
||||
): FileSystemEntry[] {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
throw new Error("Search query is required")
|
||||
}
|
||||
|
||||
const normalizedRoot = path.resolve(rootDir)
|
||||
const limit = normalizeLimit(options.limit)
|
||||
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
|
||||
const refreshRequested = options.refresh === true
|
||||
|
||||
let entries: FileSystemEntry[] | undefined
|
||||
|
||||
try {
|
||||
if (!refreshRequested) {
|
||||
entries = getWorkspaceCandidates(normalizedRoot)
|
||||
}
|
||||
|
||||
if (!entries) {
|
||||
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = buildCandidateEntries(entries, typeFilter)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
|
||||
key: "key",
|
||||
limit,
|
||||
})
|
||||
|
||||
if (!matches || matches.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return matches.map((match) => match.obj.entry)
|
||||
}
|
||||
|
||||
|
||||
function collectCandidates(rootDir: string): FileSystemEntry[] {
|
||||
const queue: string[] = [""]
|
||||
const entries: FileSystemEntry[] = []
|
||||
|
||||
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
|
||||
const relativeDir = queue.pop() || ""
|
||||
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
|
||||
|
||||
let dirents: fs.Dirent[]
|
||||
try {
|
||||
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const entryName = dirent.name
|
||||
const lowerName = entryName.toLowerCase()
|
||||
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
|
||||
const absolutePath = path.join(absoluteDir, entryName)
|
||||
|
||||
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(absolutePath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = stats.isDirectory()
|
||||
|
||||
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
if (entries.length < MAX_CANDIDATES) {
|
||||
queue.push(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
|
||||
const normalizedPath = normalizeRelativeEntryPath(relativePath)
|
||||
const entry: FileSystemEntry = {
|
||||
name: entryName,
|
||||
path: normalizedPath,
|
||||
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
|
||||
type: entryType,
|
||||
size: entryType === "file" ? stats.size : undefined,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
}
|
||||
|
||||
entries.push(entry)
|
||||
|
||||
if (entries.length >= MAX_CANDIDATES) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
|
||||
const filtered: CandidateEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (!shouldInclude(entry.type, filter)) {
|
||||
continue
|
||||
}
|
||||
filtered.push({ entry, key: buildSearchKey(entry) })
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function normalizeLimit(limit?: number) {
|
||||
if (!limit || Number.isNaN(limit)) {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
|
||||
return clamped
|
||||
}
|
||||
|
||||
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
|
||||
return filter === "all" || entryType === filter
|
||||
}
|
||||
|
||||
function normalizeRelativeEntryPath(relativePath: string): string {
|
||||
if (!relativePath) {
|
||||
return "."
|
||||
}
|
||||
let normalized = relativePath.replace(/\\+/g, "/")
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.replace(/^\/+/g, "")
|
||||
}
|
||||
return normalized || "."
|
||||
}
|
||||
|
||||
function buildSearchKey(entry: FileSystemEntry) {
|
||||
return entry.path.toLowerCase()
|
||||
}
|
||||
533
packages/server/src/index.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* CLI entry point.
|
||||
* For now this only wires the typed modules together; actual command handling comes later.
|
||||
*/
|
||||
import { Command, InvalidArgumentError, Option } from "commander"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
import { WorkspaceManager } from "./workspaces/manager"
|
||||
import { resolveConfigLocation } from "./config/location"
|
||||
import { SettingsService } from "./settings/service"
|
||||
import { BinaryResolver } from "./settings/binaries"
|
||||
import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
import { ServerMeta } from "./api-types"
|
||||
import { InstanceStore } from "./storage/instance-store"
|
||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
|
||||
interface CliOptions {
|
||||
host: string
|
||||
https: boolean
|
||||
http: boolean
|
||||
httpsPort: number
|
||||
httpPort: number
|
||||
tlsKeyPath?: string
|
||||
tlsCertPath?: string
|
||||
tlsCaPath?: string
|
||||
tlsSANs?: string
|
||||
rootDir: string
|
||||
configPath: string
|
||||
unrestrictedRoot: boolean
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
uiStaticDir: string
|
||||
uiDevServer?: string
|
||||
uiAutoUpdate: boolean
|
||||
uiNoUpdate: boolean
|
||||
uiManifestUrl?: string
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1"
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
const DEFAULT_HTTPS_PORT = 9898
|
||||
const DEFAULT_HTTP_PORT = 9899
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
const program = new Command()
|
||||
.name("codenomad")
|
||||
.description("CodeNomad CLI server")
|
||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||
.addOption(new Option("--https <enabled>", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true"))
|
||||
.addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
|
||||
.addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
|
||||
.addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
|
||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||
.addOption(
|
||||
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
)
|
||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||
.addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
|
||||
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
|
||||
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
|
||||
.addOption(
|
||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||
)
|
||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
|
||||
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
|
||||
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
|
||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||
.addOption(
|
||||
new Option("--username <username>", "Username for server authentication")
|
||||
.env("CODENOMAD_SERVER_USERNAME")
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||
.addOption(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
.default(false),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--dangerously-skip-auth",
|
||||
"Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).",
|
||||
)
|
||||
.env("CODENOMAD_SKIP_AUTH")
|
||||
.default(false),
|
||||
)
|
||||
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
host: string
|
||||
https?: string
|
||||
http?: string
|
||||
httpsPort: number
|
||||
httpPort: number
|
||||
tlsKey?: string
|
||||
tlsCert?: string
|
||||
tlsCa?: string
|
||||
tlsSANs?: string
|
||||
workspaceRoot?: string
|
||||
root?: string
|
||||
unrestrictedRoot?: boolean
|
||||
config: string
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
uiDir: string
|
||||
uiDevServer?: string
|
||||
uiNoUpdate?: boolean
|
||||
uiAutoUpdate?: string
|
||||
uiManifestUrl?: string
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
|
||||
const parseBooleanEnv = (value: string | undefined): boolean => {
|
||||
const normalized = (value ?? "").trim().toLowerCase()
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on"
|
||||
}
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
const normalizedHost = resolveHost(parsed.host)
|
||||
|
||||
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
||||
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
||||
|
||||
const httpsEnabled = parseBooleanEnv(parsed.https)
|
||||
const httpEnabled = parseBooleanEnv(parsed.http)
|
||||
|
||||
if (!httpsEnabled && !httpEnabled) {
|
||||
throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)")
|
||||
}
|
||||
|
||||
return {
|
||||
host: normalizedHost,
|
||||
https: httpsEnabled,
|
||||
http: httpEnabled,
|
||||
httpsPort: parsed.httpsPort,
|
||||
httpPort: parsed.httpPort,
|
||||
tlsKeyPath: parsed.tlsKey,
|
||||
tlsCertPath: parsed.tlsCert,
|
||||
tlsCaPath: parsed.tlsCa,
|
||||
tlsSANs: parsed.tlsSANs,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
logLevel: parsed.logLevel,
|
||||
logDestination: parsed.logDestination,
|
||||
uiStaticDir: parsed.uiDir,
|
||||
uiDevServer: parsed.uiDevServer,
|
||||
uiAutoUpdate,
|
||||
uiNoUpdate: Boolean(parsed.uiNoUpdate),
|
||||
uiManifestUrl: parsed.uiManifestUrl,
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePort(input: string): number {
|
||||
const value = Number(input)
|
||||
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
||||
throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveHost(input: string | undefined): string {
|
||||
const trimmed = input?.trim()
|
||||
if (!trimmed) return DEFAULT_HOST
|
||||
|
||||
if (trimmed === "0.0.0.0") {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
|
||||
if (trimmed === "localhost") {
|
||||
return DEFAULT_HOST
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function programHasArg(argv: string[], flag: string): boolean {
|
||||
return argv.includes(flag)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseCliOptions(process.argv.slice(2))
|
||||
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
|
||||
const workspaceLogger = logger.child({ component: "workspace" })
|
||||
const configLogger = logger.child({ component: "config" })
|
||||
const eventLogger = logger.child({ component: "events" })
|
||||
|
||||
const logOptions = {
|
||||
...options,
|
||||
authPassword: options.authPassword ? "[REDACTED]" : undefined,
|
||||
}
|
||||
|
||||
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
|
||||
|
||||
if (options.dangerouslySkipAuth) {
|
||||
logger.warn(
|
||||
"DANGEROUS: internal authentication is disabled (--dangerously-skip-auth / CODENOMAD_SKIP_AUTH).",
|
||||
)
|
||||
}
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const configLocation = resolveConfigLocation(options.configPath)
|
||||
const configDir = configLocation.baseDir
|
||||
|
||||
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
||||
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
||||
}
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
localUrl: "http://localhost:0",
|
||||
remoteUrl: undefined,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||
localPort: 0,
|
||||
remotePort: undefined,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
addresses: [],
|
||||
}
|
||||
|
||||
const authManager = new AuthManager(
|
||||
{
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
logger.child({ component: "auth" }),
|
||||
)
|
||||
|
||||
if (options.generateToken && !options.dangerouslySkipAuth) {
|
||||
const token = authManager.issueBootstrapToken()
|
||||
if (token) {
|
||||
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||
}
|
||||
}
|
||||
|
||||
const tlsResolution = resolveHttpsOptions({
|
||||
enabled: options.https,
|
||||
configDir,
|
||||
host: options.host,
|
||||
tlsKeyPath: options.tlsKeyPath,
|
||||
tlsCertPath: options.tlsCertPath,
|
||||
tlsCaPath: options.tlsCaPath,
|
||||
tlsSANs: options.tlsSANs,
|
||||
logger: logger.child({ component: "tls" }),
|
||||
})
|
||||
|
||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||
|
||||
const settings = new SettingsService(configLocation, eventBus, configLogger)
|
||||
const binaryResolver = new BinaryResolver(settings)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
settings,
|
||||
binaryResolver,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.localUrl,
|
||||
nodeExtraCaCertsPath,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "instance-events" }),
|
||||
})
|
||||
|
||||
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
|
||||
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
|
||||
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
|
||||
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
|
||||
|
||||
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
|
||||
|
||||
const uiResolution = await resolveUi({
|
||||
serverVersion: packageJson.version,
|
||||
bundledUiDir: DEFAULT_UI_STATIC_DIR,
|
||||
autoUpdate: autoUpdateEnabled,
|
||||
overrideUiDir: uiDirOverride,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
manifestUrl: options.uiManifestUrl,
|
||||
logger: logger.child({ component: "ui" }),
|
||||
})
|
||||
|
||||
serverMeta.serverVersion = packageJson.version
|
||||
serverMeta.ui = {
|
||||
version: uiResolution.uiVersion,
|
||||
source: uiResolution.source,
|
||||
}
|
||||
serverMeta.support = {
|
||||
supported: uiResolution.supported,
|
||||
message: uiResolution.message,
|
||||
latestServerVersion: uiResolution.latestServerVersion,
|
||||
latestServerUrl: uiResolution.latestServerUrl,
|
||||
minServerVersion: uiResolution.minServerVersion,
|
||||
}
|
||||
|
||||
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
|
||||
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
|
||||
const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-")
|
||||
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
|
||||
const devReleaseMonitor = enableDevUpdateChecks
|
||||
? startDevReleaseMonitor({
|
||||
currentVersion: packageJson.version,
|
||||
repo: githubRepo,
|
||||
logger: logger.child({ component: "updates" }),
|
||||
onUpdate: (release) => {
|
||||
serverMeta.update = release
|
||||
},
|
||||
})
|
||||
: null
|
||||
|
||||
if (uiResolution.uiDevServerUrl && options.https) {
|
||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
||||
}
|
||||
|
||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
|
||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||
|
||||
const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0
|
||||
const httpBindPort = httpPortExplicit ? options.httpPort : 0
|
||||
|
||||
// Listener binding rules:
|
||||
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
|
||||
// - Remote access disabled: both listen on loopback.
|
||||
// - HTTP-only mode: respect --host (used for dev/testing).
|
||||
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
|
||||
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"
|
||||
|
||||
const servers: Array<ReturnType<typeof createHttpServer>> = []
|
||||
|
||||
const httpServer = options.http
|
||||
? createHttpServer({
|
||||
bindHost: httpBindHost,
|
||||
bindPort: httpBindPort,
|
||||
defaultPort: options.httpPort,
|
||||
protocol: "http",
|
||||
workspaceManager,
|
||||
settings,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
: null
|
||||
|
||||
const httpsServer = options.https
|
||||
? createHttpServer({
|
||||
bindHost: httpsBindHost,
|
||||
bindPort: httpsBindPort,
|
||||
defaultPort: options.httpsPort,
|
||||
protocol: "https",
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
workspaceManager,
|
||||
settings,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
})
|
||||
: null
|
||||
|
||||
if (httpServer) servers.push(httpServer)
|
||||
if (httpsServer) servers.push(httpsServer)
|
||||
|
||||
const [httpStart, httpsStart] = await Promise.all([
|
||||
httpServer ? httpServer.start() : Promise.resolve(null),
|
||||
httpsServer ? httpsServer.start() : Promise.resolve(null),
|
||||
])
|
||||
|
||||
const localStart = httpStart ?? httpsStart
|
||||
if (!localStart) {
|
||||
throw new Error("No listeners started")
|
||||
}
|
||||
|
||||
const remoteStart = httpsStart ?? httpStart
|
||||
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
||||
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
||||
|
||||
// Use an explicit IPv4 loopback address for the "local" URL.
|
||||
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
|
||||
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
serverMeta.localPort = localStart.port
|
||||
serverMeta.remoteUrl = remoteUrl
|
||||
serverMeta.remotePort = remoteStart?.port
|
||||
serverMeta.host = options.host
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (serverMeta.remoteUrl) {
|
||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||
}
|
||||
|
||||
if (options.launch) {
|
||||
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) {
|
||||
logger.info("Shutdown already in progress, ignoring signal")
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info("Received shutdown signal, stopping workspaces and server")
|
||||
|
||||
const shutdownWorkspaces = (async () => {
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
})()
|
||||
|
||||
const shutdownHttp = (async () => {
|
||||
try {
|
||||
await Promise.allSettled(servers.map((srv) => srv.stop()))
|
||||
logger.info("HTTP server(s) stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
})()
|
||||
|
||||
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
|
||||
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
devReleaseMonitor?.stop()
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", shutdown)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const logger = createLogger({ component: "app" })
|
||||
logger.error({ err: error }, "CLI server crashed")
|
||||
process.exit(1)
|
||||
})
|
||||
177
packages/server/src/launcher.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { spawn } from "child_process"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Logger } from "./logger"
|
||||
|
||||
interface BrowserCandidate {
|
||||
name: string
|
||||
command: string
|
||||
args: (url: string) => string[]
|
||||
}
|
||||
|
||||
const APP_ARGS = (url: string) => [`--app=${url}`, "--new-window"]
|
||||
|
||||
export async function launchInBrowser(url: string, logger: Logger): Promise<boolean> {
|
||||
const { platform, candidates, manualExamples } = buildPlatformCandidates(url)
|
||||
|
||||
console.log(`Attempting to launch browser (${platform}) using:`)
|
||||
candidates.forEach((candidate) => console.log(` - ${candidate.name}: ${candidate.command}`))
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const success = await tryLaunch(candidate, url, logger)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
"No supported browser found to launch. Run without --launch and use one of the commands below or install a compatible browser.",
|
||||
)
|
||||
if (manualExamples.length > 0) {
|
||||
console.error("Manual launch commands:")
|
||||
manualExamples.forEach((line) => console.error(` ${line}`))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function tryLaunch(candidate: BrowserCandidate, url: string, logger: Logger): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
try {
|
||||
const args = candidate.args(url)
|
||||
const child = spawn(candidate.command, args, { stdio: "ignore", detached: true })
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.debug({ err: error, candidate: candidate.name, command: candidate.command, args }, "Browser launch failed")
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
child.once("spawn", () => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.info(
|
||||
{
|
||||
browser: candidate.name,
|
||||
command: candidate.command,
|
||||
args,
|
||||
fullCommand: [candidate.command, ...args].join(" "),
|
||||
},
|
||||
"Launched browser in app mode",
|
||||
)
|
||||
child.unref()
|
||||
resolve(true)
|
||||
})
|
||||
} catch (error) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.debug({ err: error, candidate: candidate.name, command: candidate.command }, "Browser spawn threw")
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildPlatformCandidates(url: string) {
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
return {
|
||||
platform: "macOS",
|
||||
candidates: buildMacCandidates(),
|
||||
manualExamples: buildMacManualExamples(url),
|
||||
}
|
||||
case "win32":
|
||||
return {
|
||||
platform: "Windows",
|
||||
candidates: buildWindowsCandidates(),
|
||||
manualExamples: buildWindowsManualExamples(url),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
platform: "Linux",
|
||||
candidates: buildLinuxCandidates(),
|
||||
manualExamples: buildLinuxManualExamples(url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMacCandidates(): BrowserCandidate[] {
|
||||
const apps = [
|
||||
{ name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
|
||||
{ name: "Google Chrome Canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" },
|
||||
{ name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
|
||||
{ name: "Brave Browser", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
|
||||
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
|
||||
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
|
||||
{ name: "Arc", path: "/Applications/Arc.app/Contents/MacOS/Arc" },
|
||||
]
|
||||
|
||||
return apps.map((entry) => ({ name: entry.name, command: entry.path, args: APP_ARGS }))
|
||||
}
|
||||
|
||||
function buildWindowsCandidates(): BrowserCandidate[] {
|
||||
const programFiles = process.env["ProgramFiles"]
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"]
|
||||
const localAppData = process.env["LocalAppData"]
|
||||
|
||||
const paths = [
|
||||
[programFiles, "Google/Chrome/Application/chrome.exe", "Google Chrome"],
|
||||
[programFilesX86, "Google/Chrome/Application/chrome.exe", "Google Chrome (x86)"],
|
||||
[localAppData, "Google/Chrome/Application/chrome.exe", "Google Chrome (User)"],
|
||||
[programFiles, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"],
|
||||
[programFilesX86, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (x86)"],
|
||||
[localAppData, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (User)"],
|
||||
[programFiles, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave"],
|
||||
[localAppData, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave (User)"],
|
||||
[programFiles, "Chromium/Application/chromium.exe", "Chromium"],
|
||||
] as const
|
||||
|
||||
return paths
|
||||
.filter(([root]) => Boolean(root))
|
||||
.map(([root, rel, name]) => ({
|
||||
name,
|
||||
command: path.join(root as string, rel),
|
||||
args: APP_ARGS,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildLinuxCandidates(): BrowserCandidate[] {
|
||||
const names = [
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"brave-browser",
|
||||
"microsoft-edge",
|
||||
"microsoft-edge-stable",
|
||||
"vivaldi",
|
||||
]
|
||||
|
||||
return names.map((name) => ({ name, command: name, args: APP_ARGS }))
|
||||
}
|
||||
|
||||
function buildMacManualExamples(url: string) {
|
||||
return [
|
||||
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app="${url}" --new-window`,
|
||||
`"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" --app="${url}" --new-window`,
|
||||
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildWindowsManualExamples(url: string) {
|
||||
return [
|
||||
`"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" --app="${url}" --new-window`,
|
||||
`"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe" --app="${url}" --new-window`,
|
||||
`"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildLinuxManualExamples(url: string) {
|
||||
return [
|
||||
`google-chrome --app="${url}" --new-window`,
|
||||
`chromium --app="${url}" --new-window`,
|
||||
`brave-browser --app="${url}" --new-window`,
|
||||
`microsoft-edge --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
21
packages/server/src/loader.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export async function resolve(specifier: string, context: any, defaultResolve: any) {
|
||||
try {
|
||||
return await defaultResolve(specifier, context, defaultResolve)
|
||||
} catch (error: any) {
|
||||
if (shouldRetry(specifier, error)) {
|
||||
const retried = specifier.endsWith(".js") ? specifier : `${specifier}.js`
|
||||
return defaultResolve(retried, context, defaultResolve)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetry(specifier: string, error: any) {
|
||||
if (!error || error.code !== "ERR_MODULE_NOT_FOUND") {
|
||||
return false
|
||||
}
|
||||
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
133
packages/server/src/logger.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Transform } from "node:stream"
|
||||
import pino, { Logger as PinoLogger } from "pino"
|
||||
|
||||
export type Logger = PinoLogger
|
||||
|
||||
interface LoggerOptions {
|
||||
level?: string
|
||||
destination?: string
|
||||
component?: string
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<number, string> = {
|
||||
10: "trace",
|
||||
20: "debug",
|
||||
30: "info",
|
||||
40: "warn",
|
||||
50: "error",
|
||||
60: "fatal",
|
||||
}
|
||||
|
||||
const LIFECYCLE_COMPONENTS = new Set(["app", "workspace"])
|
||||
const OMITTED_FIELDS = new Set(["time", "msg", "level", "component", "module"])
|
||||
|
||||
export function createLogger(options: LoggerOptions = {}): Logger {
|
||||
const level = (options.level ?? process.env.CLI_LOG_LEVEL ?? "info").toLowerCase()
|
||||
const destination = options.destination ?? process.env.CLI_LOG_DESTINATION ?? "stdout"
|
||||
const baseComponent = options.component ?? "app"
|
||||
const loggerOptions = {
|
||||
level,
|
||||
base: { component: baseComponent },
|
||||
timestamp: false,
|
||||
} as const
|
||||
|
||||
if (destination && destination !== "stdout") {
|
||||
const stream = pino.destination({ dest: destination, mkdir: true, sync: false })
|
||||
return pino(loggerOptions, stream)
|
||||
}
|
||||
|
||||
const lifecycleStream = new LifecycleLogStream({ restrictInfoToLifecycle: level === "info" })
|
||||
lifecycleStream.pipe(process.stdout)
|
||||
return pino(loggerOptions, lifecycleStream)
|
||||
}
|
||||
|
||||
interface LifecycleStreamOptions {
|
||||
restrictInfoToLifecycle: boolean
|
||||
}
|
||||
|
||||
class LifecycleLogStream extends Transform {
|
||||
private buffer = ""
|
||||
|
||||
constructor(private readonly options: LifecycleStreamOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: () => void) {
|
||||
this.buffer += chunk.toString()
|
||||
let newlineIndex = this.buffer.indexOf("\n")
|
||||
while (newlineIndex >= 0) {
|
||||
const line = this.buffer.slice(0, newlineIndex)
|
||||
this.buffer = this.buffer.slice(newlineIndex + 1)
|
||||
this.pushFormatted(line)
|
||||
newlineIndex = this.buffer.indexOf("\n")
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
_flush(callback: () => void) {
|
||||
if (this.buffer.length > 0) {
|
||||
this.pushFormatted(this.buffer)
|
||||
this.buffer = ""
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
private pushFormatted(line: string) {
|
||||
if (!line.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
let entry: Record<string, unknown>
|
||||
try {
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const levelNumber = typeof entry.level === "number" ? entry.level : 30
|
||||
const levelLabel = LEVEL_LABELS[levelNumber] ?? "info"
|
||||
const component = (entry.component as string | undefined) ?? (entry.module as string | undefined) ?? "app"
|
||||
|
||||
if (this.options.restrictInfoToLifecycle && levelNumber <= 30 && !LIFECYCLE_COMPONENTS.has(component)) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = typeof entry.msg === "string" ? entry.msg : ""
|
||||
const metadata = this.formatMetadata(entry)
|
||||
const formatted = metadata.length > 0 ? `[${levelLabel.toUpperCase()}] [${component}] ${message} ${metadata}` : `[${levelLabel.toUpperCase()}] [${component}] ${message}`
|
||||
this.push(`${formatted}\n`)
|
||||
}
|
||||
|
||||
private formatMetadata(entry: Record<string, unknown>): string {
|
||||
const pairs: string[] = []
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (OMITTED_FIELDS.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === "err" && value && typeof value === "object") {
|
||||
const err = value as { type?: string; message?: string; stack?: string }
|
||||
const errLabel = err.type ?? "Error"
|
||||
const errMessage = err.message ? `: ${err.message}` : ""
|
||||
pairs.push(`err=${errLabel}${errMessage}`)
|
||||
if (err.stack) {
|
||||
pairs.push(`stack="${err.stack}"`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pairs.push(`${key}=${this.stringifyValue(value)}`)
|
||||
}
|
||||
|
||||
return pairs.join(" ").trim()
|
||||
}
|
||||
|
||||
private stringifyValue(value: unknown): string {
|
||||
if (value === undefined) return "undefined"
|
||||
if (value === null) return "null"
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (value instanceof Error) return value.message ?? value.name
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
31
packages/server/src/opencode-config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createLogger } from "./logger"
|
||||
|
||||
const log = createLogger({ component: "opencode-config" })
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
const prodTemplateDirs = [
|
||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
|
||||
path.resolve(__dirname, "opencode-config"),
|
||||
].filter((dir): dir is string => Boolean(dir))
|
||||
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
||||
const templateDir = isDevBuild
|
||||
? devTemplateDir
|
||||
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
|
||||
|
||||
export function getOpencodeConfigDir(): string {
|
||||
if (!existsSync(templateDir)) {
|
||||
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
|
||||
}
|
||||
|
||||
if (isDevBuild) {
|
||||
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
|
||||
}
|
||||
|
||||
return templateDir
|
||||
}
|
||||
55
packages/server/src/plugins/channel.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FastifyReply } from "fastify"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
export interface PluginOutboundEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ClientConnection {
|
||||
reply: FastifyReply
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export class PluginChannelManager {
|
||||
private readonly clients = new Set<ClientConnection>()
|
||||
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
register(workspaceId: string, reply: FastifyReply) {
|
||||
const connection: ClientConnection = { workspaceId, reply }
|
||||
this.clients.add(connection)
|
||||
this.logger.debug({ workspaceId }, "Plugin SSE client connected")
|
||||
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
this.clients.delete(connection)
|
||||
this.logger.debug({ workspaceId }, "Plugin SSE client disconnected")
|
||||
}
|
||||
|
||||
return { close }
|
||||
}
|
||||
|
||||
send(workspaceId: string, event: PluginOutboundEvent) {
|
||||
for (const client of this.clients) {
|
||||
if (client.workspaceId !== workspaceId) continue
|
||||
this.write(client.reply, event)
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(event: PluginOutboundEvent) {
|
||||
for (const client of this.clients) {
|
||||
this.write(client.reply, event)
|
||||
}
|
||||
}
|
||||
|
||||
private write(reply: FastifyReply, event: PluginOutboundEvent) {
|
||||
try {
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to write plugin SSE event")
|
||||
}
|
||||
}
|
||||
}
|
||||
36
packages/server/src/plugins/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import type { PluginOutboundEvent } from "./channel"
|
||||
|
||||
export interface PluginInboundEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface HandlerDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) {
|
||||
switch (event.type) {
|
||||
case "codenomad.pong":
|
||||
deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received")
|
||||
return
|
||||
|
||||
default:
|
||||
deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event")
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPingEvent(): PluginOutboundEvent {
|
||||
|
||||
return {
|
||||
type: "codenomad.ping",
|
||||
properties: {
|
||||
ts: Date.now(),
|
||||
},
|
||||
}
|
||||
}
|
||||