@camstack/server 1.0.5 → 1.0.7

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.
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AddonPackageService = exports.SYSTEM_PACKAGE = void 0;
37
37
  exports.isFrameworkPackage = isFrameworkPackage;
38
+ exports.extractTgzStripped = extractTgzStripped;
38
39
  exports.sweepStaleFrameworkBackups = sweepStaleFrameworkBackups;
39
40
  exports.swapInFrameworkPackage = swapInFrameworkPackage;
40
41
  const fs = __importStar(require("node:fs"));
@@ -812,6 +813,112 @@ class AddonPackageService {
812
813
  return { success: false, version: '', requiresRestart: false, error: msg };
813
814
  }
814
815
  }
816
+ /**
817
+ * Apply a staged addon update without running npm — the fast swap-from-staged
818
+ * path. Mirrors the addon branch of `updatePackage` exactly, but calls
819
+ * `addonInstaller.applyUpdateFromStaged(name, version, stagedPath)` instead
820
+ * of `applyUpdate`/`installFromNpm`.
821
+ *
822
+ * The staged directory must already be validated and unpacked by the caller
823
+ * (e.g. the upload handler that previously called `installFromTgz`). This
824
+ * method performs the atomic swap, restarts the addon, and clears the backup
825
+ * on success — identical lifecycle to `updatePackage` addon branch.
826
+ */
827
+ async applyStagedAddonUpdate(name, version, stagedPath) {
828
+ if (!this.isAllowedPackage(name)) {
829
+ return {
830
+ success: false,
831
+ version: '',
832
+ requiresRestart: false,
833
+ error: `Package "${name}" is not an allowed @camstack/* package`,
834
+ };
835
+ }
836
+ const category = this.categorize(name);
837
+ this.logger.info('Applying staged addon update', { meta: { name, version, category } });
838
+ try {
839
+ if (category !== 'addon') {
840
+ return {
841
+ success: false,
842
+ version: '',
843
+ requiresRestart: false,
844
+ error: `applyStagedAddonUpdate only supports addon packages; "${name}" categorized as "${category}"`,
845
+ };
846
+ }
847
+ this.requireInstaller();
848
+ const addonInstaller = this.installer;
849
+ const previousVersion = this.getInstalledPackageVersion(name);
850
+ const r = await addonInstaller.applyUpdateFromStaged(name, version, stagedPath);
851
+ const updatedVersion = r.version;
852
+ // Update version in registry so UI reflects the change immediately
853
+ this.addonRegistry.refreshPackageVersion(name, updatedVersion);
854
+ // Emit addon.updated lifecycle event
855
+ this.addonRegistry.emitUpdateEvent(name, previousVersion, updatedVersion);
856
+ this.logger.info('Staged addon swap complete, triggering hot-reload', {
857
+ meta: { name, updatedVersion },
858
+ });
859
+ const addonId = this.extractAddonId(name);
860
+ if (addonId) {
861
+ try {
862
+ await this.addonRegistry.restartAddon(addonId);
863
+ this.logger.info('Addon restarted after staged update', { tags: { addonId } });
864
+ // Restart succeeded — drop the backup
865
+ addonInstaller.clearBackup(name);
866
+ }
867
+ catch (reloadError) {
868
+ const msg = (0, types_1.errMsg)(reloadError);
869
+ this.logger.warn('Hot-reload failed for addon — backup retained for rollback', {
870
+ tags: { addonId },
871
+ meta: { error: msg, backupDir: r.backupDir },
872
+ });
873
+ }
874
+ }
875
+ // Clear update cache so next check reflects new state
876
+ this.cachedUpdates = null;
877
+ this.sendUpdateNotification(name, updatedVersion);
878
+ return { success: true, version: updatedVersion, requiresRestart: false };
879
+ }
880
+ catch (error) {
881
+ const msg = (0, types_1.errMsg)(error);
882
+ this.logger.error('Failed to apply staged addon update', { meta: { name, error: msg } });
883
+ return { success: false, version: '', requiresRestart: false, error: msg };
884
+ }
885
+ }
886
+ /**
887
+ * Download the npm tarball for `name@version` as a Buffer.
888
+ *
889
+ * Wraps the module-level `httpsDownloadTarball` for use by the
890
+ * lifecycle job engine (TarballFetcher signature). The `signal` is
891
+ * forwarded to the underlying fetch calls so the AbortController
892
+ * timeout wired in LifecycleJobEngine fires correctly.
893
+ */
894
+ async fetchAddonTarball(name, version, signal) {
895
+ const registry = process.env['CAMSTACK_NPM_REGISTRY'];
896
+ const reg = (registry ?? 'https://registry.npmjs.org').replace(/\/+$/, '');
897
+ const metaUrl = `${reg}/${encodeURIComponent(name).replace(/^%40/, '@')}`;
898
+ const metaRes = await fetch(metaUrl, { signal });
899
+ if (!metaRes.ok)
900
+ throw new Error(`registry GET ${metaUrl} → ${metaRes.status}`);
901
+ const meta = (await metaRes.json());
902
+ const tarballUrl = meta.versions?.[version]?.dist?.tarball;
903
+ if (typeof tarballUrl !== 'string') {
904
+ throw new Error(`no tarball url for ${name}@${version}`);
905
+ }
906
+ const tarRes = await fetch(tarballUrl, { signal });
907
+ if (!tarRes.ok)
908
+ throw new Error(`tarball GET ${tarballUrl} → ${tarRes.status}`);
909
+ return Buffer.from(await tarRes.arrayBuffer());
910
+ }
911
+ /**
912
+ * Extract a gzipped tarball Buffer into `destDir`.
913
+ *
914
+ * Wraps the existing `tar -xzf` shell call for use by the lifecycle job
915
+ * engine (ExtractFn signature). The Buffer is written to a temp file,
916
+ * extracted with `--strip-components=1` (removes the npm `package/` prefix),
917
+ * then the temp file is removed.
918
+ */
919
+ async extractTarball(tgz, destDir) {
920
+ await extractTgzStripped(tgz, destDir);
921
+ }
815
922
  /**
816
923
  * Gracefully restart the server process.
817
924
  *
@@ -981,19 +1088,94 @@ class AddonPackageService {
981
1088
  fs.rmSync(tmpRoot, { recursive: true, force: true });
982
1089
  }
983
1090
  }
1091
+ /**
1092
+ * Resolve a version specifier (`'latest'`, semver tag, exact) for a
1093
+ * framework package to a concrete version via `npm view`.
1094
+ * Delegates to the module-level `resolveNpmVersion` helper.
1095
+ * Exposed as a public method so the lifecycle job engine wiring in
1096
+ * cap-providers can pass it as `resolveVersion` without duplicating the
1097
+ * npm-view/manifest logic.
1098
+ */
1099
+ async resolveFrameworkVersion(packageName, versionSpec) {
1100
+ return resolveNpmVersion(packageName, versionSpec, process.env['CAMSTACK_NPM_REGISTRY']);
1101
+ }
1102
+ /**
1103
+ * Return the currently-installed version of a framework package, or `null`
1104
+ * when it cannot be determined (not installed / no manifest).
1105
+ * Delegates to the module-level `readResolvedPackageManifest` helper.
1106
+ * Exposed as a public method for use by the lifecycle job engine wiring.
1107
+ */
1108
+ currentFrameworkVersionOf(packageName) {
1109
+ const manifest = readResolvedPackageManifest(packageName);
1110
+ if (manifest !== null && typeof manifest['version'] === 'string') {
1111
+ return manifest['version'];
1112
+ }
1113
+ return null;
1114
+ }
1115
+ /**
1116
+ * Update a framework package via the staged launcher-swap engine.
1117
+ *
1118
+ * New path (when `runner` is provided):
1119
+ * 1. Resolve toVersion via `npm view`.
1120
+ * 2. Delegate to `runner.startJob` with a `target:'framework'` task.
1121
+ * The engine calls `stageFramework` → downloads+stages the 4 lockstep
1122
+ * packages → calls the wired `requestFrameworkSwap`
1123
+ * (`requestFrameworkSwapAndRestart`), which writes the
1124
+ * `.pending-framework-swap.json` marker AND schedules the self-restart.
1125
+ * The hub exits; the launcher applies the swap on the next boot. The
1126
+ * restart is owned by that seam, so this method does NOT schedule one.
1127
+ * 3. Return `{ packageName, fromVersion, toVersion, restartingAt }`.
1128
+ * `restartingAt = 0` only on the legacy in-hub path when `deferRestart`
1129
+ * is set (engine path always restarts once the framework is staged).
1130
+ *
1131
+ * Legacy path (when no `runner`): keeps the original in-hub live-swap for
1132
+ * backward compatibility with callers (e.g. tests) that have not wired the
1133
+ * engine deps.
1134
+ */
984
1135
  async updateFrameworkPackage(input) {
985
1136
  const { packageName } = input;
986
1137
  if (packageName !== exports.SYSTEM_PACKAGE) {
987
1138
  throw new Error(`updateFrameworkPackage: '${packageName}' is not a framework package. Allowed: ${exports.SYSTEM_PACKAGE}`);
988
1139
  }
989
- const appRoot = resolveFrameworkPackageAppRoot(packageName, this.logger);
990
- const fromManifest = readResolvedPackageManifest(packageName);
991
- const fromVersion = fromManifest !== null && typeof fromManifest['version'] === 'string'
992
- ? fromManifest['version']
993
- : 'unknown';
1140
+ const fromVersion = this.currentFrameworkVersionOf(packageName) ?? 'unknown';
994
1141
  const requestedVersion = input.version ?? 'latest';
995
- const toVersion = await resolveNpmVersion(packageName, requestedVersion, process.env['CAMSTACK_NPM_REGISTRY']);
996
- this.logger.info('updateFrameworkPackage: installing', {
1142
+ const toVersion = await this.resolveFrameworkVersion(packageName, requestedVersion);
1143
+ // ── New staged-swap engine path ──────────────────────────────────
1144
+ if (input.runner !== undefined) {
1145
+ this.logger.info('updateFrameworkPackage: delegating to staged swap engine', {
1146
+ meta: { packageName, fromVersion, toVersion, deferRestart: input.deferRestart ?? false },
1147
+ });
1148
+ await input.runner.startJob({
1149
+ kind: 'update',
1150
+ targets: [{ name: packageName, version: toVersion, target: 'framework' }],
1151
+ createdBy: input.requestedBy ?? 'system',
1152
+ });
1153
+ // The engine's framework task already wrote the pending-swap marker AND
1154
+ // scheduled the self-restart via the wired `requestFrameworkSwap`
1155
+ // (`requestFrameworkSwapAndRestart`) — that is the single restart owner,
1156
+ // so we must NOT schedule a second one here. We only surface the
1157
+ // pre-restart toast event for the interactive single-update path.
1158
+ if (input.deferRestart !== true) {
1159
+ this.eventBusService.emit({
1160
+ id: (0, node_crypto_1.randomUUID)(),
1161
+ timestamp: new Date(),
1162
+ source: { type: 'core', id: 'addon-package-service' },
1163
+ category: types_1.EventCategory.SystemRestarting,
1164
+ data: {
1165
+ kind: 'framework-update',
1166
+ packageName,
1167
+ fromVersion,
1168
+ toVersion,
1169
+ requestedAt: Date.now(),
1170
+ },
1171
+ });
1172
+ }
1173
+ const restartingAt = input.deferRestart === true ? 0 : Date.now() + 500;
1174
+ return { packageName, fromVersion, toVersion, restartingAt };
1175
+ }
1176
+ // ── Legacy in-hub live-swap path (no runner injected) ───────────
1177
+ const appRoot = resolveFrameworkPackageAppRoot(packageName, this.logger);
1178
+ this.logger.info('updateFrameworkPackage: installing (legacy in-hub path)', {
997
1179
  meta: { packageName, fromVersion, toVersion, appRoot },
998
1180
  });
999
1181
  await this.installFrameworkPackages(packageName, toVersion, appRoot);
@@ -1118,11 +1300,22 @@ class AddonPackageService {
1118
1300
  });
1119
1301
  }, intervalMs);
1120
1302
  }
1121
- /** Run auto-update: check each installed package against its configured channel */
1303
+ /**
1304
+ * Run auto-update: check each installed package against its configured
1305
+ * channel, then route ALL packages that have a newer version through ONE
1306
+ * durable engine bulk job (F3 Task 5). The framework (`@camstack/system`) is
1307
+ * auto-detected by the engine and ordered LAST; auto-updates survive a reboot
1308
+ * via the boot reconcile, exactly like a manual "Update all".
1309
+ *
1310
+ * Candidate selection (channel resolution + npm metadata fetch + "is there a
1311
+ * newer version?" comparison) is unchanged from the previous per-item loop;
1312
+ * only the EXECUTION moved from sequential `updatePackage` calls to a single
1313
+ * `startJob`.
1314
+ */
1122
1315
  async runAutoUpdate() {
1123
1316
  this.logger.info('Running auto-update check...');
1124
1317
  const installed = this.listInstalled();
1125
- let updatedCount = 0;
1318
+ const targets = [];
1126
1319
  for (const pkg of installed) {
1127
1320
  try {
1128
1321
  // Determine effective channel for this addon
@@ -1147,7 +1340,7 @@ class AddonPackageService {
1147
1340
  : asString(distTags['latest']);
1148
1341
  if (!targetVersion || targetVersion === pkg.version)
1149
1342
  continue;
1150
- this.logger.info('Auto-updating package', {
1343
+ this.logger.info('Auto-update candidate', {
1151
1344
  meta: {
1152
1345
  name: pkg.name,
1153
1346
  currentVersion: pkg.version,
@@ -1155,18 +1348,34 @@ class AddonPackageService {
1155
1348
  channel: effectiveChannel,
1156
1349
  },
1157
1350
  });
1158
- await this.updatePackage(pkg.name, targetVersion);
1159
- updatedCount++;
1351
+ targets.push({ name: pkg.name, version: targetVersion });
1160
1352
  }
1161
1353
  catch (err) {
1162
- this.logger.warn('Auto-update failed', { meta: { name: pkg.name, error: (0, types_1.errMsg)(err) } });
1354
+ this.logger.warn('Auto-update check failed', {
1355
+ meta: { name: pkg.name, error: (0, types_1.errMsg)(err) },
1356
+ });
1163
1357
  }
1164
1358
  }
1165
- if (updatedCount > 0) {
1166
- this.logger.info('Auto-update complete', { meta: { updatedCount } });
1167
- }
1168
- else {
1359
+ if (targets.length === 0) {
1169
1360
  this.logger.debug('Auto-update: all packages up-to-date');
1361
+ return;
1362
+ }
1363
+ // Execute as ONE durable engine bulk job. The runner singleton is resolved
1364
+ // lazily via a dynamic import (the singleton module transitively imports
1365
+ // SYSTEM_PACKAGE back from this module) to avoid an init-order cycle. The
1366
+ // singleton is initialized at boot, before the scheduler timer fires. The
1367
+ // framework target, if present, is ordered last by the engine. Wrap so a
1368
+ // failure is logged, not thrown out of the timer callback.
1369
+ try {
1370
+ const { getLifecycleRunner } = await Promise.resolve().then(() => __importStar(require('../lifecycle/lifecycle-runner.singleton.js')));
1371
+ const runner = getLifecycleRunner();
1372
+ const { jobId } = await runner.startJob({ kind: 'update', targets, createdBy: 'system' });
1373
+ this.logger.info('Auto-update job started', {
1374
+ meta: { jobId, packageCount: targets.length },
1375
+ });
1376
+ }
1377
+ catch (err) {
1378
+ this.logger.error('Auto-update job failed to start', { meta: { error: (0, types_1.errMsg)(err) } });
1170
1379
  }
1171
1380
  }
1172
1381
  // =========================================================================
@@ -1484,6 +1693,35 @@ class AddonPackageService {
1484
1693
  }
1485
1694
  exports.AddonPackageService = AddonPackageService;
1486
1695
  // ---------------------------------------------------------------------------
1696
+ // Tarball extraction helper (exported for regression tests)
1697
+ // ---------------------------------------------------------------------------
1698
+ /**
1699
+ * Extract a gzipped tarball Buffer into `destDir`, stripping the npm
1700
+ * `package/` top-level prefix so that the extracted `package.json` lands at
1701
+ * `destDir/package.json` (not `destDir/package/package.json`).
1702
+ *
1703
+ * Uses `tar --strip-components=1` which mirrors what `installFromTgz` does
1704
+ * when it reads `path.join(tmpDir, 'package')` after a plain extract and
1705
+ * then uses that sub-directory as the source root.
1706
+ *
1707
+ * Exported so the regression spec in
1708
+ * `src/core/addon/__tests__/extract-tarball-strip.spec.ts` can test the real
1709
+ * implementation without instantiating `AddonPackageService`.
1710
+ */
1711
+ async function extractTgzStripped(tgz, destDir) {
1712
+ fs.mkdirSync(destDir, { recursive: true });
1713
+ const tmpFile = path.join(os.tmpdir(), `camstack-tgz-${(0, node_crypto_1.randomUUID)()}.tgz`);
1714
+ try {
1715
+ fs.writeFileSync(tmpFile, tgz);
1716
+ await execFileAsync('tar', ['--strip-components=1', '-xzf', tmpFile, '-C', destDir], {
1717
+ timeout: 60_000,
1718
+ });
1719
+ }
1720
+ finally {
1721
+ fs.rmSync(tmpFile, { force: true });
1722
+ }
1723
+ }
1724
+ // ---------------------------------------------------------------------------
1487
1725
  // Framework live-update helpers
1488
1726
  // ---------------------------------------------------------------------------
1489
1727
  /**
@@ -1358,13 +1358,27 @@ class AddonRegistryService {
1358
1358
  // the group-runner crashed for real, `$node.disconnected`
1359
1359
  // surfaces it through the health monitor; the operator can
1360
1360
  // always click Cancel on the UI mutation.
1361
- const waits = declared.map((cap) => this.capabilityRegistry.waitForProvider(cap.name, addonId, Number.POSITIVE_INFINITY));
1361
+ // Wait for declared caps to re-register, but BOUNDED — and on timeout
1362
+ // do NOT throw. DEVICE-SCOPED caps (e.g. a camera provider's
1363
+ // `device-provider` / `snapshot-provider`) only ever register once
1364
+ // devices exist; on a hub with none they NEVER come back, so an
1365
+ // infinite wait hangs the whole update — and the lifecycle job that
1366
+ // drives it (apply task stuck at `applying`, framework-last blocked,
1367
+ // the job uncancellable). The on-disk swap + runner restart have
1368
+ // already succeeded here; any caps still pending re-register
1369
+ // asynchronously as their devices come online. We only WARN which
1370
+ // caps were still pending (the comment's original objection was a
1371
+ // *misleading error* on timeout — a warning avoids that while still
1372
+ // letting the update finalize).
1373
+ const REGISTER_WAIT_MS = 120_000;
1374
+ const waits = declared.map((cap) => this.capabilityRegistry.waitForProvider(cap.name, addonId, REGISTER_WAIT_MS));
1362
1375
  const settled = await Promise.all(waits);
1363
1376
  const missing = declared
1364
1377
  .map((cap, i) => (settled[i] == null ? cap.name : null))
1365
1378
  .filter((name) => name !== null);
1366
1379
  if (missing.length > 0) {
1367
- throw new Error(`Addon "${addonId}" restarted but did not re-register capabilities in time: ${missing.join(', ')}`);
1380
+ this.logger.warn(`Addon "${addonId}" restarted; ${missing.length} capability(ies) not yet re-registered ` +
1381
+ `(re-register asynchronously — e.g. device-scoped caps awaiting devices): ${missing.join(', ')}`, { tags: { addonId } });
1368
1382
  }
1369
1383
  }
1370
1384
  // Re-register the addon's custom-action catalog. `$process.restart`
@@ -39,6 +39,36 @@ const os = __importStar(require("node:os"));
39
39
  const types_1 = require("@camstack/types");
40
40
  /** Per-call timeout for `$agent.*` RPC during reconciliation. */
41
41
  const AGENT_RECONCILE_RPC_TIMEOUT_MS = 8_000;
42
+ /**
43
+ * Package name of the system/infrastructure builtins. Addons shipped in
44
+ * `@camstack/system` (filesystem-storage, storage-orchestrator,
45
+ * sqlite-settings, hub-forwarder, metrics-native, console-logging, …) are
46
+ * bootstrap infrastructure that EVERY node — hub and agent alike — runs
47
+ * in-process. They are never `$agent.deploy`-pushed and must NEVER be
48
+ * undeployed by reconciliation: tearing them down on an agent destroys its
49
+ * own storage/settings/metrics/logging and bricks the node. This is the
50
+ * same canonical "core builtin" predicate the hub uses in
51
+ * `AddonRegistryService.buildAddonGroupPlan`.
52
+ */
53
+ /**
54
+ * Agent bootstrap-infrastructure packages — seeded by the agent's own
55
+ * `AGENT_PACKAGES` bootstrap, NEVER hub-managed deploy targets, so the
56
+ * reconcile must never undeploy them (they are absent from the hub's
57
+ * installed-addon set and would otherwise look "stale"):
58
+ * - `@camstack/system` — storage/settings/metrics/logging builtins.
59
+ * - `@camstack/addon-agent-ui` — the agent's own status dashboard
60
+ * (placement: agent-only; the hub never installs it, so it is missing
61
+ * from `hubPlacements`). The hub's deploy planner already SKIPS
62
+ * agent-only addons, so undeploying one is a reconcile asymmetry bug.
63
+ *
64
+ * The hub cannot consult `AddonInstaller.AGENT_PACKAGES` directly: it
65
+ * derives from `@camstack/agent`, which is not installed in a hub image,
66
+ * so that list is empty hub-side. Hence this explicit allowlist.
67
+ */
68
+ const AGENT_BOOTSTRAP_PACKAGES = new Set([
69
+ '@camstack/system',
70
+ '@camstack/addon-agent-ui',
71
+ ]);
42
72
  class AgentRegistryService {
43
73
  eventBus;
44
74
  moleculer;
@@ -229,6 +259,16 @@ class AgentRegistryService {
229
259
  hubPlacements.set(declId, (0, types_1.resolveAddonPlacement)(decl));
230
260
  }
231
261
  const stale = agentAddons.filter((addon) => {
262
+ // Agent bootstrap infrastructure (storage/settings/metrics/logging from
263
+ // `@camstack/system`, plus the agent-only `addon-agent-ui` dashboard)
264
+ // runs on every node as bootstrap infrastructure — never hub-managed
265
+ // deploy targets, must never be undeployed. Without this guard the
266
+ // reconcile tears down the agent's own builtins (they aren't in the
267
+ // hub's installed-addon set, so they look "stale"), which bricks the
268
+ // agent and makes it permanently unroutable. See AGENT_BOOTSTRAP_PACKAGES.
269
+ if (addon.packageName !== undefined && AGENT_BOOTSTRAP_PACKAGES.has(addon.packageName)) {
270
+ return false;
271
+ }
232
272
  const placement = hubPlacements.get(addon.id);
233
273
  // Not installed on the hub → stale.
234
274
  if (placement === undefined)
@@ -281,7 +321,9 @@ class AgentRegistryService {
281
321
  const id = entry.id;
282
322
  if (typeof id !== 'string' || id.length === 0)
283
323
  continue;
284
- result.push({ id });
324
+ // Preserve `packageName` — the reconcile uses it to skip system builtins.
325
+ const packageName = entry.packageName;
326
+ result.push({ id, packageName: typeof packageName === 'string' ? packageName : undefined });
285
327
  }
286
328
  return result;
287
329
  }
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * Process-wide singleton holder for the lifecycle job runner.
4
+ *
5
+ * The runner used to be built per-tRPC-request inside the `addons` cap factory,
6
+ * so there was no stable instance for boot-reconcile (F3 Task 4) or the
7
+ * auto-update scheduler (F3 Task 5) to use. This module builds the runner ONCE
8
+ * at boot (from the `AddonPackageService` + event bus, wired in `manual-boot`)
9
+ * and exposes it to every consumer.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.initLifecycleRunner = initLifecycleRunner;
13
+ exports.getLifecycleRunner = getLifecycleRunner;
14
+ exports.__resetLifecycleRunnerForTests = __resetLifecycleRunnerForTests;
15
+ const lifecycle_job_runner_js_1 = require("../../api/core/lifecycle-job-runner.js");
16
+ let instance = null;
17
+ /**
18
+ * Build the runner once. Idempotent: a second call returns the already-built
19
+ * instance and ignores the new deps (it does NOT rebuild).
20
+ */
21
+ function initLifecycleRunner(deps) {
22
+ if (instance === null) {
23
+ instance = (0, lifecycle_job_runner_js_1.createLifecycleJobRunner)(deps);
24
+ }
25
+ return instance;
26
+ }
27
+ /**
28
+ * Return the process-wide runner. Throws if `initLifecycleRunner` has not been
29
+ * called yet (boot wiring is expected to init it before any consumer runs).
30
+ */
31
+ function getLifecycleRunner() {
32
+ if (instance === null) {
33
+ throw new Error('lifecycle runner not initialized — call initLifecycleRunner(deps) at boot before use');
34
+ }
35
+ return instance;
36
+ }
37
+ /** Test-only: reset the singleton between specs. */
38
+ function __resetLifecycleRunnerForTests() {
39
+ instance = null;
40
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.frameworkNodePaths = frameworkNodePaths;
37
+ const path = __importStar(require("node:path"));
38
+ /**
39
+ * The framework's node_modules path to prepend to NODE_PATH so the CJS hub
40
+ * resolves @camstack/system (+ hoisted server deps) from /data/framework.
41
+ * CJS honors NODE_PATH (unlike ESM `import`, which is why addons use the
42
+ * resolver hook instead). Empty when unset or absent.
43
+ */
44
+ function frameworkNodePaths(frameworkDir, exists) {
45
+ if (!frameworkDir)
46
+ return [];
47
+ const nm = path.join(frameworkDir, 'node_modules');
48
+ return exists(nm) ? [nm] : [];
49
+ }