@camstack/server 0.2.2 → 1.0.1

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 (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. package/vitest.config.ts +0 -26
@@ -1,1787 +0,0 @@
1
- import * as fs from 'node:fs'
2
- import * as path from 'node:path'
3
- import * as os from 'node:os'
4
- import { execFile } from 'node:child_process'
5
- import { promisify } from 'node:util'
6
- import { randomUUID } from 'node:crypto'
7
- import { LoggingService } from '../logging/logging.service'
8
- import { ConfigService } from '../config/config.service'
9
- import { EventBusService } from '../events/event-bus.service'
10
- import { AddonRegistryService } from './addon-registry.service'
11
- import { NotificationServiceWrapper } from '../notification/notification-wrapper.service'
12
- import { ToastServiceWrapper } from '../notification/toast-wrapper.service'
13
- import type { IScopedLogger, PendingRestartMarkerPayload } from '@camstack/types'
14
- import type {
15
- InstalledPackage,
16
- PackageUpdate,
17
- UpdateResult,
18
- PackageVersionInfo,
19
- AutoUpdateChannel,
20
- } from '@camstack/types'
21
- import { EventCategory, errMsg } from '@camstack/types'
22
- import {
23
- AddonInstaller,
24
- detectWorkspacePackagesDir,
25
- ensureDir,
26
- scheduleSelfRestart,
27
- writePendingRestart,
28
- } from '@camstack/kernel'
29
-
30
- const execFileAsync = promisify(execFile)
31
-
32
- /**
33
- * Framework packages (manifest `camstack.system: true`) that the
34
- * `updateFrameworkPackage` cap method is allowed to update. Any other
35
- * `packageName` is rejected.
36
- *
37
- * Mirror of `camstack.system: true` in
38
- * packages/{types,kernel,core,sdk,ui-library,shm-ring}/package.json
39
- */
40
- export const FRAMEWORK_PACKAGE_ALLOWLIST: readonly string[] = [
41
- '@camstack/types',
42
- '@camstack/kernel',
43
- '@camstack/core',
44
- '@camstack/sdk',
45
- '@camstack/ui-library',
46
- // Phase 5 / D9 — the cross-platform shared-memory frame plane. A system
47
- // package (native N-API module + `FrameRing`) the stream-broker / decoder /
48
- // frame consumers all depend on; it can't be uninstalled.
49
- '@camstack/shm-ring',
50
- ]
51
-
52
- /** Test-only: exported for spec parity check against the manifest. */
53
- export function isFrameworkPackage(packageName: string): boolean {
54
- return FRAMEWORK_PACKAGE_ALLOWLIST.includes(packageName)
55
- }
56
-
57
- // ---------------------------------------------------------------------------
58
- // Typed JSON helpers
59
- //
60
- // JSON.parse returns `any`, which sprays `no-unsafe-*` violations all over
61
- // ESLint. These tiny wrappers keep the `any` contained to one place and
62
- // return the `unknown` that callers must then narrow structurally.
63
- // ---------------------------------------------------------------------------
64
-
65
- function parseJsonUnknown(text: string): unknown {
66
- // Isolate the `any` from JSON.parse to this one assignment.
67
- const parsed: unknown = JSON.parse(text)
68
- return parsed
69
- }
70
-
71
- function readJsonObject(filePath: string): Record<string, unknown> | null {
72
- try {
73
- const parsed = parseJsonUnknown(fs.readFileSync(filePath, 'utf-8'))
74
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
75
- return { ...parsed }
76
- }
77
- } catch {
78
- /* ignore */
79
- }
80
- return null
81
- }
82
-
83
- async function fetchJsonObject(response: Response): Promise<Record<string, unknown>> {
84
- const parsed: unknown = await response.json()
85
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
86
- return {}
87
- }
88
- return { ...parsed }
89
- }
90
-
91
- function asString(value: unknown, fallback = ''): string {
92
- return typeof value === 'string' ? value : fallback
93
- }
94
-
95
- function asRecord(value: unknown): Record<string, unknown> {
96
- if (value && typeof value === 'object' && !Array.isArray(value)) {
97
- return { ...value }
98
- }
99
- return {}
100
- }
101
-
102
- // ---------------------------------------------------------------------------
103
- // Auto-update config types
104
- // ---------------------------------------------------------------------------
105
-
106
- interface AutoUpdateConfig {
107
- readonly global: { channel: 'off' | 'latest' | 'beta'; intervalSeconds: number }
108
- readonly overrides: Record<string, 'off' | 'latest' | 'beta' | 'inherit'>
109
- }
110
-
111
- type AddonLoader = import('@camstack/kernel').AddonLoader
112
-
113
- // ---------------------------------------------------------------------------
114
- // Npm search types
115
- // ---------------------------------------------------------------------------
116
-
117
- interface NpmSearchResult {
118
- readonly name: string
119
- readonly version: string
120
- readonly description: string
121
- readonly keywords: string[]
122
- readonly date: string
123
- readonly publisher: { readonly username: string }
124
- }
125
-
126
- interface AddonSearchResult {
127
- readonly name: string
128
- readonly version: string
129
- readonly description: string
130
- readonly keywords: readonly string[]
131
- readonly publishedAt: string
132
- readonly author: string
133
- readonly installed: boolean
134
- readonly installedVersion?: string
135
- }
136
-
137
- // ---------------------------------------------------------------------------
138
- // Cache types
139
- // ---------------------------------------------------------------------------
140
-
141
- interface CachedUpdates {
142
- readonly updates: readonly PackageUpdate[]
143
- readonly expiresAt: number
144
- }
145
-
146
- interface CachedSearch {
147
- readonly results: readonly NpmSearchResult[]
148
- readonly timestamp: number
149
- }
150
-
151
- interface CachedVersions {
152
- readonly versions: readonly PackageVersionInfo[]
153
- readonly expiresAt: number
154
- }
155
-
156
- // ---------------------------------------------------------------------------
157
- // Constants
158
- // ---------------------------------------------------------------------------
159
-
160
- /** Core packages that live in the server's own node_modules */
161
- const CORE_MANAGED_PACKAGES: readonly string[] = ['@camstack/core', '@camstack/types']
162
-
163
- export class AddonPackageService {
164
- private readonly logger: IScopedLogger
165
-
166
- /** AddonInstaller from @camstack/kernel (may be null if kernel unavailable) */
167
- private installer: AddonInstaller | null = null
168
-
169
- /** AddonLoader for reloadPackages (may be null) */
170
- private loader: AddonLoader | null = null
171
-
172
- // -- Caches ---------------------------------------------------------------
173
- private cachedUpdates: CachedUpdates | null = null
174
- private searchCache: CachedSearch | null = null
175
- private readonly versionCache = new Map<string, CachedVersions>()
176
-
177
- // -- Auto-update state ----------------------------------------------------
178
- private autoUpdateConfig: AutoUpdateConfig = {
179
- global: { channel: 'off', intervalSeconds: 21600 },
180
- overrides: {},
181
- }
182
- private autoUpdateTimer: ReturnType<typeof setInterval> | null = null
183
-
184
- // -- Timing constants -----------------------------------------------------
185
- // Short TTL — operators expect to see freshly-published versions
186
- // within minutes of `npm publish`, not hours. The Addons page kicks
187
- // a force-refresh on mount so the first navigation after publish
188
- // always picks up the new version regardless of TTL state.
189
- private static readonly UPDATE_CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes
190
- private static readonly SEARCH_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
191
- private static readonly VERSION_CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes
192
- private static readonly NPM_REGISTRY = 'https://registry.npmjs.org'
193
- private static readonly REGISTRY_TIMEOUT_MS = 10_000
194
-
195
- constructor(
196
- private readonly loggingService: LoggingService,
197
- private readonly eventBusService: EventBusService,
198
- private readonly configService: ConfigService,
199
- private readonly addonRegistry: AddonRegistryService,
200
- private readonly notificationService: NotificationServiceWrapper,
201
- private readonly toastService: ToastServiceWrapper,
202
- ) {
203
- this.logger = this.loggingService.createLogger('AddonPackageService')
204
-
205
- // Initialize installer eagerly (no async needed).
206
- // Ensures install/uninstall works before full module init completes.
207
- try {
208
- const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
209
- const addonsDir = path.resolve(dataDir, 'addons')
210
- const workspacePackagesDir = detectWorkspacePackagesDir(__dirname)
211
- // `CAMSTACK_NPM_REGISTRY` override exists primarily for the e2e
212
- // harness — it points the installer at a sandboxed verdaccio so
213
- // the CLI-vs-npm version-interaction tests don't hit the public
214
- // registry. In production this stays unset and the installer
215
- // falls back to `https://registry.npmjs.org` (the default in
216
- // AddonInstaller). Both the `npm install` shell-out AND the
217
- // metadata HTTPS fetch honour this value.
218
- const registry = process.env['CAMSTACK_NPM_REGISTRY']
219
- this.installer = new AddonInstaller({
220
- addonsDir,
221
- workspacePackagesDir: workspacePackagesDir ?? undefined,
222
- ...(registry ? { registry } : {}),
223
- })
224
- ensureDir(addonsDir)
225
- } catch (error: unknown) {
226
- const msg = errMsg(error)
227
- this.logger.warn('Installer init failed', { meta: { error: msg } })
228
- }
229
-
230
- // Load auto-update config from disk
231
- this.autoUpdateConfig = this.loadAutoUpdateConfig()
232
-
233
- // Start auto-update timer after a delay (give server time to boot)
234
- setTimeout(() => this.scheduleAutoUpdate(), 30_000)
235
- }
236
-
237
- // =========================================================================
238
- // Install / Uninstall
239
- // =========================================================================
240
-
241
- /** Install an addon from npm registry */
242
- async installFromNpm(name: string, version?: string): Promise<{ name: string; version: string }> {
243
- this.requireInstaller()
244
- this.logger.info(`installFromNpm: ${name}@${version ?? 'latest'}`, {
245
- meta: { addonsDir: this.installer!['addonsDir'] as string },
246
- })
247
- const result = await this.installer!.installFromNpm(name, version)
248
- this.logger.info('installFromNpm result', {
249
- meta: { name: result.name, version: result.version },
250
- })
251
- return result
252
- }
253
-
254
- /** Install an addon from the local workspace (dev mode) */
255
- async installFromWorkspace(name: string): Promise<{ name: string; version: string }> {
256
- this.requireInstaller()
257
- const result = await this.installer!.install(name)
258
- this.logger.info('Installed from workspace', {
259
- meta: { name: result.name, version: result.version },
260
- })
261
- return result
262
- }
263
-
264
- /** Install an addon from an uploaded tgz file */
265
- async installFromUpload(tgzPath: string): Promise<{ name: string; version: string }> {
266
- this.requireInstaller()
267
- const result = await this.installer!.installFromTgz(tgzPath)
268
-
269
- // Mark install source as 'upload' in both legacy marker + central manifest
270
- const addonsDir = this.resolveAddonsDir()
271
- const targetDir = path.join(addonsDir, result.name)
272
- try {
273
- fs.writeFileSync(path.join(targetDir, '.install-source'), 'upload')
274
- } catch (err) {
275
- this.logger.debug('Non-fatal: failed to write .install-source marker', {
276
- meta: { error: errMsg(err) },
277
- })
278
- }
279
- this.installer!.manifest.upsert(result.name, {
280
- version: result.version,
281
- source: 'upload',
282
- })
283
-
284
- this.logger.info('Installed from upload', {
285
- meta: { name: result.name, version: result.version },
286
- })
287
- return result
288
- }
289
-
290
- /**
291
- * Roll an addon back to the version it had before the most recent
292
- * `updatePackage` call. The backup directory pointer lives in the
293
- * AddonInstaller manifest; this method restores it, refreshes the
294
- * registry, restarts the addon, and notifies the user via toast.
295
- *
296
- * Returns `{ rolledBackTo }` — null when there's no backup available
297
- * (the previous update either succeeded its health check or never
298
- * happened), the restored version string otherwise.
299
- */
300
- async rollbackPackage(name: string): Promise<{ rolledBackTo: string | null }> {
301
- this.requireInstaller()
302
- if (!this.isAllowedPackage(name)) {
303
- throw new Error(`Package "${name}" is not an allowed @camstack/* package`)
304
- }
305
-
306
- const previousVersion = this.getInstalledPackageVersion(name)
307
- const rolledBackTo = await this.installer!.rollbackAddon(name)
308
- if (rolledBackTo == null) {
309
- this.logger.info('No backup available to roll back', { meta: { name } })
310
- return { rolledBackTo: null }
311
- }
312
-
313
- // Refresh registry + emit a synthetic 'updated' lifecycle event
314
- // (the version went BACK, but downstream consumers care about the
315
- // version change itself, not the direction).
316
- this.addonRegistry.refreshPackageVersion(name, rolledBackTo)
317
- this.addonRegistry.emitUpdateEvent(name, previousVersion, rolledBackTo)
318
-
319
- const addonId = this.extractAddonId(name)
320
- if (addonId) {
321
- try {
322
- await this.addonRegistry.restartAddon(addonId)
323
- this.logger.info('Addon restarted after rollback', {
324
- tags: { addonId },
325
- meta: { rolledBackTo },
326
- })
327
- } catch (reloadError: unknown) {
328
- this.logger.warn('Restart failed after rollback', {
329
- tags: { addonId },
330
- meta: { error: errMsg(reloadError) },
331
- })
332
- }
333
- }
334
-
335
- // Clear update cache so the UI reflects the new state.
336
- this.cachedUpdates = null
337
- return { rolledBackTo }
338
- }
339
-
340
- /** Uninstall an addon (refuses to uninstall protected/required packages) */
341
- async uninstall(name: string): Promise<void> {
342
- this.requireInstaller()
343
-
344
- if (this.isProtected(name)) {
345
- throw new Error(`Cannot uninstall protected package "${name}"`)
346
- }
347
-
348
- await this.installer!.uninstall(name)
349
- this.logger.info('Uninstalled', { meta: { name } })
350
- }
351
-
352
- // =========================================================================
353
- // Full install orchestration (npm install → reload → load addons → toast)
354
- // =========================================================================
355
-
356
- /**
357
- * Full install-from-npm orchestration:
358
- * 1. npm install
359
- * 2. Reload packages
360
- * 3. Load new addons into registry
361
- * 4. Broadcast toast notification
362
- *
363
- * Returns loaded/failed addon ids.
364
- */
365
- async installAndLoad(
366
- packageName: string,
367
- version?: string,
368
- ): Promise<{ success: true; loaded: string[]; failed: string[] }> {
369
- const versionLabel = version ? `@${version}` : '@latest'
370
- this.logger.info('Installing package...', { meta: { packageName, version: versionLabel } })
371
-
372
- try {
373
- const installResult = await this.installFromNpm(packageName, version)
374
- this.logger.info('npm install complete', {
375
- meta: { name: installResult.name, version: installResult.version },
376
- })
377
- } catch (err) {
378
- const msg = errMsg(err)
379
- this.logger.error('npm install failed', { meta: { packageName, error: msg } })
380
- this.toastService.broadcast({
381
- title: 'Install Failed',
382
- message: `Failed to install ${packageName}: ${msg}`,
383
- severity: 'warning',
384
- })
385
- throw new Error(`Install failed: ${msg}`, { cause: err })
386
- }
387
-
388
- try {
389
- await this.reloadPackages()
390
- } catch (err) {
391
- const msg = errMsg(err)
392
- this.logger.error('reloadPackages failed', { meta: { error: msg } })
393
- }
394
-
395
- let loaded: string[] = []
396
- let failed: string[] = []
397
- try {
398
- const result = await this.addonRegistry.loadNewAddons()
399
- loaded = result.loaded
400
- failed = result.failed
401
- } catch (err) {
402
- const msg = errMsg(err)
403
- this.logger.error('loadNewAddons failed', { meta: { error: msg } })
404
- failed.push(packageName)
405
- }
406
-
407
- this.toastService.broadcast({
408
- title: failed.length ? 'Addon Installed (with warnings)' : 'Addon Installed',
409
- message: `${packageName} installed${loaded.length ? ` — addons: ${loaded.join(', ')}` : ''}${failed.length ? ` — failed: ${failed.join(', ')}` : ''}`,
410
- severity: failed.length ? 'warning' : 'info',
411
- })
412
- return { success: true, loaded, failed }
413
- }
414
-
415
- /**
416
- * Full install-from-workspace orchestration:
417
- * 1. Workspace install
418
- * 2. Reload packages
419
- * 3. Load new addons into registry
420
- * 4. Broadcast toast notification
421
- */
422
- async installFromWorkspaceAndLoad(
423
- packageName: string,
424
- ): Promise<{ success: true; loaded: string[]; failed: string[] }> {
425
- this.logger.info('Installing from workspace...', { meta: { packageName } })
426
-
427
- try {
428
- const result = await this.installFromWorkspace(packageName)
429
- this.logger.info('Workspace install complete', {
430
- meta: { name: result.name, version: result.version },
431
- })
432
- } catch (err) {
433
- const msg = errMsg(err)
434
- this.logger.error('Workspace install failed', { meta: { error: msg } })
435
- this.toastService.broadcast({ title: 'Install Failed', message: msg, severity: 'warning' })
436
- throw new Error(`Workspace install failed: ${msg}`, { cause: err })
437
- }
438
-
439
- try {
440
- await this.reloadPackages()
441
- } catch (err) {
442
- this.logger.warn('Non-fatal: failed to reload packages after workspace install', {
443
- meta: { error: errMsg(err) },
444
- })
445
- }
446
-
447
- let loaded: string[] = []
448
- let failed: string[] = []
449
- try {
450
- const result = await this.addonRegistry.loadNewAddons()
451
- loaded = result.loaded
452
- failed = result.failed
453
- } catch (err) {
454
- this.logger.warn('Failed to load new addons after install', { meta: { error: errMsg(err) } })
455
- failed.push(packageName)
456
- }
457
-
458
- this.toastService.broadcast({
459
- title: 'Addon Installed from Workspace',
460
- message: `${packageName} installed${loaded.length ? ` — addons: ${loaded.join(', ')}` : ''}`,
461
- severity: failed.length ? 'warning' : 'info',
462
- })
463
- return { success: true, loaded, failed }
464
- }
465
-
466
- /**
467
- * Full uninstall orchestration:
468
- * 1. Emit uninstall lifecycle event
469
- * 2. Uninstall package
470
- * 3. Reload packages
471
- * 4. Load addons (to update registry)
472
- * 5. Broadcast toast notification
473
- */
474
- async uninstallAndReload(packageName: string): Promise<{ success: true }> {
475
- if (this.isProtected(packageName)) {
476
- throw new Error(`Package ${packageName} is required and cannot be uninstalled`)
477
- }
478
-
479
- try {
480
- this.addonRegistry.emitUninstallEvent(packageName)
481
- this.logger.info('Uninstalling package...', { meta: { packageName } })
482
- await this.uninstall(packageName)
483
- this.logger.info('Uninstall complete', { meta: { packageName } })
484
- } catch (err) {
485
- const msg = errMsg(err)
486
- this.logger.error('Uninstall failed', { meta: { packageName, error: msg } })
487
- this.toastService.broadcast({ title: 'Uninstall Failed', message: msg, severity: 'warning' })
488
- throw new Error(`Uninstall failed: ${msg}`, { cause: err })
489
- }
490
-
491
- await this.reloadPackages().catch((err) => {
492
- this.logger.warn('Non-fatal: failed to reload packages after uninstall', {
493
- meta: { error: errMsg(err) },
494
- })
495
- })
496
- await this.addonRegistry.loadNewAddons().catch((err) => {
497
- this.logger.warn('Non-fatal: failed to load new addons after uninstall', {
498
- meta: { error: errMsg(err) },
499
- })
500
- })
501
-
502
- this.toastService.broadcast({
503
- title: 'Addon Uninstalled',
504
- message: `${packageName} has been removed`,
505
- severity: 'info',
506
- })
507
- return { success: true }
508
- }
509
-
510
- // =========================================================================
511
- // Workspace
512
- // =========================================================================
513
-
514
- /** Check if workspace packages directory is available (dev mode) */
515
- isWorkspaceAvailable(): boolean {
516
- return this.installer?.workspaceDir != null
517
- }
518
-
519
- /** List addon packages available in the workspace but not necessarily installed */
520
- listWorkspacePackages(): Array<{ name: string; version: string; installed: boolean }> {
521
- const workspaceDir = this.installer?.workspaceDir
522
- if (!workspaceDir) return []
523
-
524
- const results: Array<{ name: string; version: string; installed: boolean }> = []
525
- const installed = new Set(this.listInstalled().map((p) => p.name))
526
-
527
- try {
528
- for (const entry of fs.readdirSync(workspaceDir, { withFileTypes: true })) {
529
- if (!entry.isDirectory()) continue
530
- const pkgJsonPath = path.join(workspaceDir, entry.name, 'package.json')
531
- if (!fs.existsSync(pkgJsonPath)) continue
532
- const pkg = readJsonObject(pkgJsonPath)
533
- if (!pkg) {
534
- this.logger.debug('Skipping malformed package.json', { meta: { entryName: entry.name } })
535
- continue
536
- }
537
- const name = asString(pkg['name'])
538
- if (!name.startsWith('@camstack/')) continue
539
- const camstack = asRecord(pkg['camstack'])
540
- if (!Array.isArray(camstack['addons'])) continue
541
- results.push({
542
- name,
543
- version: asString(pkg['version'], '0.0.0'),
544
- installed: installed.has(name),
545
- })
546
- }
547
- } catch (err) {
548
- this.logger.warn('Failed to read workspace directory', { meta: { error: errMsg(err) } })
549
- }
550
-
551
- return results
552
- }
553
-
554
- // =========================================================================
555
- // Query
556
- // =========================================================================
557
-
558
- /** List all installed addon packages */
559
- listInstalled(): InstalledPackage[] {
560
- if (!this.installer) {
561
- throw new Error(
562
- 'AddonInstaller is not available — cannot list installed packages. Ensure @camstack/kernel is installed and the addons directory is accessible',
563
- )
564
- }
565
- return this.installer.listInstalled()
566
- }
567
-
568
- /**
569
- * Check whether a package is currently protected from uninstall.
570
- *
571
- * Source of truth: the running `AddonRegistry`'s loaded manifests —
572
- * any addon in this package with `camstack.addons[N].protected: true`
573
- * marks the whole package non-removable. This replaces the legacy
574
- * `AddonInstaller.REQUIRED_PACKAGES` hardcoded list.
575
- *
576
- * Fallback: if the registry hasn't loaded yet (early-boot install
577
- * paths, tests with stub registries), fall back to the legacy list
578
- * so we don't accidentally allow uninstalling `@camstack/core` etc.
579
- * during a window where protection metadata isn't yet readable.
580
- */
581
- isProtected(name: string): boolean {
582
- const fromRegistry = this.addonRegistry?.isPackageProtected?.(name)
583
- if (typeof fromRegistry === 'boolean') return fromRegistry
584
- return AddonInstaller.REQUIRED_PACKAGES.includes(name)
585
- }
586
-
587
- /**
588
- * Set of package names with a pending pre-update backup on disk —
589
- * i.e. packages where `applyUpdate` last ran and the post-update
590
- * health-check hasn't cleared the backup pointer yet, OR the user
591
- * explicitly hasn't called `clearBackup`.
592
- *
593
- * The Rollback button in admin-ui's AddonCard reads this via the
594
- * cap-router's `hasBackup` flag on `addons.list`.
595
- */
596
- getRollbackablePackages(): ReadonlySet<string> {
597
- if (!this.installer) return new Set()
598
- const out = new Set<string>()
599
- for (const entry of this.installer.manifest.list()) {
600
- if (entry.lastBackupDir) out.add(entry.name)
601
- }
602
- return out
603
- }
604
-
605
- // =========================================================================
606
- // Update checks
607
- // =========================================================================
608
-
609
- /**
610
- * Check npm registry for newer versions of all managed @camstack/* packages.
611
- * Includes both core packages (server root) and addon packages (data/addons).
612
- * Results are cached for 6 hours.
613
- */
614
- async checkUpdates(force?: boolean): Promise<readonly PackageUpdate[]> {
615
- if (force) {
616
- this.cachedUpdates = null
617
- }
618
-
619
- const now = Date.now()
620
- if (this.cachedUpdates && this.cachedUpdates.expiresAt > now) {
621
- // this.logger.debug('Returning cached update check results')
622
- return this.cachedUpdates.updates
623
- }
624
-
625
- this.logger.info('Checking for package updates...')
626
- const updates: PackageUpdate[] = []
627
-
628
- // Check installed addon packages in addons dir
629
- const addonUpdates = await this.checkAddonPackageUpdates()
630
- updates.push(...addonUpdates)
631
-
632
- this.logger.info('Found package updates', {
633
- meta: {
634
- count: updates.length,
635
- updates: updates.map((u) => ({
636
- name: u.name,
637
- currentVersion: u.currentVersion,
638
- latestVersion: u.latestVersion,
639
- })),
640
- },
641
- })
642
-
643
- this.cachedUpdates = {
644
- updates,
645
- expiresAt: now + AddonPackageService.UPDATE_CACHE_TTL_MS,
646
- }
647
- return updates
648
- }
649
-
650
- /** Clear the cached update check results */
651
- clearUpdateCache(): void {
652
- this.cachedUpdates = null
653
- this.logger.info('Update cache cleared')
654
- }
655
-
656
- /**
657
- * Diff an explicit list of installed packages against npm — the
658
- * agent-targeted counterpart of `checkUpdates`. The hub owns the npm
659
- * machinery; an agent only reports what it has installed (via
660
- * `$agent.status`), so the hub does the registry lookups + diff here.
661
- *
662
- * Not cached: the caller decides freshness (an agent roster changes
663
- * per deploy, and `forceRefresh` must always be live).
664
- */
665
- async checkUpdatesForInstalled(
666
- installed: readonly { name: string; version: string }[],
667
- ): Promise<readonly PackageUpdate[]> {
668
- // De-dup by package name — one npm package may bundle several addons.
669
- const seen = new Map<string, string>()
670
- for (const pkg of installed) {
671
- if (pkg.name.length > 0 && !seen.has(pkg.name)) seen.set(pkg.name, pkg.version)
672
- }
673
- const updates: PackageUpdate[] = []
674
- await Promise.all(
675
- [...seen].map(async ([name, version]) => {
676
- if (!this.isAllowedPackage(name)) return
677
- const latestVersion = await this.fetchLatestVersion(name)
678
- if (latestVersion === null || latestVersion === version) return
679
- const category = this.categorize(name)
680
- updates.push({
681
- name,
682
- currentVersion: version,
683
- latestVersion,
684
- category,
685
- requiresRestart: category === 'core',
686
- })
687
- }),
688
- )
689
- return updates
690
- }
691
-
692
- /**
693
- * Resolve `name@version` and `npm pack` it into a tarball buffer
694
- * WITHOUT installing. Used to push a package update to an agent: the
695
- * hub packs here, then ships the tgz via `$agent.deploy` — agents
696
- * need no npm runtime of their own.
697
- */
698
- async packPackage(
699
- name: string,
700
- version?: string,
701
- ): Promise<{ buffer: Buffer; version: string; filename: string }> {
702
- const registry = process.env['CAMSTACK_NPM_REGISTRY']
703
- const resolvedVersion = await resolveNpmVersion(name, version ?? 'latest', registry)
704
- const spec = `${name}@${resolvedVersion}`
705
- const destDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-pack-'))
706
- try {
707
- const args = ['pack', spec, '--pack-destination', destDir, ...buildNpmRegistryArgs(registry)]
708
- await execFileAsync('npm', args, { timeout: 120_000 })
709
- const onDisk = fs.readdirSync(destDir).find((f) => f.endsWith('.tgz'))
710
- if (onDisk === undefined) {
711
- throw new Error(`packPackage: npm pack produced no tarball for ${spec}`)
712
- }
713
- const buffer = fs.readFileSync(path.join(destDir, onDisk))
714
- return { buffer, version: resolvedVersion, filename: onDisk }
715
- } finally {
716
- fs.rmSync(destDir, { recursive: true, force: true })
717
- }
718
- }
719
-
720
- // =========================================================================
721
- // npm search
722
- // =========================================================================
723
-
724
- /** Search npm for camstack addon packages, optionally filtered by query */
725
- async searchNpm(query?: string): Promise<AddonSearchResult[]> {
726
- const npmResults = await this.fetchSearchFromNpm()
727
-
728
- let filtered: readonly NpmSearchResult[] = npmResults
729
- if (query) {
730
- const q = query.toLowerCase()
731
- filtered = npmResults.filter(
732
- (r) =>
733
- r.name.toLowerCase().includes(q) ||
734
- r.description?.toLowerCase().includes(q) ||
735
- r.keywords?.some((k) => k.toLowerCase().includes(q)),
736
- )
737
- }
738
-
739
- return filtered.map((r) => ({
740
- name: r.name,
741
- version: r.version,
742
- description: r.description ?? '',
743
- keywords: r.keywords ?? [],
744
- publishedAt: r.date ?? '',
745
- author: r.publisher?.username ?? '',
746
- installed: false, // caller will enrich
747
- installedVersion: undefined,
748
- }))
749
- }
750
-
751
- // =========================================================================
752
- // Package versions
753
- // =========================================================================
754
-
755
- /**
756
- * Fetch all published versions of a package from npm, including dist-tags.
757
- * Results are cached for 10 minutes per package.
758
- */
759
- async getPackageVersions(name: string): Promise<readonly PackageVersionInfo[]> {
760
- const now = Date.now()
761
- const cached = this.versionCache.get(name)
762
- if (cached && cached.expiresAt > now) {
763
- return cached.versions
764
- }
765
-
766
- try {
767
- const encodedName = name.replace('/', '%2F')
768
- const url = `${AddonPackageService.NPM_REGISTRY}/${encodedName}`
769
- const response = await fetch(url, {
770
- signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
771
- })
772
-
773
- if (!response.ok) {
774
- this.logger.debug('Registry returned non-ok status', {
775
- meta: { name, status: response.status },
776
- })
777
- return []
778
- }
779
-
780
- const data = await fetchJsonObject(response)
781
- const distTags = asRecord(data['dist-tags'])
782
- const versions = asRecord(data['versions'])
783
- const time = asRecord(data['time'])
784
-
785
- // Build a reverse lookup: version -> list of dist-tag names
786
- const tagsByVersion = new Map<string, string[]>()
787
- for (const [tag, ver] of Object.entries(distTags)) {
788
- const verStr = asString(ver)
789
- if (!verStr) continue
790
- const existing = tagsByVersion.get(verStr) ?? []
791
- tagsByVersion.set(verStr, [...existing, tag])
792
- }
793
-
794
- const result: PackageVersionInfo[] = Object.entries(versions).map(([ver, rawMeta]) => {
795
- const meta = asRecord(rawMeta)
796
- return {
797
- version: ver,
798
- publishedAt: asString(time[ver]),
799
- deprecated: typeof meta['deprecated'] === 'string' ? meta['deprecated'] : undefined,
800
- distTags: tagsByVersion.get(ver) ?? [],
801
- }
802
- })
803
-
804
- // Sort by published date descending (newest first)
805
- result.sort((a, b) => {
806
- if (!a.publishedAt && !b.publishedAt) return 0
807
- if (!a.publishedAt) return 1
808
- if (!b.publishedAt) return -1
809
- return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
810
- })
811
-
812
- this.versionCache.set(name, {
813
- versions: result,
814
- expiresAt: now + AddonPackageService.VERSION_CACHE_TTL_MS,
815
- })
816
-
817
- return result
818
- } catch (error: unknown) {
819
- const msg = errMsg(error)
820
- this.logger.warn('Failed to fetch versions', { meta: { name, error: msg } })
821
- return cached?.versions ?? []
822
- }
823
- }
824
-
825
- // =========================================================================
826
- // Update / restart
827
- // =========================================================================
828
-
829
- /**
830
- * Update a specific package. For addon packages, triggers hot-reload.
831
- * For core packages, installs in the server root and returns requiresRestart: true.
832
- */
833
- async updatePackage(name: string, version?: string): Promise<UpdateResult> {
834
- if (!this.isAllowedPackage(name)) {
835
- return {
836
- success: false,
837
- version: '',
838
- requiresRestart: false,
839
- error: `Package "${name}" is not an allowed @camstack/* package`,
840
- }
841
- }
842
-
843
- // Dev-mode npm-install gate REMOVED (2026-05-12). The legacy
844
- // workspace-link flow assumed addons in dev came from `packages/*`
845
- // and a stray "Update" click would clobber the source. With the
846
- // CLI's `camstack push` flow taking over that responsibility, the
847
- // hub now treats every install identically — straight from npm
848
- // (or from the operator's deliberate push). No more env var
849
- // gymnastics required to update an addon from the UI.
850
-
851
- const category = this.categorize(name)
852
- this.logger.info('Updating package', { meta: { name, version, category } })
853
-
854
- try {
855
- let updatedVersion: string
856
-
857
- if (category === 'addon') {
858
- // Reuse the long-lived installer the registry already configured
859
- // (correct addonsDir + installSource + workspaceDir) — building a
860
- // fresh one with `new AI({ addonsDir })` would lose installSource
861
- // detection and skip the central manifest write.
862
- this.requireInstaller()
863
- const addonInstaller = this.installer!
864
- const previousVersion = this.getInstalledPackageVersion(name)
865
- // getInstalledPackageVersion returns '' when not installed
866
- const isFirstInstall = !previousVersion
867
-
868
- // First-time installs go through the original installFromNpm path;
869
- // applyUpdate explicitly refuses when the package isn't tracked yet.
870
- // Subsequent updates take the safe path: backup current install,
871
- // run the install, auto-restore on failure (PR2).
872
- let result: { version: string; backupDir?: string }
873
- if (isFirstInstall) {
874
- const r = await addonInstaller.installFromNpm(name, version)
875
- result = { version: r.version }
876
- } else {
877
- const r = await addonInstaller.applyUpdate(name, version)
878
- result = { version: r.version, backupDir: r.backupDir }
879
- }
880
- updatedVersion = result.version
881
-
882
- // Update version in registry so UI reflects the change immediately
883
- this.addonRegistry.refreshPackageVersion(name, updatedVersion)
884
-
885
- // Emit addon.updated lifecycle event
886
- this.addonRegistry.emitUpdateEvent(name, previousVersion, updatedVersion)
887
-
888
- this.logger.info('Addon package updated, triggering hot-reload', {
889
- meta: { name, updatedVersion },
890
- })
891
- const addonId = this.extractAddonId(name)
892
- if (addonId) {
893
- try {
894
- await this.addonRegistry.restartAddon(addonId)
895
- this.logger.info('Addon restarted after update', { tags: { addonId } })
896
- // Restart succeeded — drop the backup. We keep it on failure
897
- // (caller can roll back manually via UI).
898
- if (result.backupDir != null) {
899
- addonInstaller.clearBackup(name)
900
- }
901
- } catch (reloadError: unknown) {
902
- const msg = errMsg(reloadError)
903
- this.logger.warn('Hot-reload failed for addon — backup retained for rollback', {
904
- tags: { addonId },
905
- meta: { error: msg, backupDir: result.backupDir },
906
- })
907
- }
908
- }
909
-
910
- // Clear update cache so next check reflects new state
911
- this.cachedUpdates = null
912
-
913
- this.sendUpdateNotification(name, updatedVersion)
914
- return { success: true, version: updatedVersion, requiresRestart: false }
915
- }
916
-
917
- // Core package -- install to data/addons/. Self-contained addon
918
- // bundles no longer require the reverse symlink under
919
- // hub/node_modules/@camstack/ — addons resolve everything from
920
- // their own inlined deps.
921
- const addonsDir = this.resolveAddonsDir()
922
- const { installPackageFromNpmSync } = await import('@camstack/kernel')
923
- const dirName = name.replace(/^@camstack\//, '')
924
- const targetDir = path.join(addonsDir, dirName)
925
- const packageSpec = version ? `${name}@${version}` : name
926
- installPackageFromNpmSync(packageSpec, targetDir)
927
-
928
- updatedVersion = this.getInstalledPackageVersion(name)
929
-
930
- // Invalidate cache after update
931
- this.cachedUpdates = null
932
-
933
- this.logger.info('Core package updated -- restart required', {
934
- meta: { name, updatedVersion },
935
- })
936
- this.sendUpdateNotification(name, updatedVersion)
937
- return { success: true, version: updatedVersion, requiresRestart: true }
938
- } catch (error: unknown) {
939
- const msg = errMsg(error)
940
- this.logger.error('Failed to update package', { meta: { name, error: msg } })
941
- return { success: false, version: '', requiresRestart: false, error: msg }
942
- }
943
- }
944
-
945
- /**
946
- * Gracefully restart the server process.
947
- *
948
- * Writes a `manual` restart marker so the post-boot service can
949
- * emit `system.restart-completed` on the next boot (powers the
950
- * admin-UI "Server restarted" toast). Emits `system.restarting`
951
- * with the same payload so subscribers can show a reconnect overlay
952
- * immediately. Then delegates to `scheduleSelfRestart` which picks
953
- * the right exit path (Electron `app.relaunch` vs supervisor-driven
954
- * `process.exit`).
955
- */
956
- restartServer(requestedBy?: string): void {
957
- this.logger.info('Server restart requested -- initiating graceful shutdown')
958
-
959
- const payload: PendingRestartMarkerPayload = {
960
- kind: 'manual',
961
- requestedAt: Date.now(),
962
- ...(requestedBy !== undefined ? { requestedBy } : {}),
963
- }
964
-
965
- try {
966
- writePendingRestart(this.resolveDataDir(), payload)
967
- } catch (err) {
968
- // Marker write failure shouldn't block the restart — log + continue.
969
- this.logger.warn(
970
- 'Failed to write restart marker; restart will proceed without completion toast',
971
- {
972
- meta: { error: errMsg(err) },
973
- },
974
- )
975
- }
976
-
977
- this.eventBusService.emit({
978
- id: randomUUID(),
979
- timestamp: new Date(),
980
- source: { type: 'core', id: 'addon-package-service' },
981
- category: EventCategory.SystemRestarting,
982
- data: payload,
983
- })
984
-
985
- // 10s grace gives in-flight requests time to drain.
986
- scheduleSelfRestart({ delayMs: 10_000 })
987
- }
988
-
989
- // =========================================================================
990
- // Framework live-update
991
- //
992
- // Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
993
- // =========================================================================
994
-
995
- /**
996
- * Snapshot of installed framework packages — drives the admin-UI
997
- * "System packages" panel. Best-effort: `latestVersion` is null when
998
- * the npm lookup fails (offline, registry unreachable), and the UI
999
- * hides the Update button for those rows.
1000
- *
1001
- * Latest-version lookups run in parallel against npm so the full
1002
- * snapshot returns in roughly the slowest individual lookup, not
1003
- * the sum.
1004
- */
1005
- async listFrameworkPackages(): Promise<
1006
- readonly {
1007
- packageName: string
1008
- currentVersion: string
1009
- latestVersion: string | null
1010
- hasUpdate: boolean
1011
- description?: string
1012
- }[]
1013
- > {
1014
- const registry = process.env['CAMSTACK_NPM_REGISTRY']
1015
-
1016
- const rows = await Promise.all(
1017
- FRAMEWORK_PACKAGE_ALLOWLIST.map(async (packageName) => {
1018
- const manifest = readResolvedPackageManifest(packageName)
1019
- const currentVersion =
1020
- manifest !== null && typeof manifest['version'] === 'string'
1021
- ? manifest['version']
1022
- : 'unknown'
1023
- const description =
1024
- manifest !== null && typeof manifest['description'] === 'string'
1025
- ? manifest['description']
1026
- : undefined
1027
-
1028
- let latestVersion: string | null = null
1029
- try {
1030
- const args = [
1031
- 'view',
1032
- `${packageName}@latest`,
1033
- 'version',
1034
- ...buildNpmRegistryArgs(registry),
1035
- ]
1036
- const { stdout } = await execFileAsync('npm', args, { timeout: 15_000 })
1037
- const trimmed = stdout.trim()
1038
- latestVersion = trimmed.length > 0 ? trimmed : null
1039
- } catch (err) {
1040
- this.logger.debug('listFrameworkPackages: npm view failed', {
1041
- meta: { packageName, error: errMsg(err) },
1042
- })
1043
- }
1044
-
1045
- const hasUpdate =
1046
- latestVersion !== null && currentVersion !== 'unknown' && latestVersion !== currentVersion
1047
-
1048
- return {
1049
- packageName,
1050
- currentVersion,
1051
- latestVersion,
1052
- hasUpdate,
1053
- ...(description !== undefined ? { description } : {}),
1054
- }
1055
- }),
1056
- )
1057
-
1058
- return rows
1059
- }
1060
-
1061
- /**
1062
- * Update one of the framework packages (manifest `camstack.system:
1063
- * true`) and schedule a hub restart.
1064
- *
1065
- * Steps:
1066
- * 1. Allow-list the package name (refuses anything not framework).
1067
- * 2. Resolve `'latest'`/`undefined` to a concrete version via `npm view`.
1068
- * 3. Run `npm install --prefix <appRoot> <name>@<version> --no-save`.
1069
- * 4. Write a `.restart-pending` marker (kind: `framework-update`).
1070
- * 5. Emit `system.restarting` event.
1071
- * 6. `scheduleSelfRestart({ delayMs: 500 })` — gives the cap method
1072
- * time to return before the WS drops.
1073
- *
1074
- * Returns BEFORE the exit fires so the admin UI receives `restartingAt`
1075
- * and can pivot to the reconnect overlay.
1076
- */
1077
- async updateFrameworkPackage(input: {
1078
- readonly packageName: string
1079
- readonly version?: string
1080
- readonly requestedBy?: string
1081
- readonly deferRestart?: boolean
1082
- }): Promise<{
1083
- packageName: string
1084
- fromVersion: string
1085
- toVersion: string
1086
- restartingAt: number
1087
- }> {
1088
- const { packageName } = input
1089
- if (!FRAMEWORK_PACKAGE_ALLOWLIST.includes(packageName)) {
1090
- throw new Error(
1091
- `updateFrameworkPackage: '${packageName}' is not a framework package. Allowed: ${FRAMEWORK_PACKAGE_ALLOWLIST.join(', ')}`,
1092
- )
1093
- }
1094
-
1095
- const appRoot = resolveFrameworkPackageAppRoot(packageName, this.logger)
1096
- const fromManifest = readResolvedPackageManifest(packageName)
1097
- const fromVersion =
1098
- fromManifest !== null && typeof fromManifest['version'] === 'string'
1099
- ? fromManifest['version']
1100
- : 'unknown'
1101
- const requestedVersion = input.version ?? 'latest'
1102
- const toVersion = await resolveNpmVersion(
1103
- packageName,
1104
- requestedVersion,
1105
- process.env['CAMSTACK_NPM_REGISTRY'],
1106
- )
1107
- const spec = `${packageName}@${toVersion}`
1108
-
1109
- this.logger.info('updateFrameworkPackage: installing', {
1110
- meta: { packageName, fromVersion, toVersion, appRoot },
1111
- })
1112
-
1113
- const registry = process.env['CAMSTACK_NPM_REGISTRY']
1114
- const args = [
1115
- 'install',
1116
- '--prefix',
1117
- appRoot,
1118
- spec,
1119
- '--no-save',
1120
- ...buildNpmRegistryArgs(registry),
1121
- ]
1122
- await execFileAsync('npm', args, { timeout: 180_000 })
1123
-
1124
- if (input.deferRestart === true) {
1125
- this.logger.info(
1126
- `updateFrameworkPackage(${packageName}@${toVersion}): install done, restart deferred`,
1127
- )
1128
- // Sentinel: 0 signals "no restart scheduled" to the caller
1129
- return { packageName, fromVersion, toVersion, restartingAt: 0 }
1130
- }
1131
-
1132
- const restartingAt = Date.now()
1133
- const markerPayload: PendingRestartMarkerPayload = {
1134
- kind: 'framework-update',
1135
- packageName,
1136
- fromVersion,
1137
- toVersion,
1138
- requestedAt: restartingAt,
1139
- ...(input.requestedBy !== undefined ? { requestedBy: input.requestedBy } : {}),
1140
- }
1141
-
1142
- try {
1143
- writePendingRestart(this.resolveDataDir(), markerPayload)
1144
- } catch (err) {
1145
- // The npm install already completed — the restart will still
1146
- // pick up the new version, just without the completion toast.
1147
- this.logger.warn('Failed to write restart marker after framework update', {
1148
- meta: { error: errMsg(err) },
1149
- })
1150
- }
1151
-
1152
- this.eventBusService.emit({
1153
- id: randomUUID(),
1154
- timestamp: new Date(),
1155
- source: { type: 'core', id: 'addon-package-service' },
1156
- category: EventCategory.SystemRestarting,
1157
- data: markerPayload,
1158
- })
1159
-
1160
- scheduleSelfRestart({ delayMs: 500 })
1161
-
1162
- return { packageName, fromVersion, toVersion, restartingAt }
1163
- }
1164
-
1165
- // =========================================================================
1166
- // Reload
1167
- // =========================================================================
1168
-
1169
- /** Re-discover addons from the addons directory (call after install/uninstall) */
1170
- async reloadPackages(): Promise<void> {
1171
- try {
1172
- const kernel = await import('@camstack/kernel')
1173
- this.loader = new kernel.AddonLoader(this.logger.child('AddonLoader'))
1174
-
1175
- const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
1176
- const addonsDir = path.resolve(dataDir, 'addons')
1177
- await this.loader.loadFromDirectory(addonsDir)
1178
-
1179
- this.logger.info('Reloaded', { meta: { count: this.loader.listAddons().length } })
1180
- } catch (error: unknown) {
1181
- const msg = errMsg(error)
1182
- this.logger.warn('reloadPackages failed', { meta: { error: msg } })
1183
- }
1184
- }
1185
-
1186
- // =========================================================================
1187
- // Auto-update channel resolution
1188
- // =========================================================================
1189
-
1190
- /** Resolve effective auto-update channel for a package */
1191
- getEffectiveAutoUpdateChannel(
1192
- _addonId: string,
1193
- globalChannel: AutoUpdateChannel,
1194
- perAddonChannel: AutoUpdateChannel,
1195
- ): 'off' | 'latest' | 'beta' {
1196
- if (perAddonChannel !== 'inherit') return perAddonChannel as 'off' | 'latest' | 'beta'
1197
- if (globalChannel === 'inherit') return 'off' // shouldn't happen at global level
1198
- return globalChannel as 'off' | 'latest' | 'beta'
1199
- }
1200
-
1201
- // =========================================================================
1202
- // Auto-update settings
1203
- // =========================================================================
1204
-
1205
- /** Get global auto-update settings */
1206
- getAutoUpdateSettings(): { channel: 'off' | 'latest' | 'beta'; intervalSeconds: number } {
1207
- return { ...this.autoUpdateConfig.global }
1208
- }
1209
-
1210
- /** Set global auto-update settings and restart the timer */
1211
- async setAutoUpdateSettings(
1212
- channel: 'off' | 'latest' | 'beta',
1213
- intervalSeconds?: number,
1214
- ): Promise<void> {
1215
- this.autoUpdateConfig = {
1216
- ...this.autoUpdateConfig,
1217
- global: {
1218
- channel,
1219
- intervalSeconds: intervalSeconds ?? this.autoUpdateConfig.global.intervalSeconds,
1220
- },
1221
- }
1222
- this.saveAutoUpdateConfig()
1223
- this.scheduleAutoUpdate()
1224
- }
1225
-
1226
- /** Get per-addon auto-update override */
1227
- getAddonAutoUpdate(addonId: string): 'off' | 'latest' | 'beta' | 'inherit' {
1228
- return this.autoUpdateConfig.overrides[addonId] ?? 'inherit'
1229
- }
1230
-
1231
- /** Set per-addon auto-update override */
1232
- async setAddonAutoUpdate(
1233
- addonId: string,
1234
- channel: 'off' | 'latest' | 'beta' | 'inherit',
1235
- ): Promise<void> {
1236
- const newOverrides = { ...this.autoUpdateConfig.overrides }
1237
- if (channel === 'inherit') {
1238
- delete newOverrides[addonId]
1239
- } else {
1240
- newOverrides[addonId] = channel
1241
- }
1242
- this.autoUpdateConfig = { ...this.autoUpdateConfig, overrides: newOverrides }
1243
- this.saveAutoUpdateConfig()
1244
- }
1245
-
1246
- /** Schedule periodic auto-update check */
1247
- scheduleAutoUpdate(): void {
1248
- if (this.autoUpdateTimer) {
1249
- clearInterval(this.autoUpdateTimer)
1250
- this.autoUpdateTimer = null
1251
- }
1252
-
1253
- const hasOverrides = Object.values(this.autoUpdateConfig.overrides).some((ch) => ch !== 'off')
1254
- if (this.autoUpdateConfig.global.channel === 'off' && !hasOverrides) {
1255
- this.logger.info('Auto-update disabled')
1256
- return
1257
- }
1258
-
1259
- const intervalMs = this.autoUpdateConfig.global.intervalSeconds * 1000
1260
- this.logger.info('Auto-update scheduled', {
1261
- meta: {
1262
- intervalSeconds: this.autoUpdateConfig.global.intervalSeconds,
1263
- channel: this.autoUpdateConfig.global.channel,
1264
- },
1265
- })
1266
-
1267
- this.autoUpdateTimer = setInterval(() => {
1268
- this.runAutoUpdate().catch((err) => {
1269
- this.logger.error('Auto-update check failed', { meta: { error: errMsg(err) } })
1270
- })
1271
- }, intervalMs)
1272
- }
1273
-
1274
- /** Run auto-update: check each installed package against its configured channel */
1275
- async runAutoUpdate(): Promise<void> {
1276
- this.logger.info('Running auto-update check...')
1277
- const installed = this.listInstalled()
1278
- let updatedCount = 0
1279
-
1280
- for (const pkg of installed) {
1281
- try {
1282
- // Determine effective channel for this addon
1283
- const addonId = pkg.name.replace('@camstack/addon-', '').replace('@camstack/', '')
1284
- const override = this.autoUpdateConfig.overrides[addonId]
1285
- const effectiveChannel = this.getEffectiveAutoUpdateChannel(
1286
- addonId,
1287
- this.autoUpdateConfig.global.channel,
1288
- override ?? 'inherit',
1289
- )
1290
-
1291
- if (effectiveChannel === 'off') continue
1292
-
1293
- // Fetch dist-tags for this package
1294
- const encodedName = pkg.name.replace('/', '%2F')
1295
- const url = `${AddonPackageService.NPM_REGISTRY}/${encodedName}`
1296
- const response = await fetch(url, {
1297
- signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
1298
- })
1299
- if (!response.ok) continue
1300
-
1301
- const data = await fetchJsonObject(response)
1302
- const distTags = asRecord(data['dist-tags'])
1303
-
1304
- // Determine target version based on channel
1305
- const targetVersion =
1306
- effectiveChannel === 'beta'
1307
- ? asString(distTags['beta']) || asString(distTags['latest'])
1308
- : asString(distTags['latest'])
1309
-
1310
- if (!targetVersion || targetVersion === pkg.version) continue
1311
-
1312
- this.logger.info('Auto-updating package', {
1313
- meta: {
1314
- name: pkg.name,
1315
- currentVersion: pkg.version,
1316
- targetVersion,
1317
- channel: effectiveChannel,
1318
- },
1319
- })
1320
- await this.updatePackage(pkg.name, targetVersion)
1321
- updatedCount++
1322
- } catch (err) {
1323
- this.logger.warn('Auto-update failed', { meta: { name: pkg.name, error: errMsg(err) } })
1324
- }
1325
- }
1326
-
1327
- if (updatedCount > 0) {
1328
- this.logger.info('Auto-update complete', { meta: { updatedCount } })
1329
- } else {
1330
- this.logger.debug('Auto-update: all packages up-to-date')
1331
- }
1332
- }
1333
-
1334
- // =========================================================================
1335
- // Private: auto-update config persistence
1336
- // =========================================================================
1337
-
1338
- /** Load auto-update config from disk */
1339
- private loadAutoUpdateConfig(): AutoUpdateConfig {
1340
- const configPath = path.join(this.resolveDataDir(), 'auto-update.json')
1341
- try {
1342
- if (fs.existsSync(configPath)) {
1343
- const raw = readJsonObject(configPath)
1344
- if (raw) {
1345
- const global = asRecord(raw['global'])
1346
- const channel = asString(global['channel'])
1347
- const validChannel: AutoUpdateConfig['global']['channel'] =
1348
- channel === 'latest' || channel === 'beta' ? channel : 'off'
1349
- return {
1350
- global: {
1351
- channel: validChannel,
1352
- intervalSeconds:
1353
- typeof global['intervalSeconds'] === 'number' ? global['intervalSeconds'] : 3600,
1354
- },
1355
- overrides: Object.fromEntries(
1356
- Object.entries(asRecord(raw['overrides'])).map(([k, v]) => {
1357
- const s = asString(v)
1358
- const valid: AutoUpdateConfig['overrides'][string] =
1359
- s === 'off' || s === 'latest' || s === 'beta' || s === 'inherit' ? s : 'inherit'
1360
- return [k, valid]
1361
- }),
1362
- ),
1363
- }
1364
- }
1365
- }
1366
- } catch (err) {
1367
- this.logger.debug('Corrupt auto-update config, falling back to defaults', {
1368
- meta: { error: errMsg(err) },
1369
- })
1370
- }
1371
- return { global: { channel: 'off', intervalSeconds: 21600 }, overrides: {} }
1372
- }
1373
-
1374
- /** Save auto-update config to disk */
1375
- private saveAutoUpdateConfig(): void {
1376
- const dataDir = this.resolveDataDir()
1377
- try {
1378
- if (!fs.existsSync(dataDir)) {
1379
- fs.mkdirSync(dataDir, { recursive: true })
1380
- }
1381
- const configPath = path.join(dataDir, 'auto-update.json')
1382
- fs.writeFileSync(configPath, JSON.stringify(this.autoUpdateConfig, null, 2))
1383
- } catch (err) {
1384
- this.logger.warn('Failed to save auto-update config', { meta: { error: errMsg(err) } })
1385
- }
1386
- }
1387
-
1388
- /** Resolve the top-level data directory */
1389
- private resolveDataDir(): string {
1390
- return path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
1391
- }
1392
-
1393
- // =========================================================================
1394
- // Private: core package update checks
1395
- // =========================================================================
1396
-
1397
- /**
1398
- * Check addon packages for updates by reading installed versions from
1399
- * data/addons/{name}/package.json and comparing against npm registry.
1400
- */
1401
- private async checkAddonPackageUpdates(): Promise<PackageUpdate[]> {
1402
- const addonsDir = this.resolveAddonsDir()
1403
- const updates: PackageUpdate[] = []
1404
-
1405
- if (!fs.existsSync(addonsDir)) return updates
1406
-
1407
- // Collect all package.json paths -- handles both flat and scoped layouts
1408
- const pkgJsonPaths: string[] = []
1409
- const topDirs = fs
1410
- .readdirSync(addonsDir, { withFileTypes: true })
1411
- .filter((d) => d.isDirectory())
1412
-
1413
- for (const dir of topDirs) {
1414
- const dirPath = path.join(addonsDir, dir.name)
1415
- if (dir.name.startsWith('@')) {
1416
- // Scoped package directory -- scan one level deeper
1417
- const scopedDirs = fs
1418
- .readdirSync(dirPath, { withFileTypes: true })
1419
- .filter((d) => d.isDirectory())
1420
- for (const scopedDir of scopedDirs) {
1421
- const pkgJson = path.join(dirPath, scopedDir.name, 'package.json')
1422
- if (fs.existsSync(pkgJson)) pkgJsonPaths.push(pkgJson)
1423
- }
1424
- } else {
1425
- const pkgJson = path.join(dirPath, 'package.json')
1426
- if (fs.existsSync(pkgJson)) pkgJsonPaths.push(pkgJson)
1427
- }
1428
- }
1429
-
1430
- for (const pkgJsonPath of pkgJsonPaths) {
1431
- try {
1432
- const pkgJson = readJsonObject(pkgJsonPath)
1433
- if (!pkgJson) continue
1434
- const name = asString(pkgJson['name'])
1435
- const version = asString(pkgJson['version'])
1436
- if (!name || !version || !this.isAllowedPackage(name)) continue
1437
-
1438
- // Skip non-addon packages (core, types, etc.) — only check packages with camstack.addons
1439
- const camstackField = asRecord(pkgJson['camstack'])
1440
- if (!camstackField['addons']) continue
1441
-
1442
- // Skip workspace-installed packages (dev mode) — they always lag behind npm
1443
- const addonDir = path.dirname(pkgJsonPath)
1444
- const installSourcePath = path.join(addonDir, '.install-source')
1445
- if (fs.existsSync(installSourcePath)) {
1446
- const source = fs.readFileSync(installSourcePath, 'utf-8').trim()
1447
- if (source === 'workspace') continue
1448
- }
1449
-
1450
- const latestVersion = await this.fetchLatestVersion(name)
1451
- if (!latestVersion) continue
1452
-
1453
- if (latestVersion !== version) {
1454
- updates.push({
1455
- name,
1456
- currentVersion: version,
1457
- latestVersion,
1458
- category: this.categorize(name),
1459
- requiresRestart: this.categorize(name) === 'core',
1460
- })
1461
- }
1462
- } catch (error: unknown) {
1463
- const msg = errMsg(error)
1464
- this.logger.debug('Failed to check updates for addon', {
1465
- meta: { pkgJsonPath, error: msg },
1466
- })
1467
- }
1468
- }
1469
-
1470
- return updates
1471
- }
1472
-
1473
- // =========================================================================
1474
- // Private: npm registry helpers
1475
- // =========================================================================
1476
-
1477
- /**
1478
- * Base URL for npm registry METADATA fetches.
1479
- *
1480
- * Honours `CAMSTACK_NPM_REGISTRY` so update checks resolve against
1481
- * the same registry the installer/pack paths use. Without this, a
1482
- * per-node `listUpdates` (which diffs an agent's roster via
1483
- * `checkUpdatesForInstalled` → `fetchLatestVersion`) would bypass a
1484
- * private registry — including the e2e harness's verdaccio — and
1485
- * silently report "no update" for packages that only exist there.
1486
- * Trailing slashes are stripped so the `${base}/${name}` join is clean.
1487
- */
1488
- private resolveRegistryBase(): string {
1489
- const override = process.env['CAMSTACK_NPM_REGISTRY']
1490
- const base = override && override.length > 0 ? override : AddonPackageService.NPM_REGISTRY
1491
- return base.replace(/\/+$/, '')
1492
- }
1493
-
1494
- /** Fetch the latest published version of a package from the npm registry */
1495
- private async fetchLatestVersion(packageName: string): Promise<string | null> {
1496
- try {
1497
- const encodedName = packageName.replace('/', '%2F')
1498
- const url = `${this.resolveRegistryBase()}/${encodedName}/latest`
1499
- const response = await fetch(url, {
1500
- signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
1501
- })
1502
- if (!response.ok) {
1503
- this.logger.debug('Registry returned non-ok status', {
1504
- meta: { packageName, status: response.status },
1505
- })
1506
- return null
1507
- }
1508
- const data = await fetchJsonObject(response)
1509
- const version = asString(data['version'])
1510
- return version || null
1511
- } catch (error: unknown) {
1512
- const msg = errMsg(error)
1513
- this.logger.debug('Failed to fetch latest version', { meta: { packageName, error: msg } })
1514
- return null
1515
- }
1516
- }
1517
-
1518
- /** Fetch npm search results for camstack addon packages (cached 5 min) */
1519
- private async fetchSearchFromNpm(): Promise<readonly NpmSearchResult[]> {
1520
- if (
1521
- this.searchCache &&
1522
- Date.now() - this.searchCache.timestamp < AddonPackageService.SEARCH_CACHE_TTL_MS
1523
- ) {
1524
- return this.searchCache.results
1525
- }
1526
-
1527
- const url =
1528
- 'https://registry.npmjs.org/-/v1/search?text=keywords:camstack+keywords:addon&size=250'
1529
-
1530
- try {
1531
- const response = await fetch(url, {
1532
- headers: { Accept: 'application/json' },
1533
- signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
1534
- })
1535
-
1536
- if (!response.ok) {
1537
- throw new Error(`npm search failed: ${response.status}`)
1538
- }
1539
-
1540
- const data = await fetchJsonObject(response)
1541
- const rawObjects = Array.isArray(data['objects']) ? data['objects'] : []
1542
- const results: NpmSearchResult[] = []
1543
- for (const raw of rawObjects) {
1544
- const wrapper = asRecord(raw)
1545
- const pkg = asRecord(wrapper['package'])
1546
- const name = asString(pkg['name'])
1547
- if (!name) continue
1548
- const rawKeywords = Array.isArray(pkg['keywords']) ? pkg['keywords'] : []
1549
- const publisher = asRecord(pkg['publisher'])
1550
- results.push({
1551
- name,
1552
- version: asString(pkg['version']),
1553
- description: asString(pkg['description']),
1554
- keywords: rawKeywords.filter((k): k is string => typeof k === 'string'),
1555
- date: asString(pkg['date']),
1556
- publisher: { username: asString(publisher['username']) },
1557
- })
1558
- }
1559
-
1560
- this.searchCache = { results, timestamp: Date.now() }
1561
- return results
1562
- } catch (err: unknown) {
1563
- this.logger.warn('npm search failed', { meta: { error: errMsg(err) } })
1564
- // Stale cache is better than nothing on transient network failure
1565
- return this.searchCache?.results ?? []
1566
- }
1567
- }
1568
-
1569
- // =========================================================================
1570
- // Private: general helpers
1571
- // =========================================================================
1572
-
1573
- /**
1574
- * Read the currently installed version of a package.
1575
- * Checks data/addons/ first, then falls back to Node module resolution.
1576
- * Returns '0.0.0' if not found.
1577
- */
1578
- private getInstalledPackageVersion(packageName: string): string {
1579
- try {
1580
- const addonsDir = this.resolveAddonsDir()
1581
- const dirName = packageName.replace(/^@camstack\//, '')
1582
- const pkgJsonPath = path.join(addonsDir, dirName, 'package.json')
1583
- if (fs.existsSync(pkgJsonPath)) {
1584
- const pkgJson = readJsonObject(pkgJsonPath)
1585
- const v = asString(pkgJson?.['version'])
1586
- if (v) return v
1587
- }
1588
- } catch (err) {
1589
- this.logger.debug('Version lookup via fs failed, trying require.resolve', {
1590
- meta: { packageName, error: errMsg(err) },
1591
- })
1592
- }
1593
-
1594
- try {
1595
- const pkgJsonPath = require.resolve(`${packageName}/package.json`)
1596
- const pkgJson = readJsonObject(pkgJsonPath)
1597
- return asString(pkgJson?.['version'], '0.0.0')
1598
- } catch (err) {
1599
- this.logger.debug('Could not resolve version, returning 0.0.0', {
1600
- meta: { packageName, error: errMsg(err) },
1601
- })
1602
- return '0.0.0'
1603
- }
1604
- }
1605
-
1606
- /** Only allow @camstack/* scoped packages */
1607
- private isAllowedPackage(name: string): boolean {
1608
- return name.startsWith('@camstack/')
1609
- }
1610
-
1611
- /** Categorize a package as 'addon' or 'core' */
1612
- private categorize(name: string): 'addon' | 'core' {
1613
- if (CORE_MANAGED_PACKAGES.includes(name)) {
1614
- return 'core'
1615
- }
1616
- return name.includes('/addon-') ? 'addon' : 'core'
1617
- }
1618
-
1619
- /** Extract addon ID from package name: '@camstack/addon-benchmark' -> 'benchmark' */
1620
- private extractAddonId(packageName: string): string | null {
1621
- const match = packageName.match(/@camstack\/addon-(.+)/)
1622
- return match?.[1] ?? null
1623
- }
1624
-
1625
- /** Resolve the addons directory from config or fall back to default */
1626
- private resolveAddonsDir(): string {
1627
- const dataPath = this.configService.get<string>('server.dataPath') ?? 'camstack-data'
1628
- return path.resolve(dataPath, 'addons')
1629
- }
1630
-
1631
- /** Throw if installer was not initialised */
1632
- private requireInstaller(): void {
1633
- if (!this.installer) {
1634
- throw new Error('AddonInstaller is not available -- @camstack/kernel may not be installed')
1635
- }
1636
- }
1637
-
1638
- /** Send notification and toast for a successful package update */
1639
- private sendUpdateNotification(name: string, version: string): void {
1640
- this.notificationService
1641
- .notify({
1642
- title: 'Package Updated',
1643
- message: `${name} updated to v${version}`,
1644
- severity: 'info',
1645
- category: 'system',
1646
- timestamp: Date.now(),
1647
- })
1648
- .catch((err: unknown) => {
1649
- const msg = errMsg(err)
1650
- this.logger.debug('Update notification failed', { meta: { error: msg } })
1651
- })
1652
-
1653
- this.toastService.broadcast({
1654
- title: 'Package Updated',
1655
- message: `${name} updated to v${version}`,
1656
- severity: 'info',
1657
- duration: 5000,
1658
- })
1659
- }
1660
- }
1661
-
1662
- // ---------------------------------------------------------------------------
1663
- // Framework live-update helpers
1664
- // ---------------------------------------------------------------------------
1665
-
1666
- /**
1667
- * Build the npm CLI args that pin every relevant registry to
1668
- * `CAMSTACK_NPM_REGISTRY`. We pass BOTH the default `--registry` and
1669
- * the scope-specific `--@camstack:registry=` flag because workspace or
1670
- * user-home `.npmrc` files commonly declare
1671
- * `@camstack:registry=https://registry.npmjs.org/`, and that scoped
1672
- * entry takes precedence over the plain `--registry` CLI flag for
1673
- * `@camstack/*` lookups — which is exactly the path framework-update
1674
- * traverses.
1675
- *
1676
- * Without this, the e2e suite's verdaccio gets bypassed even with
1677
- * `CAMSTACK_NPM_REGISTRY` set, AND in production any operator running
1678
- * their own private npm proxy via `@camstack:registry` would have
1679
- * `updateFrameworkPackage` silently route around it.
1680
- */
1681
- function buildNpmRegistryArgs(registry: string | undefined): readonly string[] {
1682
- if (registry === undefined || registry.length === 0) return []
1683
- return ['--registry', registry, `--@camstack:registry=${registry}`]
1684
- }
1685
-
1686
- /**
1687
- * Resolve the directory whose `node_modules/<pkg>/` holds the currently-
1688
- * installed copy of a framework package. `npm install --prefix <appRoot>`
1689
- * will then update that exact copy in place.
1690
- *
1691
- * Strategy: ask Node's resolver where it finds the package today, then walk
1692
- * up to the `node_modules/`-parent. This matches whatever resolution path
1693
- * the running hub actually uses (server-local node_modules in prod;
1694
- * workspace-root in dev; bundled in Electron) without hard-coding either.
1695
- *
1696
- * Test knob: `CAMSTACK_FRAMEWORK_APP_ROOT_OVERRIDE` short-circuits the walk
1697
- * and returns the env-supplied path. Used by the e2e suite to redirect the
1698
- * `npm install --prefix` side-effects into an isolated temp dir instead of
1699
- * the workspace's `server/backend/node_modules/`. Never set in production.
1700
- */
1701
- function resolveFrameworkPackageAppRoot(packageName: string, logger: IScopedLogger): string {
1702
- const override = process.env['CAMSTACK_FRAMEWORK_APP_ROOT_OVERRIDE']
1703
- if (override !== undefined && override.length > 0) {
1704
- return override
1705
- }
1706
- const resolved = require.resolve(`${packageName}/package.json`)
1707
- // …/<appRoot>/node_modules/<scope>/<name>/package.json
1708
- // walk up: package.json → name → scope → node_modules → appRoot
1709
- let dir = path.dirname(resolved)
1710
- while (dir !== path.dirname(dir)) {
1711
- if (path.basename(dir) === 'node_modules') {
1712
- return path.dirname(dir)
1713
- }
1714
- dir = path.dirname(dir)
1715
- }
1716
- logger.warn(`Could not resolve appRoot for ${packageName}; falling back to process.cwd()`)
1717
- return process.cwd()
1718
- }
1719
-
1720
- /**
1721
- * Read a framework package's `package.json`, resolved however the
1722
- * running hub actually loads it — workspace symlink in dev, a real
1723
- * `node_modules` tree in prod, bundled in Electron.
1724
- *
1725
- * `require.resolve('<pkg>/package.json')` is the happy path. Packages
1726
- * whose `exports` map omits the `./package.json` subpath (e.g.
1727
- * `@camstack/sdk`, `@camstack/ui-library`) make that throw — so we
1728
- * fall back to resolving the package's main entry and walking up to
1729
- * the first `package.json` whose `name` matches.
1730
- *
1731
- * This is deliberately independent of `resolveFrameworkPackageAppRoot`:
1732
- * that walk only finds a real `node_modules`-parent, which doesn't
1733
- * exist for workspace-symlinked packages in dev — the cause of the
1734
- * `vunknown` version label in the System Packages UI.
1735
- */
1736
- function readResolvedPackageManifest(packageName: string): Record<string, unknown> | null {
1737
- try {
1738
- return readJsonObject(require.resolve(`${packageName}/package.json`))
1739
- } catch {
1740
- // `exports` map blocks the package.json subpath — fall through.
1741
- }
1742
- try {
1743
- let dir = path.dirname(require.resolve(packageName))
1744
- while (dir !== path.dirname(dir)) {
1745
- const candidate = path.join(dir, 'package.json')
1746
- const obj = fs.existsSync(candidate) ? readJsonObject(candidate) : null
1747
- if (obj !== null && obj['name'] === packageName) return obj
1748
- dir = path.dirname(dir)
1749
- }
1750
- } catch {
1751
- // The package itself is not resolvable — treat as not installed.
1752
- }
1753
- return null
1754
- }
1755
-
1756
- /**
1757
- * Resolve a version specifier (`'latest'`, semver tag, exact) to a concrete
1758
- * version string via `npm view <pkg>@<spec> version`. Returns the spec as-is
1759
- * when `npm view` is unavailable or fails — better to attempt the install
1760
- * than to block.
1761
- */
1762
- async function resolveNpmVersion(
1763
- packageName: string,
1764
- versionSpec: string,
1765
- registry: string | undefined,
1766
- ): Promise<string> {
1767
- const args = [
1768
- 'view',
1769
- `${packageName}@${versionSpec}`,
1770
- 'version',
1771
- ...buildNpmRegistryArgs(registry),
1772
- ]
1773
- try {
1774
- const { stdout } = await execFileAsync('npm', args, { timeout: 30_000 })
1775
- const trimmed = stdout.trim()
1776
- // `npm view` returns just the version line for a single match, or
1777
- // "pkg@1.2.3 '1.2.3'" lines for a range. Take the last token on the
1778
- // last non-empty line which is the most specific resolved version.
1779
- const lines = trimmed.split('\n').filter((line) => line.trim().length > 0)
1780
- if (lines.length === 0) return versionSpec
1781
- const last = lines[lines.length - 1]!.trim()
1782
- const match = last.match(/'([^']+)'\s*$/)
1783
- return match ? match[1]! : last
1784
- } catch {
1785
- return versionSpec
1786
- }
1787
- }