@vantran-se/openchamber-web 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  2. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  3. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
  15. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  16. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  27. package/dist/assets/de-BMnfXm-G.js +10 -0
  28. package/dist/assets/es-DKEvCCL6.js +16 -0
  29. package/dist/assets/fr-D9fgE2xE.js +14 -0
  30. package/dist/assets/index-DUke1nme.css +1 -0
  31. package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
  35. package/dist/assets/ja-BEXad-KW.js +16 -0
  36. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
  38. package/dist/assets/main-CI9DtHJv.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
  43. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  44. package/dist/assets/pl-BlhkmR5b.js +16 -0
  45. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  47. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  52. package/dist/assets/tr-CehUK1M9.js +7 -0
  53. package/dist/assets/uk-Bno5WGNz.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-Bc1WPd4i.js} +1 -1
  61. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  62. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +3 -3
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -0,0 +1,213 @@
1
+ // The routes over a stand-in journey: the shapes, the statuses per refusal, and the switch.
2
+
3
+ import http from 'node:http';
4
+
5
+ import express from 'express';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ import { SpaceError } from './errors.js';
9
+ import { SPACES_ROUTE, answerFailure, createSwitchController, registerSpaceRoutes } from './routes.js';
10
+
11
+ const ID = 'a1b2c3d4e5f6';
12
+ const servers = [];
13
+
14
+ afterEach(async () => {
15
+ for (const server of servers.splice(0)) await new Promise((resolve) => server.close(() => resolve()));
16
+ });
17
+
18
+ /** An app with the routes over `journey`, or with the feature off when `journey` is null. */
19
+ const serve = async ({ journey, places = [], switchState = { enabled: true } } = {}) => {
20
+ const app = express();
21
+ app.use(express.json());
22
+ const calls = [];
23
+ const state = { journey, enabled: switchState.enabled };
24
+ registerSpaceRoutes(app, {
25
+ getJourney: () => state.journey,
26
+ getPlaces: () => places,
27
+ readSwitch: async () => ({ enabled: state.enabled, spaces: state.enabled ? [{ id: ID, name: 'One', state: 'running' }] : [] }),
28
+ setSwitch: async (enabled) => {
29
+ calls.push(['setSwitch', enabled]);
30
+ if (switchState.failWith) throw switchState.failWith;
31
+ state.enabled = enabled;
32
+ state.journey = enabled ? journey : null;
33
+ return { enabled, stopped: enabled ? [] : [{ id: ID, name: 'One' }], stillRunning: [] };
34
+ },
35
+ });
36
+ const server = http.createServer(app);
37
+ servers.push(server);
38
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
39
+ const base = `http://127.0.0.1:${server.address().port}`;
40
+ const call = async (method, path, body) => {
41
+ const response = await fetch(`${base}${path}`, { method, headers: body === undefined ? {} : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
42
+ return { status: response.status, body: await response.json() };
43
+ };
44
+ return { call, calls, state };
45
+ };
46
+
47
+ const journeyOf = (overrides = {}) => {
48
+ const calls = [];
49
+ const record = (name, value) => async (...args) => { calls.push([name, ...args]); if (value instanceof Error) throw value; return value; };
50
+ return {
51
+ calls,
52
+ listSpaces: record('listSpaces', [{ id: ID, name: 'One', state: 'running' }]),
53
+ createSpace: record('createSpace', { id: ID, state: 'preparing' }),
54
+ startSpace: record('startSpace', { id: ID, state: 'running' }),
55
+ stopSpace: record('stopSpace', { id: ID, state: 'exited' }),
56
+ removeSpace: record('removeSpace', { id: ID, removed: true }),
57
+ grantAccess: record('grantAccess', { grant: { id: 'open-1' } }),
58
+ readJournal: record('readJournal', { records: [], dropped: 0, since: 'now' }),
59
+ previewApply: record('previewApply', { changedPaths: 2 }),
60
+ applySpace: record('applySpace', { applied: { status: 'applied' } }),
61
+ stopAllSpaces: record('stopAllSpaces', { stopped: [], stillRunning: [] }),
62
+ ...overrides,
63
+ };
64
+ };
65
+
66
+ describe('space routes', () => {
67
+ it('answers every journey route with 404 and isolated_spaces_off while the feature is off, and the switch still works', async () => {
68
+ const { call, calls } = await serve({ journey: null, switchState: { enabled: false } });
69
+ for (const [method, path] of [['GET', ''], ['POST', ''], ['GET', '/places'], ['POST', `/${ID}/start`], ['POST', `/${ID}/stop`], ['POST', `/${ID}/grants`], ['DELETE', `/${ID}`], ['GET', `/${ID}/journal`], ['GET', `/${ID}/apply`], ['POST', `/${ID}/apply`]]) {
70
+ expect(await call(method, `${SPACES_ROUTE}${path}`, method === 'GET' || method === 'DELETE' ? undefined : {}), `${method} ${path}`).toEqual({ status: 404, body: { code: 'isolated_spaces_off', message: 'Isolated spaces are turned off.', details: null } });
71
+ }
72
+ expect(await call('GET', `${SPACES_ROUTE}/switch`)).toEqual({ status: 200, body: { enabled: false, spaces: [] } });
73
+ expect(await call('PUT', `${SPACES_ROUTE}/switch`, { enabled: true })).toEqual({ status: 200, body: { enabled: true, stopped: [], stillRunning: [] } });
74
+ expect(calls).toEqual([['setSwitch', true]]);
75
+ expect(await call('PUT', `${SPACES_ROUTE}/switch`, { enabled: 'yes' })).toMatchObject({ status: 400, body: { code: 'invalid_request_body' } });
76
+ expect(await call('PUT', `${SPACES_ROUTE}/switch`, [])).toMatchObject({ status: 400, body: { code: 'invalid_request_body' } });
77
+ });
78
+
79
+ it('reports what turning the switch off would stop, and what it stopped', async () => {
80
+ const journey = journeyOf();
81
+ const { call, state } = await serve({ journey });
82
+ expect(await call('GET', `${SPACES_ROUTE}/switch`)).toEqual({ status: 200, body: { enabled: true, spaces: [{ id: ID, name: 'One', state: 'running' }] } });
83
+ expect(await call('PUT', `${SPACES_ROUTE}/switch`, { enabled: false })).toEqual({ status: 200, body: { enabled: false, stopped: [{ id: ID, name: 'One' }], stillRunning: [] } });
84
+ expect(state.journey).toBeNull();
85
+ expect(await call('GET', SPACES_ROUTE)).toMatchObject({ status: 404, body: { code: 'isolated_spaces_off' } });
86
+ });
87
+
88
+ it('passes a refusal of the switch on with its status', async () => {
89
+ const { call } = await serve({ journey: journeyOf(), switchState: { enabled: true, failWith: new SpaceError('space_preparing', 'A space is still being made.', { spaces: [ID] }) } });
90
+ expect(await call('PUT', `${SPACES_ROUTE}/switch`, { enabled: false })).toEqual({ status: 409, body: { code: 'space_preparing', message: 'A space is still being made.', details: { spaces: [ID] } } });
91
+ });
92
+
93
+ it('translates each route into the journey call it stands for', async () => {
94
+ const journey = journeyOf();
95
+ const places = [{ id: 'docker', check: async () => ({ available: true, version: '29.2.1', hostIsolation: true }) }];
96
+ const { call } = await serve({ journey, places });
97
+ expect(await call('GET', `${SPACES_ROUTE}/places`)).toEqual({ status: 200, body: { places: [{ id: 'docker', available: true, version: '29.2.1', hostIsolation: true }] } });
98
+ expect(await call('GET', SPACES_ROUTE)).toEqual({ status: 200, body: { spaces: [{ id: ID, name: 'One', state: 'running' }] } });
99
+ const request = { projectDirectory: '/home/me/project', name: 'One', start: 'clean', network: { mode: 'open' } };
100
+ expect(await call('POST', SPACES_ROUTE, request)).toEqual({ status: 202, body: { id: ID, state: 'preparing' } });
101
+ expect(await call('POST', `${SPACES_ROUTE}/${ID}/start`)).toEqual({ status: 200, body: { id: ID, state: 'running' } });
102
+ expect(await call('POST', `${SPACES_ROUTE}/${ID}/stop`)).toEqual({ status: 200, body: { id: ID, state: 'exited' } });
103
+ expect(await call('POST', `${SPACES_ROUTE}/${ID}/grants`, { kind: 'domain', upstream: 'https://registry.example.com/' })).toEqual({ status: 200, body: { grant: { id: 'open-1' } } });
104
+ expect(await call('GET', `${SPACES_ROUTE}/${ID}/journal`)).toEqual({ status: 200, body: { records: [], dropped: 0, since: 'now' } });
105
+ expect(await call('GET', `${SPACES_ROUTE}/${ID}/apply`)).toEqual({ status: 200, body: { changedPaths: 2 } });
106
+ expect(await call('POST', `${SPACES_ROUTE}/${ID}/apply`, { as: 'branch', branch: 'b' })).toEqual({ status: 200, body: { applied: { status: 'applied' } } });
107
+ expect(await call('DELETE', `${SPACES_ROUTE}/${ID}`)).toEqual({ status: 200, body: { id: ID, removed: true } });
108
+ expect(journey.calls).toEqual([
109
+ ['listSpaces', { access: true }], ['createSpace', request], ['startSpace', ID], ['stopSpace', ID], ['grantAccess', ID, { kind: 'domain', upstream: 'https://registry.example.com/' }], ['readJournal', ID], ['previewApply', ID], ['applySpace', ID, { as: 'branch', branch: 'b' }], ['removeSpace', ID],
110
+ ]);
111
+ });
112
+
113
+ it('refuses an id that is not a space id before the journey is asked, and a body that is not an object', async () => {
114
+ const journey = journeyOf();
115
+ const { call } = await serve({ journey });
116
+ for (const bad of ['ABCDEF012345', '0f0f0f0f0f0', 'a1b2c3d4e5f6x']) {
117
+ expect(await call('POST', `${SPACES_ROUTE}/${encodeURIComponent(bad)}/start`)).toMatchObject({ status: 404, body: { code: 'space_not_found' } });
118
+ }
119
+ expect(await call('POST', SPACES_ROUTE, [1])).toMatchObject({ status: 400, body: { code: 'invalid_request_body' } });
120
+ expect(journey.calls).toEqual([]);
121
+ });
122
+
123
+ it('gives each refusal of the journey the status of its code, and never a stack', async () => {
124
+ const answers = [];
125
+ const res = { status(code) { this.code = code; return this; }, json(body) { answers.push({ status: this.code, body }); } };
126
+ const cases = [
127
+ ['space_not_found', 404], ['isolated_spaces_off', 404], ['project_not_registered', 400], ['invalid_network', 400], ['space_preparing', 409], ['space_not_running', 409],
128
+ ['space_busy', 409], ['space_creation_failed', 409], ['invalid_grant_request', 400], ['secret_source_missing', 409], ['space_record_unreadable', 409],
129
+ ['branch_exists', 409], ['changes_do_not_apply', 409], ['changes_route_closed', 409], ['nothing_to_apply', 409], ['place_cannot_restrict_network', 409],
130
+ ['space_remove_incomplete', 502], ['docker_command_failed', 502], ['code_out_failed', 502],
131
+ ];
132
+ for (const [code, status] of cases) {
133
+ answerFailure(res, new SpaceError(code, `about ${code}`, { step: 'x' }));
134
+ expect(answers.at(-1), code).toEqual({ status, body: { code, message: `about ${code}`, details: { step: 'x' } } });
135
+ }
136
+ answerFailure(res, new TypeError('boom'));
137
+ expect(answers.at(-1)).toEqual({ status: 500, body: { code: 'space_journey_failed', message: 'boom', details: null } });
138
+ expect(JSON.stringify(answers)).not.toContain('at ');
139
+ });
140
+ });
141
+
142
+ describe('the switch controller', () => {
143
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
144
+ const hostOf = (name, { spaces = [], listFails = false } = {}) => {
145
+ const host = {
146
+ name,
147
+ closed: 0,
148
+ reopened: 0,
149
+ journey: {
150
+ listSpaces: async () => { if (listFails) throw new SpaceError('docker_command_failed', 'docker ps exited 1'); return spaces; },
151
+ stopAllSpaces: async () => { await sleep(30); return { stopped: spaces.map(({ id, name: spaceName }) => ({ id, name: spaceName })), stillRunning: [] }; },
152
+ reopen: () => { host.reopened += 1; },
153
+ },
154
+ close: () => { host.closed += 1; },
155
+ };
156
+ return host;
157
+ };
158
+ const controllerWith = ({ host = null, persistFails = false } = {}) => {
159
+ const state = { host, built: 0, started: [], persisted: [] };
160
+ const controller = createSwitchController({
161
+ getHost: () => state.host,
162
+ setHost: (next) => { state.host = next; },
163
+ buildHost: () => { state.built += 1; return hostOf(`built-${state.built}`); },
164
+ startHost: (made) => { state.started.push(made.name); },
165
+ persist: async (enabled) => { await sleep(10); if (persistFails) throw new Error('disk full'); state.persisted.push(enabled); },
166
+ });
167
+ return { controller, state };
168
+ };
169
+
170
+ it('reads the switch, and says the list is unknown rather than failing when the place cannot be asked', async () => {
171
+ expect(await controllerWith().controller.readSwitch()).toEqual({ enabled: false, spaces: [] });
172
+ const on = controllerWith({ host: hostOf('on', { spaces: [{ id: ID, name: 'One', state: 'running', extra: 1 }] }) });
173
+ expect(await on.controller.readSwitch()).toEqual({ enabled: true, spaces: [{ id: ID, name: 'One', state: 'running' }] });
174
+ const down = controllerWith({ host: hostOf('down', { listFails: true }) });
175
+ expect(await down.controller.readSwitch()).toEqual({ enabled: true, spaces: null, failure: { code: 'docker_command_failed', message: 'docker ps exited 1' } });
176
+ });
177
+
178
+ it('builds one host for two turn-ons at once, and closes the one host once for two turn-offs', async () => {
179
+ const { controller, state } = controllerWith();
180
+ const [first, second] = await Promise.all([controller.setSwitch(true), controller.setSwitch(true)]);
181
+ expect(first).toEqual({ enabled: true, stopped: [], stillRunning: [] });
182
+ expect(second).toEqual(first);
183
+ expect(state.built).toBe(1);
184
+ expect(state.started).toEqual(['built-1']);
185
+ expect(state.persisted).toEqual([true]);
186
+ const on = state.host;
187
+ const [offFirst, offSecond] = await Promise.all([controller.setSwitch(false), controller.setSwitch(false)]);
188
+ expect(offFirst).toEqual({ enabled: false, stopped: [], stillRunning: [] });
189
+ expect(offSecond).toEqual(offFirst);
190
+ expect(on.closed).toBe(1);
191
+ expect(state.host).toBeNull();
192
+ expect(state.persisted).toEqual([true, false]);
193
+ });
194
+
195
+ it('stops the spaces, writes the setting and only then closes the host; a setting that cannot be written leaves the feature on and open again', async () => {
196
+ const host = hostOf('on', { spaces: [{ id: ID, name: 'One', state: 'running' }] });
197
+ const failing = controllerWith({ host, persistFails: true });
198
+ await expect(failing.controller.setSwitch(false)).rejects.toThrow('disk full');
199
+ expect(host.closed).toBe(0);
200
+ expect(host.reopened).toBe(1);
201
+ expect(failing.state.host).toBe(host);
202
+
203
+ const { controller, state } = controllerWith({ host });
204
+ expect(await controller.setSwitch(false)).toEqual({ enabled: false, stopped: [{ id: ID, name: 'One' }], stillRunning: [] });
205
+ expect(host.closed).toBe(1);
206
+ expect(state.host).toBeNull();
207
+ // A turn-on that arrived while the turn-off was running builds the host after it, once.
208
+ const [off, on] = await Promise.all([controller.setSwitch(false), controller.setSwitch(true)]);
209
+ expect(off.enabled).toBe(false);
210
+ expect(on.enabled).toBe(true);
211
+ expect(state.built).toBe(1);
212
+ });
213
+ });
@@ -1,5 +1,8 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
2
4
  import path from 'node:path';
5
+ import { Duplex } from 'node:stream';
3
6
 
4
7
  import { SpaceError } from './errors.js';
5
8
 
@@ -70,6 +73,11 @@ const trackTree = (child) => {
70
73
  liveTrees.add(child);
71
74
  };
72
75
 
76
+ /** What an abort rejects with: the caller's own SpaceError, or one of ours for any other reason. */
77
+ const abortReason = (signal, file, args) => (signal.reason instanceof SpaceError
78
+ ? signal.reason
79
+ : new SpaceError('command_aborted', `${file} ${args[0] ?? ''} was stopped by its caller`));
80
+
73
81
  /**
74
82
  * Runs one executable with an argument array and resolves `{ code, stdout, stderr }`
75
83
  * for any exit code. The executable is spawned directly, never through a shell.
@@ -86,7 +94,28 @@ const trackTree = (child) => {
86
94
  * caller that cleans up afterwards does not race processes that still hold its files. Measured on
87
95
  * Windows: without the wait, removing the temporary folder failed with EBUSY and hid the timeout.
88
96
  * `options.killWaitMs` changes that bound, for the tests. While a `killTree` child runs, an exit of
89
- * this process kills its tree too, see `liveTrees`.
97
+ * this process kills its tree too, see `liveTrees`, unless `options.keepAtExit` is true: then only a
98
+ * timeout or an abort kills it, and it outlives an exit of this process, as a child without `killTree`
99
+ * does on POSIX. It is for `git apply`, which, killed in the middle of writing, leaves the user's project
100
+ * half changed, while left to run it finishes. Such a child also has no pipe back to this process on
101
+ * its output, because a pipe dies with this process, and a filter of git's that then printed got
102
+ * SIGPIPE and stopped the apply in the middle all the same, measured by a reviewer. Its stdout goes
103
+ * nowhere and resolves as empty; its stderr goes into a file this process made and removed at once,
104
+ * which it and the child each hold open, so no clean-up of anybody's can break the child's writes. At
105
+ * `close` the last `maxOutputBytes` of it are read: the same window as `keepTail`, and without
106
+ * `keepTail` a longer stderr is `command_output_too_large` then, after the child has ended.
107
+ *
108
+ * `options.signal` is an `AbortSignal` that lets the caller stop the child from outside: the child is
109
+ * killed as a timeout kills it, and the rejection is the signal's reason when that reason is a
110
+ * SpaceError, and `command_aborted` otherwise, so every rejection of this function is a SpaceError.
111
+ * The caller then knows that it stopped the child, which the exit code cannot tell: on Windows a tree
112
+ * ended by `taskkill` exits with code 1, not a signal. A signal that is already aborted starts nothing.
113
+ *
114
+ * `options.keepTail: true` turns the output cap into a window: past `maxOutputBytes`, each stream keeps
115
+ * its last `maxOutputBytes` bytes and the child runs on. It is for a child whose exit code is the answer
116
+ * and whose output is only shown: a `git apply --check` that fails prints a line or two for every path
117
+ * that does not fit, and measured by a reviewer, tens of thousands of such paths passed four megabytes,
118
+ * where the cap stopped the check and its answer was lost.
90
119
  *
91
120
  * Rejects with a SpaceError when the process cannot start (`command_spawn_failed`),
92
121
  * runs past `timeoutMs` (`command_timeout`), prints more than `maxOutputBytes`
@@ -96,11 +125,35 @@ const trackTree = (child) => {
96
125
  export function runCommand(file, args, options = {}) {
97
126
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
98
127
  const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
128
+ const keepTail = options.keepTail === true;
99
129
  const stdin = options.stdin ?? '';
100
130
  const killTree = options.killTree === true;
131
+ const keepAtExit = options.keepAtExit === true;
101
132
  const killWaitMs = options.killWaitMs ?? KILL_WAIT_MS;
133
+ const abortSignal = options.signal;
102
134
 
103
135
  return new Promise((resolve, reject) => {
136
+ if (abortSignal?.aborted) {
137
+ reject(abortReason(abortSignal, file, args));
138
+ return;
139
+ }
140
+ // Where a keepAtExit child's stderr goes: a file already removed from its folder, open here and in the child.
141
+ let errorFile = null;
142
+ const closeErrorFile = () => {
143
+ if (errorFile === null) return;
144
+ try { fs.closeSync(errorFile.fd); } catch { /* already closed */ }
145
+ fs.rmSync(errorFile.folder, { recursive: true, force: true });
146
+ errorFile = null;
147
+ };
148
+ if (keepAtExit) {
149
+ try {
150
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-stderr-'));
151
+ errorFile = { folder, fd: fs.openSync(path.join(folder, 'stderr'), 'w+') };
152
+ } catch (error) {
153
+ reject(new SpaceError('command_spawn_failed', `Could not start ${file}: no file for its output: ${error.message}`, { errno: error.code ?? null }));
154
+ return;
155
+ }
156
+ }
104
157
  let child;
105
158
  try {
106
159
  child = spawn(file, args, {
@@ -111,18 +164,23 @@ export function runCommand(file, args, options = {}) {
111
164
  // On POSIX the child leads a process group of its own, so the kill can reach its children.
112
165
  // Never on Windows: there `detached` gives the child a console of its own.
113
166
  detached: killTree && process.platform !== 'win32',
114
- stdio: ['pipe', 'pipe', 'pipe'],
167
+ stdio: errorFile === null ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'ignore', errorFile.fd],
115
168
  });
116
169
  } catch (error) {
170
+ closeErrorFile();
117
171
  reject(new SpaceError('command_spawn_failed', `Could not start ${file}: ${error.message}`, { errno: error.code ?? null }));
118
172
  return;
119
173
  }
120
174
 
121
- if (killTree) trackTree(child);
175
+ if (killTree && !keepAtExit) trackTree(child);
176
+ // The child holds its own copy now; with the name gone, nothing that cleans folders can take it away.
177
+ // Where the system refuses to remove an open file, the folder goes at `close`.
178
+ if (errorFile !== null) fs.rmSync(errorFile.folder, { recursive: true, force: true });
122
179
 
123
180
  const stdoutChunks = [];
124
181
  const stderrChunks = [];
125
182
  let capturedBytes = 0;
183
+ const heldBytes = new Map([[stdoutChunks, 0], [stderrChunks, 0]]);
126
184
  let settled = false;
127
185
  let closed = false;
128
186
  const closeWaiters = [];
@@ -139,6 +197,7 @@ export function runCommand(file, args, options = {}) {
139
197
  if (settled) return;
140
198
  settled = true;
141
199
  clearTimeout(timer);
200
+ abortSignal?.removeEventListener('abort', stop);
142
201
  if (!killTree) {
143
202
  child.kill('SIGKILL');
144
203
  reject(error);
@@ -150,21 +209,42 @@ export function runCommand(file, args, options = {}) {
150
209
  const timer = setTimeout(() => {
151
210
  fail(new SpaceError('command_timeout', `${file} ${args[0] ?? ''} did not finish within ${timeoutMs} ms and was stopped`));
152
211
  }, timeoutMs);
212
+ const stop = () => fail(abortReason(abortSignal, file, args));
213
+ abortSignal?.addEventListener('abort', stop, { once: true });
153
214
 
154
- const capture = (chunks) => (chunk) => {
215
+ // Only the last `maxOutputBytes` of the stream stay, and nothing fails.
216
+ const window = (chunks) => (chunk) => {
217
+ chunks.push(chunk);
218
+ let held = heldBytes.get(chunks) + chunk.length;
219
+ while (held - chunks[0].length >= maxOutputBytes) held -= chunks.shift().length;
220
+ if (held > maxOutputBytes) {
221
+ chunks[0] = chunks[0].subarray(held - maxOutputBytes);
222
+ held = maxOutputBytes;
223
+ }
224
+ heldBytes.set(chunks, held);
225
+ };
226
+ const capture = (chunks) => (keepTail ? window(chunks) : (chunk) => {
155
227
  capturedBytes += chunk.length;
156
228
  if (capturedBytes > maxOutputBytes) {
157
229
  fail(new SpaceError('command_output_too_large', `${file} ${args[0] ?? ''} printed more than ${maxOutputBytes} bytes and was stopped`));
158
230
  return;
159
231
  }
160
232
  chunks.push(chunk);
161
- };
233
+ });
162
234
 
163
- child.stdout.on('data', capture(stdoutChunks));
164
- child.stderr.on('data', capture(stderrChunks));
235
+ child.stdout?.on('data', capture(stdoutChunks));
236
+ child.stderr?.on('data', capture(stderrChunks));
237
+ /** The last `maxOutputBytes` of the stderr file, and its whole length. */
238
+ const readErrorFile = () => {
239
+ const size = fs.fstatSync(errorFile.fd).size;
240
+ const kept = Buffer.alloc(Math.min(size, maxOutputBytes));
241
+ fs.readSync(errorFile.fd, kept, 0, kept.length, size - kept.length);
242
+ return { size, kept };
243
+ };
165
244
 
166
245
  child.on('error', (error) => {
167
246
  liveTrees.delete(child);
247
+ closeErrorFile();
168
248
  fail(new SpaceError('command_spawn_failed', `Could not start ${file}: ${error.message}`, { errno: error.code ?? null }));
169
249
  });
170
250
 
@@ -175,13 +255,34 @@ export function runCommand(file, args, options = {}) {
175
255
  child.on('close', (code, signal) => {
176
256
  closed = true;
177
257
  for (const wake of closeWaiters.splice(0)) wake();
178
- if (settled) return;
258
+ if (settled) {
259
+ closeErrorFile();
260
+ return;
261
+ }
179
262
  settled = true;
180
263
  clearTimeout(timer);
264
+ abortSignal?.removeEventListener('abort', stop);
181
265
  if (code === null) {
266
+ closeErrorFile();
182
267
  reject(new SpaceError('command_killed', `${file} ${args[0] ?? ''} was stopped by signal ${signal}`));
183
268
  return;
184
269
  }
270
+ if (errorFile !== null) {
271
+ let read;
272
+ try {
273
+ read = readErrorFile();
274
+ } catch (error) {
275
+ reject(new SpaceError('command_output_unreadable', `The output of ${file} ${args[0] ?? ''} could not be read back: ${error.message}`, { exitCode: code }));
276
+ return;
277
+ } finally {
278
+ closeErrorFile();
279
+ }
280
+ if (!keepTail && read.size > maxOutputBytes) {
281
+ reject(new SpaceError('command_output_too_large', `${file} ${args[0] ?? ''} printed more than ${maxOutputBytes} bytes`));
282
+ return;
283
+ }
284
+ stderrChunks.push(read.kept);
285
+ }
185
286
  resolve({
186
287
  code,
187
288
  stdout: Buffer.concat(stdoutChunks).toString('utf8'),
@@ -259,3 +360,155 @@ export async function killProcessTree(child, {
259
360
  }
260
361
  return true;
261
362
  }
363
+
364
+ // How much of a streamed command's stderr is kept for its error message.
365
+ const STREAM_STDERR_BYTES = 4 * 1024;
366
+
367
+ /**
368
+ * A command's stdin and stdout as one duplex stream that an `http.Agent` can use as a socket.
369
+ * Written for `docker exec --interactive` with the bridge inside a space, which joins the two
370
+ * pipes to the loopback port of the server there: the dispatcher's requests travel through
371
+ * this and never through a network of the space. Everything an `http` client calls on a
372
+ * `net.Socket` is here: `setNoDelay`, `setKeepAlive`, `setTimeout`, `ref` and `unref`, and
373
+ * `destroy`, which kills the command. Backpressure runs both ways through the pipes.
374
+ *
375
+ * The stream ends when the command's stdout ends. A command that exits with a code other than
376
+ * zero, or before it wrote anything, destroys the stream with `command_stream_failed` and the
377
+ * tail of its stderr, so a bridge that could not reach the server inside says so.
378
+ */
379
+ class CommandStream extends Duplex {
380
+ #child;
381
+ #file;
382
+ #args;
383
+ #stderr = [];
384
+ #stderrBytes = 0;
385
+ #receivedBytes = 0;
386
+ #closed = false;
387
+ #timer = null;
388
+
389
+ constructor(child, file, args) {
390
+ super({ allowHalfOpen: false });
391
+ this.#child = child;
392
+ this.#file = file;
393
+ this.#args = args;
394
+ this.timeout = 0;
395
+ // A failure that arrives before a consumer listens must not end the process. Whoever holds
396
+ // the stream still gets the error; this listener only keeps it from being uncaught.
397
+ this.on('error', () => {});
398
+
399
+ child.stdout.on('data', (chunk) => {
400
+ this.#receivedBytes += chunk.length;
401
+ this.#touch();
402
+ if (!this.push(chunk)) child.stdout.pause();
403
+ });
404
+ // The readable side ends at the command's `close`, not at stdout's `end`, so that a command
405
+ // which failed can still end the stream with its reason rather than with a bare end.
406
+ child.stderr.on('data', (chunk) => {
407
+ if (this.#stderrBytes >= STREAM_STDERR_BYTES) return;
408
+ this.#stderr.push(chunk.subarray(0, STREAM_STDERR_BYTES - this.#stderrBytes));
409
+ this.#stderrBytes += chunk.length;
410
+ });
411
+ child.stdin.on('error', (error) => {
412
+ // After the command has ended, its input closing is not news.
413
+ if (!this.#closed) this.destroy(this.#failure(`its input closed: ${error.code ?? error.message}`));
414
+ });
415
+ child.on('error', (error) => {
416
+ this.#closed = true;
417
+ this.destroy(new SpaceError('command_spawn_failed', `Could not start ${file}: ${error.message}`, { errno: error.code ?? null }));
418
+ });
419
+ child.on('close', (code, signal) => {
420
+ this.#closed = true;
421
+ if (this.destroyed) return;
422
+ // A clean end: the readable side ends, the writable side follows (`allowHalfOpen: false`),
423
+ // and nothing is destroyed here, so what is still buffered is read.
424
+ if (code === 0 && this.#receivedBytes > 0) {
425
+ this.push(null);
426
+ return;
427
+ }
428
+ const why = code === null ? `stopped by signal ${signal}` : `exited with code ${code}`;
429
+ this.destroy(this.#failure(`${why}${this.#stderrText() ? `: ${this.#stderrText()}` : ''}`));
430
+ });
431
+ }
432
+
433
+ #stderrText() {
434
+ return Buffer.concat(this.#stderr).toString('utf8').trim();
435
+ }
436
+
437
+ #failure(what) {
438
+ return new SpaceError('command_stream_failed', `${this.#file} ${this.#args[0] ?? ''} ${what}`);
439
+ }
440
+
441
+ #touch() {
442
+ if (this.timeout > 0) this.setTimeout(this.timeout);
443
+ }
444
+
445
+ _read() {
446
+ this.#child.stdout.resume();
447
+ }
448
+
449
+ _write(chunk, _encoding, callback) {
450
+ this.#touch();
451
+ if (this.#closed) {
452
+ callback(this.#failure('has ended'));
453
+ return;
454
+ }
455
+ this.#child.stdin.write(chunk, callback);
456
+ }
457
+
458
+ _final(callback) {
459
+ if (this.#closed) {
460
+ callback();
461
+ return;
462
+ }
463
+ this.#child.stdin.end(callback);
464
+ }
465
+
466
+ _destroy(error, callback) {
467
+ if (this.#timer) clearTimeout(this.#timer);
468
+ this.#timer = null;
469
+ this.#child.stdout.removeAllListeners('data');
470
+ try {
471
+ this.#child.kill('SIGKILL');
472
+ } catch {
473
+ // Already gone.
474
+ }
475
+ callback(error);
476
+ }
477
+
478
+ /** Emits `timeout` after `milliseconds` without a byte in either direction. Zero cancels it. */
479
+ setTimeout(milliseconds, callback) {
480
+ if (this.#timer) clearTimeout(this.#timer);
481
+ this.#timer = null;
482
+ this.timeout = milliseconds;
483
+ if (callback) this.once('timeout', callback);
484
+ if (milliseconds > 0) {
485
+ this.#timer = setTimeout(() => { this.#timer = null; this.emit('timeout'); }, milliseconds);
486
+ this.#timer.unref?.();
487
+ }
488
+ return this;
489
+ }
490
+
491
+ setNoDelay() { return this; }
492
+
493
+ setKeepAlive() { return this; }
494
+
495
+ ref() { return this; }
496
+
497
+ unref() { return this; }
498
+ }
499
+
500
+ /**
501
+ * Starts one executable with an argument array and hands back its stdin and stdout as a
502
+ * `CommandStream`. The executable is spawned directly, never through a shell, hidden on
503
+ * Windows. It is the transport of the place contract's `connect`. A command that cannot be
504
+ * started throws `command_spawn_failed` at once.
505
+ */
506
+ export function openCommandStream(file, args) {
507
+ let child;
508
+ try {
509
+ child = spawn(file, args, { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
510
+ } catch (error) {
511
+ throw new SpaceError('command_spawn_failed', `Could not start ${file}: ${error.message}`, { errno: error.code ?? null });
512
+ }
513
+ return new CommandStream(child, file, args);
514
+ }