@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.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- 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
|
+
}
|