@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,212 @@
1
+ import { execFile } from 'child_process'
2
+ import { promisify } from 'util'
3
+
4
+ import { LoggingService } from '../logging/logging.service'
5
+ import type { StreamMetadata, StreamAudioMetadata } from '@camstack/types'
6
+ import type { IScopedLogger } from '@camstack/types'
7
+ import { errMsg } from '@camstack/types'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ const CACHE_TTL_MS = 3_600_000 // 1 hour
12
+ const PROBE_TIMEOUT_MS = 5_000
13
+
14
+ /** Codec aliases normalised to canonical names. */
15
+ const CODEC_ALIASES: Readonly<Record<string, string>> = {
16
+ hevc: 'h265',
17
+ }
18
+
19
+ interface CacheEntry {
20
+ readonly metadata: StreamMetadata
21
+ readonly timestamp: number
22
+ }
23
+
24
+ export class StreamProbeService {
25
+ private readonly logger: IScopedLogger
26
+ private readonly cache = new Map<string, CacheEntry>()
27
+
28
+ constructor(
29
+ loggingService: LoggingService,
30
+ ) {
31
+ this.logger = loggingService.createLogger('StreamProbeService')
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Public API
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** Probe a stream URL and return its metadata (cached for 1 hour). */
39
+ async probe(url: string, options?: { force?: boolean }): Promise<StreamMetadata> {
40
+ const force = options?.force ?? false
41
+
42
+ if (!force) {
43
+ const cached = this.cache.get(url)
44
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
45
+ return cached.metadata
46
+ }
47
+ }
48
+
49
+ const metadata = await this.runProbe(url)
50
+ this.cache.set(url, { metadata, timestamp: Date.now() })
51
+ return metadata
52
+ }
53
+
54
+ /**
55
+ * Generic field probe: given a field key and value, decides how to probe.
56
+ * Stream fields (stream_*) → ffprobe, other URLs → HTTP HEAD check.
57
+ */
58
+ async probeField(key: string, value: unknown): Promise<{ status: 'ok' | 'error'; labels?: string[]; error?: string }> {
59
+ const url = String(value ?? '').trim()
60
+ if (!url) {
61
+ return { status: 'error', error: 'No URL provided' }
62
+ }
63
+
64
+ // Stream fields: use ffprobe
65
+ if (key.startsWith('stream')) {
66
+ const meta = await this.probe(url, { force: true })
67
+ const labels: string[] = []
68
+ if (meta.width && meta.height) labels.push(`${meta.width}\u00d7${meta.height}`)
69
+ if (meta.codec) {
70
+ const codecDisplay: Record<string, string> = { h265: 'H.265', h264: 'H.264', hevc: 'H.265' }
71
+ labels.push(codecDisplay[meta.codec] ?? meta.codec.toUpperCase())
72
+ }
73
+ if (meta.fps) labels.push(`${Math.round(meta.fps)}fps`)
74
+ if (meta.audio?.codec) {
75
+ const audioCodec = meta.audio.codec.toUpperCase()
76
+ const audioBits: string[] = [audioCodec]
77
+ if (meta.audio.sampleRate) audioBits.push(`${Math.round(meta.audio.sampleRate / 1000)}kHz`)
78
+ if (meta.audio.channels === 1) audioBits.push('mono')
79
+ else if (meta.audio.channels === 2) audioBits.push('stereo')
80
+ else if (meta.audio.channels) audioBits.push(`${meta.audio.channels}ch`)
81
+ labels.push(`audio: ${audioBits.join(' ')}`)
82
+ }
83
+ if (labels.length === 0) {
84
+ return { status: 'error', error: 'No video streams found at URL' }
85
+ }
86
+ return { status: 'ok', labels }
87
+ }
88
+
89
+ // Other URL fields (snapshot, etc.): GET with early abort to check reachability + content-type
90
+ try {
91
+ const controller = new AbortController()
92
+ const timeout = AbortSignal.timeout(5000)
93
+ timeout.addEventListener('abort', () => controller.abort(timeout.reason))
94
+ const response = await fetch(url, { method: 'GET', signal: controller.signal })
95
+ if (!response.ok) {
96
+ controller.abort()
97
+ return { status: 'error', error: `HTTP ${response.status} ${response.statusText}` }
98
+ }
99
+ const contentType = response.headers.get('content-type') ?? ''
100
+ controller.abort() // Don't download the full body
101
+ const labels: string[] = ['Reachable']
102
+ if (contentType.startsWith('image/')) {
103
+ labels.push(contentType.replace('image/', '').toUpperCase())
104
+ }
105
+ return { status: 'ok', labels }
106
+ } catch (err) {
107
+ if (err instanceof Error && err.name === 'AbortError') {
108
+ // We aborted intentionally after reading headers — that's success
109
+ return { status: 'ok', labels: ['Reachable'] }
110
+ }
111
+ const msg = errMsg(err)
112
+ return { status: 'error', error: `Unreachable: ${msg}` }
113
+ }
114
+ }
115
+
116
+ /** Clear cached metadata for a single URL or all URLs. */
117
+ clearCache(url?: string): void {
118
+ if (url) {
119
+ this.cache.delete(url)
120
+ } else {
121
+ this.cache.clear()
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Internal
127
+ // ---------------------------------------------------------------------------
128
+
129
+ private async runProbe(url: string): Promise<StreamMetadata> {
130
+ try {
131
+ // Query first video + first audio stream in one ffprobe pass.
132
+ // `-show_streams` returns every stream in the container; we filter
133
+ // by codec_type when parsing. Fields cover both kinds.
134
+ const { stdout } = await execFileAsync('ffprobe', [
135
+ '-v', 'error',
136
+ '-rtsp_transport', 'tcp',
137
+ '-timeout', '5000000',
138
+ '-show_entries',
139
+ 'stream=codec_type,codec_name,profile,width,height,r_frame_rate,bit_rate,sample_rate,channels',
140
+ '-of', 'json',
141
+ url,
142
+ ], { timeout: PROBE_TIMEOUT_MS })
143
+
144
+ return this.parseOutput(stdout)
145
+ } catch (err) {
146
+ this.logger.error('ffprobe failed', { meta: { url, error: String(err) } })
147
+ return {}
148
+ }
149
+ }
150
+
151
+ private parseOutput(stdout: string): StreamMetadata {
152
+ const parsed: unknown = JSON.parse(stdout)
153
+ const streams = (parsed as { streams?: unknown[] }).streams
154
+ if (!Array.isArray(streams) || streams.length === 0) {
155
+ return {}
156
+ }
157
+
158
+ const meta: StreamMetadata = {}
159
+
160
+ for (const raw of streams) {
161
+ const stream = raw as Record<string, unknown>
162
+ const codecType = typeof stream.codec_type === 'string' ? stream.codec_type : undefined
163
+
164
+ if (codecType === 'video' && meta.codec === undefined) {
165
+ const rawCodec = typeof stream.codec_name === 'string' ? stream.codec_name : undefined
166
+ meta.codec = rawCodec ? (CODEC_ALIASES[rawCodec] ?? rawCodec) : undefined
167
+ if (typeof stream.width === 'number') meta.width = stream.width
168
+ if (typeof stream.height === 'number') meta.height = stream.height
169
+ const fps = this.parseFps(stream.r_frame_rate)
170
+ if (fps !== undefined) meta.fps = fps
171
+ const bitrateKbps = this.parseBitrateKbps(stream.bit_rate)
172
+ if (bitrateKbps !== undefined) meta.bitrateKbps = bitrateKbps
173
+ } else if (codecType === 'audio' && meta.audio === undefined) {
174
+ const audio: StreamAudioMetadata = {}
175
+ if (typeof stream.codec_name === 'string') audio.codec = stream.codec_name
176
+ if (typeof stream.profile === 'string') audio.profile = stream.profile
177
+ const sampleRate = this.parseIntField(stream.sample_rate)
178
+ if (sampleRate !== undefined) audio.sampleRate = sampleRate
179
+ if (typeof stream.channels === 'number') audio.channels = stream.channels
180
+ const audioBitrateKbps = this.parseBitrateKbps(stream.bit_rate)
181
+ if (audioBitrateKbps !== undefined) audio.bitrateKbps = audioBitrateKbps
182
+ meta.audio = audio
183
+ }
184
+ }
185
+
186
+ return meta
187
+ }
188
+
189
+ private parseIntField(value: unknown): number | undefined {
190
+ const n = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : NaN
191
+ if (!Number.isFinite(n) || n <= 0) return undefined
192
+ return Math.round(n)
193
+ }
194
+
195
+ /** Parse fractional frame rate string like "25/1" → 25. */
196
+ private parseFps(value: unknown): number | undefined {
197
+ if (typeof value !== 'string') return undefined
198
+ const parts = value.split('/')
199
+ if (parts.length !== 2) return undefined
200
+ const num = Number(parts[0])
201
+ const den = Number(parts[1])
202
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return undefined
203
+ return Math.round((num / den) * 100) / 100
204
+ }
205
+
206
+ /** Parse bit_rate string (bps) → kbps. */
207
+ private parseBitrateKbps(value: unknown): number | undefined {
208
+ const n = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : NaN
209
+ if (!Number.isFinite(n) || n <= 0) return undefined
210
+ return Math.round(n / 1000)
211
+ }
212
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * TopologyEmitterService — push the cluster topology over the event bus.
3
+ *
4
+ * Subscribes to agent connect/disconnect + addon lifecycle events,
5
+ * recomputes the topology snapshot on each change (debounced), and
6
+ * emits `cluster.topology-snapshot`. Also fires a periodic safety-net
7
+ * snapshot every TOPOLOGY_HEARTBEAT_MS so consumers that boot mid-
8
+ * stream get a fresh payload without waiting for the next lifecycle
9
+ * change.
10
+ *
11
+ * Replaces UI polling on `nodes.topology`. Admin-ui dashboards
12
+ * subscribe to the snapshot category and read state directly from the
13
+ * payload — zero round-trip.
14
+ */
15
+ import { randomUUID } from 'node:crypto'
16
+ import { EventCategory } from '@camstack/types'
17
+
18
+ type Unsubscribe = () => void
19
+ import type { EventBusService } from '../events/event-bus.service'
20
+ import type { AgentRegistryService } from '../agent/agent-registry.service'
21
+ import type { AddonRegistryService } from '../addon/addon-registry.service'
22
+ import { computeTopology } from '../../api/core/cap-providers'
23
+
24
+ const DEBOUNCE_MS = 200
25
+ const HEARTBEAT_MS = 30_000
26
+
27
+ const LIFECYCLE_CATEGORIES: readonly EventCategory[] = [
28
+ EventCategory.AgentOnline,
29
+ EventCategory.AgentOffline,
30
+ EventCategory.WorkerOnline,
31
+ EventCategory.WorkerOffline,
32
+ EventCategory.AgentUnregistered,
33
+ EventCategory.AddonStarted,
34
+ EventCategory.AddonStopped,
35
+ EventCategory.AddonRestarted,
36
+ EventCategory.AddonInstalled,
37
+ EventCategory.AddonUninstalled,
38
+ EventCategory.AddonUpdated,
39
+ EventCategory.AddonCrashed,
40
+ ]
41
+
42
+ export class TopologyEmitterService {
43
+ private readonly unsubscribers: Unsubscribe[] = []
44
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null
45
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
46
+ private emitting = false
47
+
48
+ constructor(
49
+ private readonly eventBus: EventBusService,
50
+ private readonly agentRegistry: AgentRegistryService,
51
+ private readonly addonRegistry: AddonRegistryService,
52
+ ) {}
53
+
54
+ onModuleInit(): void {
55
+ for (const category of LIFECYCLE_CATEGORIES) {
56
+ const unsub = this.eventBus.subscribe({ category }, () => this.scheduleEmit())
57
+ this.unsubscribers.push(unsub)
58
+ }
59
+ // Heartbeat fires the first emit ~immediately so consumers that
60
+ // mount before any lifecycle change still see a snapshot.
61
+ void this.emitNow()
62
+ this.heartbeatTimer = setInterval(() => this.scheduleEmit(), HEARTBEAT_MS)
63
+ }
64
+
65
+ onModuleDestroy(): void {
66
+ if (this.debounceTimer) clearTimeout(this.debounceTimer)
67
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
68
+ for (const unsub of this.unsubscribers) {
69
+ try { unsub() } catch { /* idempotent */ }
70
+ }
71
+ this.unsubscribers.length = 0
72
+ }
73
+
74
+ private scheduleEmit(): void {
75
+ if (this.debounceTimer) clearTimeout(this.debounceTimer)
76
+ this.debounceTimer = setTimeout(() => {
77
+ this.debounceTimer = null
78
+ void this.emitNow()
79
+ }, DEBOUNCE_MS)
80
+ }
81
+
82
+ private async emitNow(): Promise<void> {
83
+ if (this.emitting) return
84
+ this.emitting = true
85
+ try {
86
+ const nodes = await computeTopology(this.agentRegistry, this.addonRegistry)
87
+ this.eventBus.emit({
88
+ id: randomUUID(),
89
+ timestamp: new Date(),
90
+ source: { type: 'core', id: 'topology-emitter' },
91
+ category: EventCategory.ClusterTopologySnapshot,
92
+ data: { nodes, timestamp: Date.now() },
93
+ })
94
+ } catch {
95
+ // Best-effort observability path. Next lifecycle event or the
96
+ // heartbeat will retry — no need to log here.
97
+ } finally {
98
+ this.emitting = false
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Launcher -- entry point that ensures required addons are installed
3
+ * before main.ts loads. This solves the chicken-and-egg problem:
4
+ * main.ts has static imports from @camstack/core, but core lives in
5
+ * data/addons/ and may not exist on first boot.
6
+ *
7
+ * 1. Run AddonInstaller.ensureRequiredPackages (zero core dependencies)
8
+ * 2. Create symlinks so @camstack/core resolves from data/addons/
9
+ * 3. Dynamically import main.ts (which has static @camstack/core imports)
10
+ */
11
+ import * as fs from 'node:fs'
12
+ import * as path from 'node:path'
13
+ import * as tar from 'tar'
14
+ import * as yaml from 'js-yaml'
15
+ import { AddonInstaller, bootstrapSchema, detectWorkspacePackagesDir } from '@camstack/kernel'
16
+
17
+ /** Path of the manifest file embedded inside every archive. */
18
+ const ARCHIVE_MANIFEST_NAME = '.camstack-backup-manifest.json'
19
+
20
+ /** Resolve the data directory from env or default */
21
+ const DATA_DIR = process.env['CAMSTACK_DATA'] ?? 'camstack-data'
22
+
23
+ const RESTORE_MARKER_FILE = '.pending-restore.json'
24
+
25
+ /**
26
+ * Apply a pending system-restore archive over `dataDir` if a marker is
27
+ * present. Runs BEFORE the addon installer so the restored snapshot's
28
+ * `addons/` folder is what `ensureRequiredPackages` picks up.
29
+ *
30
+ * Marker contract is owned by `SystemBackupService.scheduleRestoreMarker`
31
+ * (packages/core/src/builtins/system-backup) — kept inline here so the
32
+ * launcher remains zero-core-deps. Schema:
33
+ * { archivePath: string, requestedAt: number, source: string }
34
+ *
35
+ * Failure modes:
36
+ * - marker malformed → log + leave it in place (operator visibility)
37
+ * - archive missing → log + clear marker (auto-recover from stale)
38
+ * - tar fails → throw → launcher exits non-zero, marker preserved
39
+ */
40
+ async function applyPendingRestore(dataDir: string): Promise<void> {
41
+ const markerPath = path.join(dataDir, RESTORE_MARKER_FILE)
42
+ if (!fs.existsSync(markerPath)) return
43
+
44
+ console.log(`[launcher] Pending restore marker found at ${markerPath}`)
45
+ let archivePath: string | null = null
46
+ let locations: string[] | null = null
47
+ try {
48
+ const raw = fs.readFileSync(markerPath, 'utf-8')
49
+ const parsed: unknown = JSON.parse(raw)
50
+ if (
51
+ typeof parsed === 'object' &&
52
+ parsed != null &&
53
+ typeof (parsed as { archivePath?: unknown }).archivePath === 'string'
54
+ ) {
55
+ archivePath = (parsed as { archivePath: string }).archivePath
56
+ }
57
+ const locField = (parsed as { locations?: unknown }).locations
58
+ if (Array.isArray(locField) && locField.every((l) => typeof l === 'string')) {
59
+ locations = locField as string[]
60
+ }
61
+ } catch (err) {
62
+ console.error('[launcher] Restore marker malformed — leaving in place:', err)
63
+ return
64
+ }
65
+
66
+ if (archivePath == null) {
67
+ console.error('[launcher] Restore marker missing archivePath — clearing')
68
+ fs.rmSync(markerPath, { force: true })
69
+ return
70
+ }
71
+
72
+ if (!fs.existsSync(archivePath)) {
73
+ console.error(`[launcher] Restore archive ${archivePath} missing — clearing marker`)
74
+ fs.rmSync(markerPath, { force: true })
75
+ return
76
+ }
77
+
78
+ const scope = locations ? ` (only: ${locations.join(', ')})` : ''
79
+ console.log(`[launcher] Restoring snapshot from ${archivePath} into ${dataDir}${scope}`)
80
+ fs.mkdirSync(dataDir, { recursive: true })
81
+
82
+ // Same filter logic SystemBackupService.extractArchive uses — kept
83
+ // inline here so the launcher stays zero-core-deps. The shape is
84
+ // straightforward enough to duplicate.
85
+ const allowedPrefixes = locations
86
+ ? locations.map((loc) => loc.replace(/^\.\//, '').replace(/\/+$/, ''))
87
+ : null
88
+ await tar.extract({
89
+ file: archivePath,
90
+ cwd: dataDir,
91
+ filter: (p) => {
92
+ if (p === ARCHIVE_MANIFEST_NAME || p === `./${ARCHIVE_MANIFEST_NAME}`) return false
93
+ if (!allowedPrefixes) return true
94
+ const normalized = p.replace(/^\.\//, '')
95
+ return allowedPrefixes.some((prefix) =>
96
+ normalized === prefix || normalized.startsWith(`${prefix}/`),
97
+ )
98
+ },
99
+ })
100
+ fs.rmSync(markerPath, { force: true })
101
+ console.log('[launcher] Restore complete; marker cleared')
102
+ }
103
+
104
+ /** Narrow `CAMSTACK_INSTALL_SOURCE` env into the typed union. */
105
+ function parseInstallSource(value: string | undefined): 'npm' | 'local' | 'symlink' | undefined {
106
+ if (value === 'npm' || value === 'local' || value === 'symlink') return value
107
+ return undefined
108
+ }
109
+
110
+ /**
111
+ * Locate + parse `config.yaml` relative to the data dir. Returns the
112
+ * raw object on success, `null` when missing or unparseable. The
113
+ * launcher uses this BEFORE @camstack/core is imported so it must stay
114
+ * free of core deps — just js-yaml + the kernel's bootstrapSchema for
115
+ * shape validation by the call site.
116
+ */
117
+ function readConfigYaml(dataDir: string): unknown {
118
+ const candidates = [
119
+ path.resolve(dataDir, '..', 'config.yaml'),
120
+ path.resolve(dataDir, 'config.yaml'),
121
+ path.resolve(process.cwd(), 'config.yaml'),
122
+ ]
123
+ const found = candidates.find((p) => fs.existsSync(p))
124
+ if (!found) return null
125
+ try {
126
+ return yaml.load(fs.readFileSync(found, 'utf-8')) ?? {}
127
+ } catch (err) {
128
+ console.warn(`[launcher] Could not parse ${found}: ${err instanceof Error ? err.message : String(err)}`)
129
+ return null
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Read `bootstrap.installSource` from `<dataDir>/config.yaml` if present.
135
+ * Returns the configured value or `null` when absent.
136
+ *
137
+ * Production deployments should leave this unset (defaults to 'npm');
138
+ * `symlink` is a dev-time convenience that points data/addons/<pkg>
139
+ * directly at the workspace source dir — never set in shipped
140
+ * containers or Electron bundles.
141
+ */
142
+ function readBootstrapInstallSource(dataDir: string): 'npm' | 'local' | 'symlink' | undefined {
143
+ const raw = readConfigYaml(dataDir)
144
+ if (raw === null) return undefined
145
+ const validation = bootstrapSchema.safeParse(raw)
146
+ if (!validation.success) return undefined
147
+ return parseInstallSource(validation.data.bootstrap.installSource)
148
+ }
149
+
150
+ /**
151
+ * Read `bootstrap.requiredAddons` from `<dataDir>/config.yaml` if present.
152
+ * Returns the parsed array, an empty array when explicitly set to `[]`,
153
+ * or `null` when the field is absent (caller should fall back to the
154
+ * kernel's REQUIRED_PACKAGES constant).
155
+ *
156
+ * Inline YAML parse rather than going through ConfigManager: the
157
+ * launcher runs before @camstack/core is imported and must stay free of
158
+ * core dependencies. Just yaml.load + bootstrapSchema (kernel) for
159
+ * shape validation.
160
+ */
161
+ function readBootstrapRequiredAddons(dataDir: string): readonly string[] | null {
162
+ const raw = readConfigYaml(dataDir)
163
+ if (raw === null) return null
164
+
165
+ const validation = bootstrapSchema.safeParse(raw)
166
+ if (!validation.success) {
167
+ console.warn(`[launcher] config.yaml failed bootstrapSchema validation: ${validation.error.message}`)
168
+ return null
169
+ }
170
+
171
+ return validation.data.bootstrap.requiredAddons ?? null
172
+ }
173
+
174
+ async function launch(): Promise<void> {
175
+ const dataDir = DATA_DIR
176
+ const addonsDir = path.resolve(dataDir, 'addons')
177
+
178
+ // Restore must happen first so the snapshot's `addons/` is what the
179
+ // installer + symlink step pick up — otherwise we'd install a stale
180
+ // set and overwrite it a step later.
181
+ await applyPendingRestore(dataDir)
182
+
183
+ // Install source resolution:
184
+ // 1. CAMSTACK_BUNDLED_ADDONS_DIR — set by Electron-packaged builds
185
+ // to <resourcesPath>/addons. Pre-built addons ship with the
186
+ // bundle, copied at first launch (`'local'` mode).
187
+ // 2. CAMSTACK_INSTALL_SOURCE=local — explicit opt-in to copy from
188
+ // the monorepo workspace (used by e2e + dev bootstrap). Hub
189
+ // auto-detects `packages/` via detectWorkspacePackagesDir.
190
+ // 3. Otherwise: 'npm' from the registry. Local development pushes
191
+ // via `camstack deploy` (CLI tarball upload), bypassing this
192
+ // bootstrap entirely.
193
+ console.log('[launcher] Checking required packages...')
194
+ // Install source resolution:
195
+ // CAMSTACK_INSTALL_SOURCE env: 'npm' | 'local' | 'symlink' | undefined.
196
+ // CAMSTACK_BUNDLED_ADDONS_DIR: Electron path, forces 'local'.
197
+ // bootstrap.installSource (config.yaml): same set as env. Env wins.
198
+ const rawEnvSource = process.env['CAMSTACK_INSTALL_SOURCE']
199
+ const yamlSource = readBootstrapInstallSource(dataDir)
200
+ const explicitSource = parseInstallSource(rawEnvSource) ?? yamlSource
201
+ const bundledDir = process.env['CAMSTACK_BUNDLED_ADDONS_DIR']
202
+ let workspaceDir: string | null = null
203
+ let resolvedSource: 'npm' | 'local' | 'symlink' | undefined = explicitSource
204
+ if (bundledDir && fs.existsSync(bundledDir)) {
205
+ workspaceDir = bundledDir
206
+ resolvedSource = 'local'
207
+ console.log(`[launcher] Using bundled addons from ${bundledDir}`)
208
+ } else if (explicitSource === 'local' || explicitSource === 'symlink') {
209
+ workspaceDir = detectWorkspacePackagesDir(__dirname)
210
+ if (workspaceDir === null) {
211
+ console.warn(
212
+ `[launcher] installSource=${explicitSource} requested but no workspace `
213
+ + `packages/ dir found from ${__dirname} — falling back to 'npm'`,
214
+ )
215
+ resolvedSource = 'npm'
216
+ }
217
+ }
218
+ const installer = new AddonInstaller({
219
+ addonsDir,
220
+ workspacePackagesDir: workspaceDir ?? undefined,
221
+ installSource: resolvedSource,
222
+ })
223
+
224
+ // bootstrap.requiredAddons from config.yaml — if set, REPLACES the
225
+ // kernel's hard-coded REQUIRED_PACKAGES. The kernel never reaches into
226
+ // SQL before this list is installed; everything here must be a known
227
+ // package name that can be resolved from the workspace, the bundle, or
228
+ // npm. Default (undefined) → fall back to the in-kernel constant.
229
+ const bootstrapRequired = readBootstrapRequiredAddons(dataDir)
230
+ if (bootstrapRequired !== null) {
231
+ console.log(`[launcher] bootstrap.requiredAddons from config.yaml: ${bootstrapRequired.length} packages`)
232
+ await installer.ensureRequiredPackages(bootstrapRequired)
233
+ } else {
234
+ await installer.ensureRequiredPackages()
235
+ }
236
+
237
+ // Self-contained addon bundles (build preset `self-contained`) inline
238
+ // @camstack/types + zod + @camstack/sdk into each addon's dist. The
239
+ // hub no longer plants peer-dep symlinks under
240
+ // addonsDir/node_modules/@camstack/ — addons resolve everything from
241
+ // their own bundle. Kept the serverDir derivation in case future
242
+ // tooling needs it (NODE_PATH extension below still uses it).
243
+ const serverDir = path.resolve(__dirname, '..')
244
+ const nodeModulesDir = path.join(serverDir, 'node_modules')
245
+
246
+ // Extend Node's module resolution so addons in `data/addons/`
247
+ // (which sit outside the server tree on packaged builds — e.g.
248
+ // `~/Library/Application Support/.../data/addons/`) can resolve
249
+ // third-party peer deps from the hub's own `node_modules`.
250
+ //
251
+ // Why: an addon's runtime file `data/addons/@scope/<x>/dist/x.js`
252
+ // walks UP looking for `node_modules/<pkg>`. With user-data
253
+ // outside the workspace, the walk dead-ends well before hitting
254
+ // the hub's tree. NODE_PATH instructs Node to ALSO check the
255
+ // listed dirs after the regular walk fails.
256
+ //
257
+ // The workspace-root node_modules holds hoisted deps (zod, werift,
258
+ // etc); the server-local node_modules has direct deps. Listing
259
+ // both covers every realistic resolution.
260
+ const workspaceRootNodeModules = path.resolve(serverDir, '..', '..', 'node_modules')
261
+ const extraNodePaths = [nodeModulesDir, workspaceRootNodeModules]
262
+ .filter((p) => fs.existsSync(p))
263
+ if (extraNodePaths.length > 0) {
264
+ const sep = process.platform === 'win32' ? ';' : ':'
265
+ process.env['NODE_PATH'] = process.env['NODE_PATH']
266
+ ? `${process.env['NODE_PATH']}${sep}${extraNodePaths.join(sep)}`
267
+ : extraNodePaths.join(sep)
268
+ // Re-init Node's module path cache so the change takes effect for
269
+ // subsequent require()/import() calls. Without this the resolver
270
+ // already cached the empty list at startup.
271
+ const Module = require('node:module') as { _initPaths: () => void }
272
+ Module._initPaths()
273
+ console.log(`[launcher] NODE_PATH extended with: ${extraNodePaths.join(sep)}`)
274
+ }
275
+
276
+ // Now safe to load main.ts (which has static imports from @camstack/core)
277
+ console.log('[launcher] Starting server...')
278
+ await import('./main')
279
+ }
280
+
281
+ // Signal handlers — forward to default exit so NestJS shutdown hooks
282
+ // (Moleculer broker stopped() → kills spawned workers) run cleanly.
283
+ // Without these, TTY close / `npm run serve` kill leaves workers orphaned.
284
+ let shuttingDown = false
285
+ const handleSignal = (signal: NodeJS.Signals) => {
286
+ if (shuttingDown) return
287
+ shuttingDown = true
288
+ console.log(`[launcher] Received ${signal} — shutting down`)
289
+ process.exit(0)
290
+ }
291
+ process.on('SIGHUP', handleSignal)
292
+ process.on('SIGTERM', handleSignal)
293
+ process.on('SIGINT', handleSignal)
294
+
295
+ // Parent-death watchdog (macOS: no PR_SET_PDEATHSIG). If tsx watch / npm
296
+ // get orphaned to init, self-destruct so NestJS shutdown hooks fire before
297
+ // the process tree turns into a zombie.
298
+ const initialPpid = process.ppid
299
+ setInterval(() => {
300
+ if (process.ppid === 1 && initialPpid !== 1) {
301
+ console.error('[launcher] Parent died (reparented to init) — exiting')
302
+ process.exit(0)
303
+ }
304
+ }, 2000).unref()
305
+
306
+ launch().catch((err) => {
307
+ console.error('[launcher] FATAL:', err)
308
+ process.exit(1)
309
+ })