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