@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,36 @@
1
+ import { NotificationService } from '@camstack/core'
2
+ import type { Notification } from '@camstack/types'
3
+ import { CapabilityService } from '../capability/capability.service'
4
+ import { LoggingService } from '../logging/logging.service'
5
+
6
+ /**
7
+ * NestJS-injectable wrapper around the core NotificationService.
8
+ *
9
+ * Lazily creates the NotificationService on first use, wiring in the
10
+ * CapabilityRegistry for proxy-based output resolution.
11
+ */
12
+ export class NotificationServiceWrapper {
13
+ private _service: NotificationService | null = null
14
+
15
+ constructor(
16
+ private readonly caps: CapabilityService,
17
+ private readonly logging: LoggingService,
18
+ ) {}
19
+
20
+ get service(): NotificationService {
21
+ if (!this._service) {
22
+ this._service = new NotificationService(
23
+ this.logging.createLogger('notifications'),
24
+ )
25
+ const registry = this.caps.getRegistry()
26
+ if (registry) {
27
+ this._service.setRegistry(registry)
28
+ }
29
+ }
30
+ return this._service
31
+ }
32
+
33
+ async notify(notification: Notification): Promise<void> {
34
+ return this.service.notify(notification)
35
+ }
36
+ }
@@ -0,0 +1,31 @@
1
+ import { ToastService } from '@camstack/core'
2
+ import type { Toast } from '@camstack/types'
3
+
4
+ /**
5
+ * NestJS-injectable wrapper around the core ToastService.
6
+ *
7
+ * Provides toast broadcasting to connected UI clients.
8
+ */
9
+ export class ToastServiceWrapper {
10
+ private readonly _service = new ToastService()
11
+
12
+ get service(): ToastService {
13
+ return this._service
14
+ }
15
+
16
+ broadcast(toast: Toast): void {
17
+ this._service.broadcast(toast)
18
+ }
19
+
20
+ sendToUser(userId: string, toast: Toast): void {
21
+ this._service.sendToUser(userId, toast)
22
+ }
23
+
24
+ subscribe(
25
+ connectionId: string,
26
+ userId: string,
27
+ callback: (toast: Toast) => void,
28
+ ): () => void {
29
+ return this._service.subscribe(connectionId, userId, callback)
30
+ }
31
+ }
@@ -0,0 +1 @@
1
+ export const PROVIDER_MANAGER = Symbol('PROVIDER_MANAGER')
@@ -0,0 +1,417 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -- test file: mock typing crosses generic boundaries */
2
+ /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ /**
4
+ * ReplEngineService specs — exercises the actual current shape of the
5
+ * REPL service, including the SystemManager warm-boot path that
6
+ * recently hung the entire REPL when the broker tried to route
7
+ * `live.onEvent` through the Moleculer service registry.
8
+ *
9
+ * Mocks at the AddonRegistry boundary (the dependency of the service)
10
+ * — the rest is real: ReplEngine, SystemManager, EventBus adapter.
11
+ */
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
13
+ import { ReplEngineService } from './repl-engine.service'
14
+ import { SystemEventBus } from '@camstack/core'
15
+ import type { ReplSessionContext } from '@camstack/core'
16
+ import type { DeviceBinding } from '@camstack/types'
17
+ import { DeviceType } from '@camstack/types'
18
+
19
+ // ── In-process broker api fixture ─────────────────────────────────────
20
+ //
21
+ // Mimics what `addonRegistry.getBrokerApi()` returns: a plain object
22
+ // with `<cap>.<method>.{query|mutate}` shape that resolves locally.
23
+ // The REPL service merges `live.onEvent` via the EventBus adapter on
24
+ // top of this base.
25
+
26
+ interface BrokerApiState {
27
+ bindings: Map<number, DeviceBinding>
28
+ snapshots: Record<string, Record<string, Record<string, unknown>>>
29
+ devices: Map<number, {
30
+ id: number
31
+ stableId: string
32
+ addonId: string
33
+ type: DeviceType
34
+ name: string
35
+ parentDeviceId: number | null
36
+ role: string | null
37
+ online: boolean
38
+ features: string[]
39
+ isCamera: boolean
40
+ config: Record<string, unknown>
41
+ }>
42
+ }
43
+
44
+ function makeBrokerApi(state: BrokerApiState): unknown {
45
+ return {
46
+ deviceManager: {
47
+ getAllBindings: {
48
+ query: vi.fn(async () => Array.from(state.bindings.values())),
49
+ },
50
+ listAll: {
51
+ query: vi.fn(async () => Array.from(state.devices.values())),
52
+ },
53
+ getBindings: {
54
+ query: vi.fn(async ({ deviceId }: { deviceId: number }) =>
55
+ state.bindings.get(deviceId) ?? { deviceId, entries: [] }),
56
+ },
57
+ },
58
+ deviceState: {
59
+ getCapSlice: {
60
+ query: vi.fn(async ({ deviceId, capName }: { deviceId: number; capName: string }) =>
61
+ state.snapshots[String(deviceId)]?.[capName] ?? null),
62
+ },
63
+ getAllSnapshots: {
64
+ query: vi.fn(async () => state.snapshots),
65
+ },
66
+ },
67
+ }
68
+ }
69
+
70
+ // ── Harness ───────────────────────────────────────────────────────────
71
+
72
+ interface Harness {
73
+ service: ReplEngineService
74
+ state: BrokerApiState
75
+ eventBus: SystemEventBus
76
+ /** Re-fetch the broker api spies for assertion. */
77
+ brokerApi: any
78
+ }
79
+
80
+ function makeHarness(seed?: Partial<BrokerApiState>): Harness {
81
+ // Reset the static SystemMirror singleton so each test gets a fresh
82
+ // warm-boot. The service caches it in static class fields — accept
83
+ // the test-only reach-in to keep tests isolated.
84
+ ;(ReplEngineService as any).systemMirror = null
85
+ ;(ReplEngineService as any).systemMirrorInit = null
86
+
87
+ const state: BrokerApiState = {
88
+ bindings: seed?.bindings ?? new Map(),
89
+ snapshots: seed?.snapshots ?? {},
90
+ devices: seed?.devices ?? new Map(),
91
+ }
92
+
93
+ const brokerApi = makeBrokerApi(state)
94
+ const eventBus = new SystemEventBus(1000)
95
+
96
+ // Real EventBusService stub — the service only calls .subscribe.
97
+ const eventBusService = {
98
+ subscribe: (filter: any, handler: any) => eventBus.subscribe(filter, handler),
99
+ emit: (evt: any) => eventBus.emit(evt),
100
+ getRecent: () => [],
101
+ } as any
102
+
103
+ const deviceRegistry = {
104
+ getAll: () => Array.from(state.devices.values()),
105
+ getById: (id: number) => state.devices.get(id) ?? null,
106
+ getAllForAddon: (addonId: string) =>
107
+ Array.from(state.devices.values()).filter((d) => d.addonId === addonId),
108
+ getAllWithAddonId: () =>
109
+ Array.from(state.devices.values()).map((d) => ({ addonId: d.addonId, device: d })),
110
+ getAddonId: (id: number) => state.devices.get(id)?.addonId ?? null,
111
+ }
112
+
113
+ const integrationRegistry = {
114
+ listIntegrations: vi.fn(async () => []),
115
+ getIntegration: vi.fn(async () => null),
116
+ }
117
+
118
+ const addonRegistry = {
119
+ getBrokerApi: () => brokerApi,
120
+ getDeviceRegistry: () => deviceRegistry,
121
+ getIntegrationRegistry: () => integrationRegistry,
122
+ listAddons: () => [],
123
+ } as any
124
+
125
+ const loggingService = {
126
+ createLogger: () => ({ info: vi.fn(), child: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }),
127
+ } as any
128
+
129
+ const service = new ReplEngineService(addonRegistry, eventBusService, loggingService)
130
+ return { service, state, eventBus, brokerApi }
131
+ }
132
+
133
+ const mkBinding = (deviceId: number, capNames: string[]): DeviceBinding => ({
134
+ deviceId,
135
+ entries: capNames.map((capName) => ({
136
+ capName,
137
+ kind: 'native' as const,
138
+ providerAddonId: 'test',
139
+ providerNodeId: 'hub',
140
+ nativeAddonId: 'test',
141
+ })),
142
+ })
143
+
144
+ const mkDevice = (id: number, overrides: Partial<BrokerApiState['devices'] extends Map<number, infer V> ? V : never> = {}): BrokerApiState['devices'] extends Map<number, infer V> ? V : never => ({
145
+ id,
146
+ stableId: `stable-${id}`,
147
+ addonId: 'addon-test',
148
+ type: DeviceType.Camera,
149
+ name: `Device ${id}`,
150
+ parentDeviceId: null,
151
+ role: null,
152
+ online: true,
153
+ features: [],
154
+ isCamera: true,
155
+ config: {},
156
+ ...overrides,
157
+ } as any)
158
+
159
+ const SYSTEM_CTX: ReplSessionContext = { scope: { type: 'system' }, variables: {} }
160
+
161
+ // ── Tests ─────────────────────────────────────────────────────────────
162
+
163
+ describe('ReplEngineService — basic eval', () => {
164
+ let h: Harness
165
+ beforeEach(() => { h = makeHarness() })
166
+
167
+ it('evaluates simple arithmetic expressions', async () => {
168
+ const r = await h.service.execute('1 + 1', SYSTEM_CTX)
169
+ expect(r.type).toBe('value')
170
+ expect(r.output).toBe('2')
171
+ })
172
+
173
+ it('returns void for variable declarations', async () => {
174
+ const r = await h.service.execute('const x = 42', SYSTEM_CTX)
175
+ expect(r.type).toBe('void')
176
+ })
177
+
178
+ it('returns error type when user code throws', async () => {
179
+ const r = await h.service.execute("throw new Error('boom')", SYSTEM_CTX)
180
+ expect(r.type).toBe('error')
181
+ expect(r.output).toContain('boom')
182
+ })
183
+
184
+ it('reports duration for every eval', async () => {
185
+ const r = await h.service.execute('1 + 1', SYSTEM_CTX)
186
+ expect(r.duration).toBeGreaterThanOrEqual(0)
187
+ expect(r.duration).toBeLessThan(5_000)
188
+ })
189
+
190
+ it('isolates blocked globals — fetch / setTimeout / require unavailable', async () => {
191
+ const fetchR = await h.service.execute('typeof fetch', SYSTEM_CTX)
192
+ expect(fetchR.output).toBe("'undefined'")
193
+ const reqR = await h.service.execute('typeof require', SYSTEM_CTX)
194
+ expect(reqR.output).toBe("'undefined'")
195
+ const setTimeoutR = await h.service.execute('typeof setTimeout', SYSTEM_CTX)
196
+ expect(setTimeoutR.output).toBe("'undefined'")
197
+ })
198
+
199
+ it('exposes JSON / Math / Date as standard globals', async () => {
200
+ const json = await h.service.execute('JSON.stringify({a:1})', SYSTEM_CTX)
201
+ expect(json.output).toContain('"a":1')
202
+ const math = await h.service.execute('Math.max(1, 5, 3)', SYSTEM_CTX)
203
+ expect(math.output).toBe('5')
204
+ })
205
+ })
206
+
207
+ describe('ReplEngineService — system scope sandbox', () => {
208
+ let h: Harness
209
+ beforeEach(() => {
210
+ h = makeHarness({
211
+ bindings: new Map([[1, mkBinding(1, ['battery'])]]),
212
+ devices: new Map([[1, mkDevice(1, { name: 'Test Cam' })]]),
213
+ snapshots: { '1': { battery: { sleeping: true, percentage: 50 } } },
214
+ })
215
+ })
216
+
217
+ it('binds `sm` to the warm-booted SystemManager', async () => {
218
+ const r = await h.service.execute('typeof sm', SYSTEM_CTX)
219
+ expect(r.output).toBe("'object'")
220
+ })
221
+
222
+ it('sm.getDeviceById returns a typed proxy with sync state', async () => {
223
+ const r = await h.service.execute('sm.getDeviceById(1).state.battery.value.sleeping', SYSTEM_CTX)
224
+ expect(r.type).toBe('value')
225
+ expect(r.output).toBe('true')
226
+ })
227
+
228
+ it('sm.getDeviceByName resolves the metadata mirror', async () => {
229
+ const r = await h.service.execute("sm.getDeviceByName('Test Cam').deviceId", SYSTEM_CTX)
230
+ expect(r.output).toBe('1')
231
+ })
232
+
233
+ it('sm.summary returns counts', async () => {
234
+ const r = await h.service.execute('sm.summary().totalDevices', SYSTEM_CTX)
235
+ expect(r.output).toBe('1')
236
+ })
237
+
238
+ it('sm.query filters by addonId + caps + online', async () => {
239
+ h.state.bindings.set(2, mkBinding(2, ['snapshot']))
240
+ h.state.devices.set(2, mkDevice(2, { name: 'Cam 2', addonId: 'reolink', online: true }))
241
+ h.state.devices.set(1, mkDevice(1, { name: 'Cam 1', addonId: 'reolink', online: false }))
242
+ // Re-warm-boot the SM so the new devices show up.
243
+ ;(ReplEngineService as any).systemMirror = null
244
+ ;(ReplEngineService as any).systemMirrorInit = null
245
+
246
+ const r = await h.service.execute(
247
+ "sm.query({ addonId: 'reolink', online: true }).map(d => d.deviceId)",
248
+ SYSTEM_CTX,
249
+ )
250
+ expect(r.output).toContain('2')
251
+ expect(r.output).not.toContain('1,')
252
+ })
253
+
254
+ it('legacy variables remain accessible (backward-compat)', async () => {
255
+ const r1 = await h.service.execute('typeof addonRegistry', SYSTEM_CTX)
256
+ expect(r1.output).toBe("'object'")
257
+ const r2 = await h.service.execute('typeof eventBus', SYSTEM_CTX)
258
+ expect(r2.output).toBe("'object'")
259
+ const r3 = await h.service.execute('typeof getDevice', SYSTEM_CTX)
260
+ expect(r3.output).toBe("'function'")
261
+ })
262
+ })
263
+
264
+ describe('ReplEngineService — device scope sandbox', () => {
265
+ const DEVICE_CTX: ReplSessionContext = {
266
+ scope: { type: 'device', deviceId: 8 },
267
+ variables: {},
268
+ }
269
+
270
+ let h: Harness
271
+ beforeEach(() => {
272
+ h = makeHarness({
273
+ bindings: new Map([[8, mkBinding(8, ['battery', 'snapshot'])]]),
274
+ devices: new Map([[8, mkDevice(8, { name: 'Sala', addonId: 'reolink' })]]),
275
+ snapshots: { '8': { battery: { sleeping: false, percentage: 88 } } },
276
+ })
277
+ })
278
+
279
+ it('pre-binds `device` as a DeviceProxy with sync state', async () => {
280
+ const r = await h.service.execute('device.state.battery.value.percentage', DEVICE_CTX)
281
+ expect(r.type).toBe('value')
282
+ expect(r.output).toBe('88')
283
+ })
284
+
285
+ it('exposes deviceId, info, sm, rawDevice', async () => {
286
+ const a = await h.service.execute('deviceId', DEVICE_CTX)
287
+ expect(a.output).toBe('8')
288
+ const b = await h.service.execute('info.name', DEVICE_CTX)
289
+ expect(b.output).toBe("'Sala'")
290
+ const c = await h.service.execute('typeof sm', DEVICE_CTX)
291
+ expect(c.output).toBe("'object'")
292
+ const d = await h.service.execute('typeof rawDevice', DEVICE_CTX)
293
+ // rawDevice is the raw IDevice from registry — null in our harness for device 8 (we use plain objects)
294
+ // but the type check ensures the binding is in scope.
295
+ expect(['undefined', "'object'"]).toContain(d.output)
296
+ })
297
+
298
+ it('device proxy exposes .state for every cap with runtimeState', async () => {
299
+ const r = await h.service.execute('typeof device.state.battery.subscribe', DEVICE_CTX)
300
+ expect(r.output).toBe("'function'")
301
+ const r2 = await h.service.execute('typeof device.state.motion.value', DEVICE_CTX)
302
+ expect(r2.output).toBe("'undefined'")
303
+ })
304
+
305
+ it('device proxy exposes cap method dispatchers', async () => {
306
+ const r = await h.service.execute('typeof device.snapshot.getSnapshot', DEVICE_CTX)
307
+ expect(r.output).toBe("'function'")
308
+ })
309
+ })
310
+
311
+ describe('ReplEngineService — SystemManager warm-boot resilience', () => {
312
+ it('warm-boot does NOT hang on `live.onEvent` (regression — broker can\'t route)', async () => {
313
+ // The bug: `getBrokerApi().live.onEvent.subscribe(...)` polls
314
+ // forever for a Moleculer service that doesn't exist. The fix
315
+ // injects a direct EventBus adapter for `live` so SM init never
316
+ // touches the broker for subscriptions.
317
+ const h = makeHarness({
318
+ bindings: new Map([[1, mkBinding(1, ['battery'])]]),
319
+ devices: new Map([[1, mkDevice(1)]]),
320
+ snapshots: {},
321
+ })
322
+
323
+ // 5 second hard ceiling — way above the 15s SM timeout but below
324
+ // the broker's infinite poll. If the bug regresses this test
325
+ // hangs the runner, not silently passes.
326
+ const result = await Promise.race([
327
+ h.service.execute('typeof sm', SYSTEM_CTX),
328
+ new Promise<never>((_, reject) =>
329
+ setTimeout(() => reject(new Error('test ceiling — REPL eval hung')), 5_000),
330
+ ),
331
+ ])
332
+ expect(result.type).toBe('value')
333
+ expect(result.output).toBe("'object'")
334
+ })
335
+
336
+ it('first eval triggers warm-boot; subsequent evals reuse the cached SystemManager', async () => {
337
+ const h = makeHarness({
338
+ bindings: new Map([[1, mkBinding(1, ['battery'])]]),
339
+ devices: new Map([[1, mkDevice(1)]]),
340
+ snapshots: {},
341
+ })
342
+
343
+ await h.service.execute('typeof sm', SYSTEM_CTX)
344
+ await h.service.execute('typeof sm', SYSTEM_CTX)
345
+ await h.service.execute('typeof sm', SYSTEM_CTX)
346
+
347
+ expect(h.brokerApi.deviceManager.getAllBindings.query).toHaveBeenCalledTimes(1)
348
+ expect(h.brokerApi.deviceState.getAllSnapshots.query).toHaveBeenCalledTimes(1)
349
+ expect(h.brokerApi.deviceManager.listAll.query).toHaveBeenCalledTimes(1)
350
+ })
351
+
352
+ it('live.onEvent uses the in-process EventBus, not the broker', async () => {
353
+ const h = makeHarness({
354
+ bindings: new Map([[1, mkBinding(1, ['battery'])]]),
355
+ devices: new Map([[1, mkDevice(1)]]),
356
+ snapshots: { '1': { battery: { sleeping: true, percentage: 50 } } },
357
+ })
358
+
359
+ // Boot SM via first eval.
360
+ await h.service.execute('typeof sm', SYSTEM_CTX)
361
+
362
+ // Fire an in-process state-change event — SM mirror should pick it up.
363
+ h.eventBus.emit({
364
+ id: 'test-1',
365
+ timestamp: Date.now(),
366
+ category: 'device.state-changed',
367
+ source: { type: 'device', id: 1, deviceId: 1 },
368
+ data: { deviceId: 1, capName: 'battery', slice: { sleeping: false, percentage: 90 } },
369
+ })
370
+ // Microtask flush.
371
+ await new Promise((r) => setTimeout(r, 10))
372
+
373
+ const r = await h.service.execute('sm.getDeviceById(1).state.battery.value.sleeping', SYSTEM_CTX)
374
+ expect(r.output).toBe('false')
375
+ })
376
+ })
377
+
378
+ describe('ReplEngineService — error paths', () => {
379
+ let h: Harness
380
+ beforeEach(() => { h = makeHarness() })
381
+
382
+ it('returns error when accessing undefined variables', async () => {
383
+ const r = await h.service.execute('undefinedVariable.foo', SYSTEM_CTX)
384
+ expect(r.type).toBe('error')
385
+ })
386
+
387
+ it('error output does not leak the engine internals', async () => {
388
+ const r = await h.service.execute('throw new TypeError("user error")', SYSTEM_CTX)
389
+ expect(r.type).toBe('error')
390
+ expect(r.output).toContain('user error')
391
+ // Should NOT contain stack frames from repl-engine.ts itself.
392
+ expect(r.output).not.toContain('repl-engine.ts')
393
+ })
394
+ })
395
+
396
+ describe('ReplEngineService — completions', () => {
397
+ let h: Harness
398
+ beforeEach(() => {
399
+ h = makeHarness({
400
+ bindings: new Map([[1, mkBinding(1, ['battery'])]]),
401
+ devices: new Map([[1, mkDevice(1)]]),
402
+ snapshots: {},
403
+ })
404
+ })
405
+
406
+ it('returns sandbox keys when partial is empty', async () => {
407
+ const completions = await h.service.getCompletions('', SYSTEM_CTX)
408
+ expect(completions).toContain('sm')
409
+ expect(completions).toContain('JSON')
410
+ expect(completions).toContain('Math')
411
+ })
412
+
413
+ it('filters by partial prefix', async () => {
414
+ const completions = await h.service.getCompletions('sm', SYSTEM_CTX)
415
+ expect(completions).toContain('sm')
416
+ })
417
+ })
@@ -0,0 +1,156 @@
1
+ import { ReplEngine } from '@camstack/core'
2
+ import type { IReplContextProvider } from '@camstack/core'
3
+ import { SystemMirror, type SystemMirrorApi, type DeviceProxy } from '@camstack/types'
4
+ import { AddonRegistryService } from '../addon/addon-registry.service'
5
+ import { EventBusService } from '../events/event-bus.service'
6
+ import { LoggingService } from '../logging/logging.service'
7
+
8
+ export class ReplEngineService extends ReplEngine {
9
+ /**
10
+ * Lazily-instantiated `SystemMirror` shared across REPL sessions.
11
+ * Holds a single warm-boot mirror that every `sm.getDeviceById(id)`
12
+ * lookup serves from. Init runs at first access (Promise cached so
13
+ * concurrent sessions don't double-fetch).
14
+ */
15
+ private static systemMirror: SystemMirror | null = null
16
+ private static systemMirrorInit: Promise<SystemMirror> | null = null
17
+
18
+ /**
19
+ * Build a `SystemMirrorApi` for in-process server use. The cap-method
20
+ * surface (deviceManager / deviceState queries) goes through the
21
+ * standard broker tRPC client — those resolve locally via
22
+ * `localProviderLink`. The `live.onEvent` channel is NOT a cap, so
23
+ * the broker can't route it; we synthesize the same shape over the
24
+ * local `EventBusService` so SystemMirror subscriptions work without
25
+ * crossing the network boundary or polling the broker for a
26
+ * non-existent `live` service.
27
+ */
28
+ private static buildInProcessApi(
29
+ addonRegistry: AddonRegistryService,
30
+ eventBus: EventBusService,
31
+ ): SystemMirrorApi {
32
+ const baseApi = addonRegistry.getBrokerApi() as unknown as SystemMirrorApi
33
+ return {
34
+ ...baseApi,
35
+ live: {
36
+ onEvent: {
37
+ subscribe: (input, opts) => {
38
+ const off = eventBus.subscribe(
39
+ { category: input.category },
40
+ (evt) => {
41
+ try {
42
+ opts.onData({ data: evt.data })
43
+ } catch (err) {
44
+ opts.onError?.(err)
45
+ }
46
+ },
47
+ )
48
+ return { unsubscribe: off }
49
+ },
50
+ },
51
+ },
52
+ }
53
+ }
54
+
55
+ private static getOrInitSystemMirror(
56
+ addonRegistry: AddonRegistryService,
57
+ eventBus: EventBusService,
58
+ ): Promise<SystemMirror> {
59
+ if (this.systemMirror) return Promise.resolve(this.systemMirror)
60
+ if (!this.systemMirrorInit) {
61
+ const api = this.buildInProcessApi(addonRegistry, eventBus)
62
+ const sm = new SystemMirror(api)
63
+ this.systemMirrorInit = sm.init().then(() => {
64
+ this.systemMirror = sm
65
+ return sm
66
+ })
67
+ }
68
+ return this.systemMirrorInit
69
+ }
70
+
71
+ constructor(
72
+ addonRegistry: AddonRegistryService,
73
+ eventBus: EventBusService,
74
+ _loggingService: LoggingService,
75
+ ) {
76
+ const contextProvider: IReplContextProvider = {
77
+ async getSystemSandbox() {
78
+ const integrationRegistry = addonRegistry.getIntegrationRegistry()
79
+ const deviceRegistry = addonRegistry.getDeviceRegistry()
80
+ // Warm-boot the SystemMirror so `sm.getDeviceById(id)` is sync
81
+ // for the first user expression.
82
+ const sm = await ReplEngineService.getOrInitSystemMirror(addonRegistry, eventBus)
83
+ return {
84
+ // ── New canonical API ───────────────────────────────────────
85
+ /**
86
+ * SystemMirror — the cap-driven, reactive view of every
87
+ * device. Sync `getDeviceById(id)`, typed `state.<cap>.value`
88
+ * reads, full method dispatch, query helpers.
89
+ */
90
+ sm,
91
+ // ── Legacy (kept for backward-compat) ──────────────────────
92
+ addonRegistry,
93
+ eventBus,
94
+ integrationRegistry,
95
+ devices: () => deviceRegistry.getAll(),
96
+ integrations: async () => (await integrationRegistry?.listIntegrations()) ?? [],
97
+ addons: () => addonRegistry.listAddons(),
98
+ getDevice: (id: number) => deviceRegistry.getById(id),
99
+ getIntegration: async (id: string) => (await integrationRegistry?.getIntegration(id)) ?? null,
100
+ getSystemMirror: () => Promise.resolve(sm),
101
+ }
102
+ },
103
+ async getDeviceSandbox(deviceId: number) {
104
+ const deviceRegistry = addonRegistry.getDeviceRegistry()
105
+ const rawDevice = deviceRegistry.getById(deviceId)
106
+ const sm = await ReplEngineService.getOrInitSystemMirror(addonRegistry, eventBus)
107
+ // `device` is the typed DeviceProxy backed by the SystemMirror
108
+ // mirror — same shape as `sm.getDeviceById(deviceId)`. Sync
109
+ // state reads + cap-method dispatch via the wrapper chain.
110
+ const device: DeviceProxy | null = sm.getDeviceById(deviceId)
111
+ return {
112
+ /** SystemMirror — full cluster view. */
113
+ sm,
114
+ /**
115
+ * The current device as a DeviceProxy. Sync state reads
116
+ * (`device.state.battery.value`) + async cap methods
117
+ * (`await device.snapshot.getSnapshot({})`).
118
+ */
119
+ device,
120
+ /** Numeric device id (same as URL). */
121
+ deviceId,
122
+ /** Device metadata (name, addonId, type, online, …). */
123
+ info: sm.getDeviceInfo(deviceId),
124
+ /** Raw IDevice instance — escape hatch for legacy access.
125
+ * Prefer `device` (DeviceProxy) for new code. */
126
+ rawDevice,
127
+ }
128
+ },
129
+ getProviderSandbox(addonId: string) {
130
+ const integrationRegistry = addonRegistry.getIntegrationRegistry()
131
+ const deviceRegistry = addonRegistry.getDeviceRegistry()
132
+ const devices = deviceRegistry.getAllForAddon(addonId)
133
+ return {
134
+ getIntegration: () => integrationRegistry?.getIntegration(addonId) ?? Promise.resolve(null),
135
+ devices,
136
+ }
137
+ },
138
+ getAddonSandbox(addonId: string) {
139
+ // REPL exposes addon metadata only — never the live in-process
140
+ // instance. Direct addon access only works for hub-local addons
141
+ // and breaks for remote-agent addons. To invoke addon behaviour
142
+ // from the REPL, use the cap router via tRPC instead.
143
+ const entry = addonRegistry.listAddons().find((e) => e.manifest.id === addonId)
144
+ return {
145
+ manifest: entry?.manifest,
146
+ declaration: entry?.declaration,
147
+ source: entry?.source,
148
+ installSource: entry?.installSource,
149
+ process: entry?.process,
150
+ }
151
+ },
152
+ }
153
+
154
+ super(contextProvider)
155
+ }
156
+ }