@guanjinke/artifact-client 1.7.2 → 1.8.0

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.
@@ -42,6 +42,8 @@ const fast_xml_parser_1 = require("fast-xml-parser");
42
42
  const catalog_1 = require("./catalog");
43
43
  const archive_1 = require("./archive");
44
44
  const builder_1 = require("./builder");
45
+ const config_1 = require("./config");
46
+ const plan_1 = require("./plan");
45
47
  const registry_1 = require("./registry");
46
48
  const validate_1 = require("./validate");
47
49
  const process_1 = require("./process");
@@ -55,19 +57,48 @@ const builderOptions = { ignoreAttributes: false, format: true, suppressEmptyNod
55
57
  const windowsPath = (value, platform) => (0, local_path_1.nativeSeparators)(value, platform);
56
58
  /** The separator the recorded entries use on the target platform. */
57
59
  const separatorOf = (platform) => (platform === 'win32' ? '\\' : '/');
58
- /** Modules `update-env` may replace; exported so the web interface lists the same set. */
59
- exports.updatableModules = ['dashboard', 'reporting', 'sheets', 'documents', 'analysis', 'common'];
60
+ /** Every catalog module can be updated remotely; local overrides are excluded per environment. */
61
+ exports.updatableModules = Object.keys(catalog_1.catalog).sort();
62
+ /**
63
+ * Server-root settings the environment owns.
64
+ *
65
+ * `create-env` never copies them out of the archive, because bundled startup
66
+ * overrides must not decide how a generated environment runs. An update replaces
67
+ * the whole `Server` directory, so it carries the deployed files across the
68
+ * replacement instead of deleting them with the old version.
69
+ */
70
+ const serverOwnedFiles = ['appsettings.json', 'appsettings.development.json'];
71
+ /**
72
+ * Package files a staged artifact must provide before it may replace a deployed
73
+ * module. `create-env` checks the same ones while it assembles an environment, so
74
+ * an update cannot install something a fresh environment would have been refused.
75
+ */
76
+ const requiredPackageFiles = {
77
+ server: ['Wyn.Identity.Service.dll'],
78
+ monitoring: ['Wyn.Configuration.dll', 'appsettings.json'],
79
+ };
80
+ /**
81
+ * Package entries an update must not copy over a deployed directory.
82
+ *
83
+ * Besides the environment-owned Server settings, monitoring's own `conf`
84
+ * directory is excluded: its assets are merged into the canonical `conf/` and the
85
+ * directory itself has to become a junction to `conf/` again, not a second copy
86
+ * of the template the Monitor would then resolve configuration from.
87
+ */
88
+ const excludedCopyNames = (mapping) => mapping.role === 'server' ? [...serverOwnedFiles] : mapping.role === 'monitor' ? ['conf'] : [];
89
+ /**
90
+ * The modules a selection may name, from the same table `create-env` and the web
91
+ * interface read.
92
+ *
93
+ * Reading it through the call keeps this module loadable on its own: the policy
94
+ * is only needed while an update records a module it installs for the first time.
95
+ */
96
+ const selectableModules = () => new Set([...new Set(Object.values(plan_1.optionalModulesByEdition).flat())]);
60
97
  const installationDirectory = (root, destination) => path.join(root, ...destination.split('/'));
61
98
  function validateUpdatableModule(module) {
62
- if (exports.updatableModules.includes(module))
99
+ if (Object.prototype.hasOwnProperty.call(catalog_1.catalog, module))
63
100
  return;
64
- if (module === 'server')
65
- throw new Error('Module server cannot be updated: it defines the environment runtime and configuration schema. Recreate the environment with create-env instead.');
66
- if (module === 'monitoring')
67
- throw new Error('Module monitoring cannot be updated: it defines the configuration schema and monitor runtime. Recreate the environment with create-env instead.');
68
- if (module === 'collaboration')
69
- throw new Error('Module collaboration is deployed together with documents by create-env and the Server loads it from its own configuration entry; it is not updated on its own. Update `documents`, or recreate the environment.');
70
- throw new Error(`Unsupported module "${module}": updateable modules are dashboard, reporting, sheets, documents, analysis and common.`);
101
+ throw new Error(`Unsupported module "${module}": supported modules are ${Object.keys(catalog_1.catalog).sort().join(', ')}.`);
71
102
  }
72
103
  /** `all` is the only allowed pseudo-module: every configured module from remote. */
73
104
  const allModules = 'all';
@@ -214,6 +245,12 @@ function stringList(raw) {
214
245
  throw new Error('Invalid environment configuration.');
215
246
  return list;
216
247
  }
248
+ /**
249
+ * One recorded module list of the manifest, or undefined when the environment
250
+ * records none. A field of the wrong shape is treated as absent rather than as an
251
+ * error: the update only ever adds to these lists.
252
+ */
253
+ const stringListOrUndefined = (raw) => Array.isArray(raw) && raw.every(item => typeof item === 'string') ? [...raw] : undefined;
217
254
  /** Replace or append one `<sys:string>` path list, matching by destination or assembly name. */
218
255
  function rewritePathList(values, entries, platform) {
219
256
  const strings = values.map(value => value.trim());
@@ -247,7 +284,10 @@ function rewritePathList(values, entries, platform) {
247
284
  * is returned. Dashboard stays the first `Plugins` entry; every other entry keeps
248
285
  * its order. A worker service keeps all of its other fields, including
249
286
  * `Disabled`; a configuration without that node is updated through
250
- * `<PluginWorkers>` alone.
287
+ * `<PluginWorkers>` alone. The Server service is kept in step the same way a
288
+ * worker is, and a native service the environment does not declare yet gets the
289
+ * empty node `create-env` writes, so a freshly installed collaboration service is
290
+ * actually loaded.
251
291
  */
252
292
  function patchEnvironmentConfiguration(xml, entries, platform = process.platform) {
253
293
  if (typeof xml !== 'string' || /<!DOCTYPE|<!ENTITY/i.test(xml) || fast_xml_parser_1.XMLValidator.validate(xml) !== true)
@@ -256,7 +296,7 @@ function patchEnvironmentConfiguration(xml, entries, platform = process.platform
256
296
  const server = document?.SystemConfig?.Services?.Server;
257
297
  if (!server || typeof server !== 'object' || Array.isArray(server))
258
298
  throw new Error('Environment configuration has no Server service.');
259
- const pluginEntries = entries.filter(entry => entry.role !== 'worker');
299
+ const pluginEntries = entries.filter(entry => entry.role === undefined || entry.role === 'plugin');
260
300
  if (server.Plugins !== undefined || pluginEntries.length) {
261
301
  const plugins = server.Plugins === undefined || server.Plugins === '' ? {} : server.Plugins;
262
302
  if (typeof plugins !== 'object' || Array.isArray(plugins))
@@ -279,18 +319,29 @@ function patchEnvironmentConfiguration(xml, entries, platform = process.platform
279
319
  throw new Error('Invalid environment configuration.');
280
320
  pluginWorkers['sys:string'] = rewritePathList(stringList(pluginWorkers['sys:string']), workers, platform);
281
321
  server.PluginWorkers = pluginWorkers;
282
- const services = document?.SystemConfig?.Services;
283
- for (const entry of workers) {
284
- if (!entry.service || !services || typeof services !== 'object' || Array.isArray(services))
285
- continue;
286
- const service = services[entry.service];
287
- if (!service || typeof service !== 'object' || Array.isArray(service))
322
+ }
323
+ // Every entry a service starts from an assembly: the embedded workers and the
324
+ // Server itself. Each service keeps all of its other fields, including
325
+ // `Disabled` and the Server's `Urls` and storage settings.
326
+ const hosted = entries.filter(entry => (entry.role === 'worker' || entry.role === 'server') && entry.service);
327
+ // A native out-of-process service carries no assembly and is declared empty,
328
+ // exactly like the product's own `conf/Wyn.conf`: the Server loads it itself.
329
+ const declared = entries.filter(entry => entry.role === 'service' && entry.service);
330
+ const services = document?.SystemConfig?.Services;
331
+ if ((hosted.length || declared.length) && services && typeof services === 'object' && !Array.isArray(services)) {
332
+ const nodes = services;
333
+ for (const entry of declared)
334
+ if (nodes[entry.service] === undefined)
335
+ nodes[entry.service] = {};
336
+ for (const entry of hosted) {
337
+ const node = nodes[entry.service];
338
+ if (!node || typeof node !== 'object' || Array.isArray(node))
288
339
  continue;
289
- const node = service;
290
- // The worker is loaded from the path list above and uses its own directory
340
+ const fields = node;
341
+ // The service is loaded from the path list above and uses its own directory
291
342
  // as its base directory: keep the service in step without touching Disabled.
292
- node.WorkingDirectory = windowsPath((0, local_path_1.directoryOf)(entry.path, platform), platform);
293
- node.Assembly = (0, local_path_1.baseNameOf)(entry.path);
343
+ fields.WorkingDirectory = windowsPath((0, local_path_1.directoryOf)(entry.path, platform), platform);
344
+ fields.Assembly = (0, local_path_1.baseNameOf)(entry.path);
294
345
  }
295
346
  }
296
347
  const output = new fast_xml_parser_1.XMLBuilder(builderOptions).build(document);
@@ -422,6 +473,8 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
422
473
  const source = path.join(expanded, mapping.source);
423
474
  if (!(await fs.lstat(source)).isDirectory())
424
475
  throw new Error('Required source directory missing.');
476
+ if (mapping.role === 'service' && mapping.assemblies.length === 0)
477
+ continue;
425
478
  const present = [];
426
479
  for (const name of mapping.assemblies) {
427
480
  try {
@@ -436,6 +489,12 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
436
489
  if (present.length !== 1)
437
490
  throw new Error(`Expected one runtime assembly in ${mapping.destination || 'root'}.`);
438
491
  assemblies.set(mapping.destination, present[0]);
492
+ // A plugin is loaded into the Server's own runtime; every other managed
493
+ // entry point is started by the Monitor and needs its own runtimeconfig for
494
+ // the .NET host to resolve the framework at all — exactly what create-env
495
+ // requires before it deploys one.
496
+ if (mapping.role !== 'plugin')
497
+ await (0, builder_1.regularFile)(path.join(source, present[0].replace(/\.dll$/, '.runtimeconfig.json')));
439
498
  }
440
499
  onProgress?.(`${label}: Archive and layout checks complete`);
441
500
  return { ...artifact, sha256, expanded, assemblies };
@@ -444,6 +503,96 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
444
503
  throw new Error(`Module update failed for ${artifact.plugin}/${artifact.version}/${artifact.name} (download, archive or layout): ${error.message}`);
445
504
  }
446
505
  }
506
+ /**
507
+ * The configuration schema this environment already runs.
508
+ *
509
+ * `create-env` recorded the schema it detected from the Monitor template in the
510
+ * manifest; an environment whose manifest predates the field falls back to the
511
+ * namespace its live `conf/Wyn.conf` declares. A Server or Monitor update needs
512
+ * this to check that the new package ships the matching XSD assets.
513
+ */
514
+ function environmentSchema(manifest, configuration) {
515
+ if (manifest.schema === 'V91' || manifest.schema === 'V100')
516
+ return manifest.schema;
517
+ try {
518
+ return (0, config_1.detectConfigurationSchema)(configuration);
519
+ }
520
+ catch {
521
+ return undefined;
522
+ }
523
+ }
524
+ /**
525
+ * Check a staged Server or Monitor package against the deployed environment.
526
+ *
527
+ * `create-env` verifies the same things while it assembles: the Server's
528
+ * identity service, the Monitor's configuration assembly and settings, and the
529
+ * `xsd/<schema>/Wyn.conf.xsd` both packages ship for the configuration schema the
530
+ * environment declares. A Server package may legitimately omit its XSD — Wyn's
531
+ * configuration reader falls back to ordinary XML deserialization — which
532
+ * `create-env` records as a warning rather than rejecting, so the update keeps
533
+ * that policy. Returns the warning text, or undefined when the package is
534
+ * complete.
535
+ */
536
+ async function verifyStagedPackage(module, staged, schema) {
537
+ const label = `${staged.plugin}/${staged.version}/${staged.name}`;
538
+ for (const name of requiredPackageFiles[module] ?? []) {
539
+ try {
540
+ await (0, builder_1.regularFile)(path.join(staged.expanded, name));
541
+ }
542
+ catch {
543
+ throw new Error(`Module update failed for ${label}: the package is missing ${name}.`);
544
+ }
545
+ }
546
+ const relative = `xsd/${schema}/Wyn.conf.xsd`;
547
+ try {
548
+ await (0, builder_1.regularFile)(path.join(staged.expanded, relative));
549
+ return undefined;
550
+ }
551
+ catch (error) {
552
+ if (module === 'server' && error.code === 'ENOENT') {
553
+ return `Server package ${label} is missing ${relative}; Server will skip XSD validation. XML reader compatibility and runtime startup have not been verified.`;
554
+ }
555
+ throw new Error(`Configuration schema validation failed for ${label}: ${module === 'server' ? 'Server' : 'Monitor'} package is missing ${relative}.`);
556
+ }
557
+ }
558
+ /** Copy a tree over an existing one, replacing files instead of refusing a collision. */
559
+ async function overlayTree(source, destination) {
560
+ await fs.mkdir(destination, { recursive: true });
561
+ for (const entry of await fs.readdir(source, { withFileTypes: true })) {
562
+ const from = path.join(source, entry.name);
563
+ const to = path.join(destination, entry.name);
564
+ if (entry.isDirectory())
565
+ await overlayTree(from, to);
566
+ else if (entry.isFile())
567
+ await fs.copyFile(from, to);
568
+ else
569
+ throw new Error('Unexpected link in extracted archive.');
570
+ }
571
+ }
572
+ /**
573
+ * Merge a monitoring package's `conf` assets into the canonical configuration
574
+ * directory.
575
+ *
576
+ * `create-env` moves everything but the `Wyn.conf` template out of the Monitor
577
+ * package and leaves the environment's own `conf/Wyn.conf` as the single editable
578
+ * configuration, so the update refreshes the package's assets in place and never
579
+ * touches that file. An asset the new package no longer ships stays where it is:
580
+ * the directory also holds whatever the environment itself added.
581
+ */
582
+ async function mergeConfigurationAssets(source, destination) {
583
+ for (const entry of await fs.readdir(source, { withFileTypes: true })) {
584
+ if (entry.name.toLowerCase() === 'wyn.conf')
585
+ continue;
586
+ const from = path.join(source, entry.name);
587
+ const to = path.join(destination, entry.name);
588
+ if (entry.isDirectory())
589
+ await overlayTree(from, to);
590
+ else if (entry.isFile())
591
+ await fs.copyFile(from, to);
592
+ else
593
+ throw new Error('Unexpected link in extracted archive.');
594
+ }
595
+ }
447
596
  /** Mappings of one module that a local source may override. */
448
597
  const localMappings = (module) => catalog_1.catalog[module].mappings.filter(mapping => mapping.role === 'plugin' || mapping.role === 'worker');
449
598
  const expectedAssemblies = (module) => [...new Set(localMappings(module).flatMap(mapping => mapping.assemblies))];
@@ -468,6 +617,11 @@ function mappingForAssembly(module, assembly) {
468
617
  */
469
618
  async function resolveLocalSource(entry, module, assembly, input) {
470
619
  const mappings = localMappings(module);
620
+ // The Server, Monitor and collaboration deployments are whole services, not a
621
+ // plugin the Server loads: a developer build has no place to point the
622
+ // configuration at, so they are refreshed from their artifact only.
623
+ if (!mappings.length)
624
+ throw new Error(`Module ${module} is deployed from its artifact only: it has no plugin or worker assembly a local build could replace. Update it from a remote artifact, or recreate the environment with create-env.`);
471
625
  const source = path.resolve(input);
472
626
  const stat = await lstatOrUndefined(source);
473
627
  if (!stat)
@@ -588,7 +742,7 @@ class EnvironmentUpdater {
588
742
  skipped.push({ module, reason: 'local' });
589
743
  const selected = configured.filter(module => !localModules.has(module));
590
744
  if (!selected.length)
591
- throw new Error(`Module spec all matched no updatable modules: ${configured.length ? configured.map(module => `${module} has a local source`).join('; ') : 'no configured module is updateable (server and monitoring are never updated)'}.`);
745
+ throw new Error(`Module spec all matched no updatable modules: ${configured.length ? configured.map(module => `${module} has a local source`).join('; ') : 'this environment has no installed module to refresh'}.`);
592
746
  parsed = selected.map(module => ({ module }));
593
747
  onProgress?.(`Updating all configured modules from remote: ${selected.join(', ')}${skipped.length ? ` (skipping local: ${skipped.map(item => item.module).join(', ')})` : ''}`);
594
748
  }
@@ -631,17 +785,32 @@ class EnvironmentUpdater {
631
785
  // the update. Planning touches nothing and runs before any download or write,
632
786
  // so an incomplete installation fails loudly with the path it expected and
633
787
  // leaves the environment exactly as it was.
788
+ //
789
+ // The assets live at `Server/wwwroot/3DScene`, so refreshing the server
790
+ // module removes the whole tree they are in. Plan every packaged file as
791
+ // missing in that case: comparing against the content that is about to be
792
+ // deleted would report every file identical and lay nothing out at all.
793
+ const refreshesServer = remote.some(spec => spec.module === 'server');
634
794
  const resourceSource = (0, package_resource_1.bundledResourceDirectory)(package_resource_1.sceneResourceName, this.bundledResources);
635
795
  const resourceDestination = path.join(entry.root, ...package_resource_1.sceneResourceDestination.split('/'));
636
796
  onProgress?.(`Checking bundled 3D scene resources in ${package_resource_1.sceneResourceDestination}`);
637
- const resourcePlan = await (0, package_resource_1.planBundledResource)(resourceSource, resourceDestination);
797
+ const resourcePlan = await (0, package_resource_1.planBundledResource)(resourceSource, resourceDestination, refreshesServer);
638
798
  const resourceWrites = resourcePlan.added + resourcePlan.updated;
639
799
  const resource = { name: package_resource_1.sceneResourceName, source: resourceSource, destination: package_resource_1.sceneResourceDestination, files: resourcePlan.files.length, added: resourcePlan.added, updated: resourcePlan.updated };
640
800
  const resourceSummary = `${resourcePlan.files.length - resourceWrites} identical, ${resourcePlan.added} missing, ${resourcePlan.updated} different`;
641
801
  onProgress?.(`Bundled 3D scene resources in ${package_resource_1.sceneResourceDestination}: ${dryRun ? 'dry run; ' : ''}${resourceSummary}`);
642
802
  const downloads = [...(manifest.downloads ?? [])];
643
- const selectedModules = manifest.selectedModules === undefined ? undefined : [...manifest.selectedModules];
803
+ const selectedModules = stringListOrUndefined(manifest.selectedModules);
804
+ const autoAddedModules = stringListOrUndefined(manifest.autoAddedModules);
644
805
  const moduleSources = { ...(manifest.moduleSources ?? {}) };
806
+ // What a fresh environment would have selected for itself. An update records
807
+ // a first install the way `create-env` does: a selectable module joins the
808
+ // selection, a core component or an automatic dependency is added instead.
809
+ const selectable = selectableModules();
810
+ // The manifest records the .NET requirements read from each artifact's
811
+ // runtimeconfig files, so an update replaces the entries of the modules it
812
+ // refreshes and leaves every other module's alone.
813
+ let requirements = Array.isArray(manifest.requirements) ? manifest.requirements.filter((line) => typeof line === 'string') : [];
645
814
  const workspace = !dryRun && (remote.length > 0 || resourceWrites > 0) ? await fs.mkdtemp(path.join(path.dirname(entry.root), '.afm-env-')) : undefined;
646
815
  const warnings = [];
647
816
  try {
@@ -658,6 +827,23 @@ class EnvironmentUpdater {
658
827
  staged.set(spec.module, await stageRemoteArtifact(client, resolved.get(spec.module), workspace, onProgress));
659
828
  }
660
829
  const configured = await fs.readFile(path.join(entry.root, 'conf', 'Wyn.conf'), 'utf8');
830
+ // A Server or Monitor package is only usable when it still serves the
831
+ // configuration schema this environment runs, so both are checked against
832
+ // the deployed schema before anything is planned around them.
833
+ const schema = remote.some(spec => spec.module === 'server' || spec.module === 'monitoring')
834
+ ? environmentSchema(manifest, configured)
835
+ : undefined;
836
+ if (remote.some(spec => spec.module === 'server' || spec.module === 'monitoring') && !schema) {
837
+ throw new Error(`Module update for ${remote.filter(spec => spec.module === 'server' || spec.module === 'monitoring').map(spec => spec.module).join(' and ')} needs the configuration schema of ${entry.name}, which neither its manifest nor its conf/Wyn.conf records. Recreate the environment with create-env.`);
838
+ }
839
+ for (const spec of remote) {
840
+ const stagedArtifact = staged.get(spec.module);
841
+ if (!stagedArtifact || !schema || !(spec.module in requiredPackageFiles))
842
+ continue;
843
+ const warning = await verifyStagedPackage(spec.module, stagedArtifact, schema);
844
+ if (warning)
845
+ warnings.push(warning);
846
+ }
661
847
  const plans = [];
662
848
  // A module may carry several local specs: plan it once, for all its targets.
663
849
  for (const module of [...new Set(parsed.map(spec => spec.module))]) {
@@ -685,7 +871,7 @@ class EnvironmentUpdater {
685
871
  });
686
872
  plans.push({
687
873
  module, previous, next: { local: override.map(target => target.path).join(', ') }, local: override,
688
- entries, copies: [], removals, copiedDirectories: [], removedDirectories,
874
+ entries, copies: [], removals, preserved: [], copiedDirectories: [], removedDirectories,
689
875
  });
690
876
  continue;
691
877
  }
@@ -693,30 +879,59 @@ class EnvironmentUpdater {
693
879
  const stagedArtifact = staged.get(module);
694
880
  const copies = [];
695
881
  const removals = [];
882
+ const preserved = [];
696
883
  const copiedDirectories = [];
697
884
  const removedDirectories = [];
698
885
  const entries = [];
886
+ let configurationAssets;
887
+ let configurationLink;
699
888
  for (const mapping of remoteArtifact.mappings) {
700
889
  const destination = installationDirectory(entry.root, mapping.destination);
701
890
  if (await exists(destination)) {
702
891
  removals.push(destination);
703
892
  removedDirectories.push(mapping.destination);
704
893
  }
894
+ // The Server settings the environment owns are never taken from the
895
+ // archive and must survive the replacement, so carry each one that is
896
+ // actually deployed across the directory swap.
897
+ if (stagedArtifact && mapping.role === 'server' && workspace) {
898
+ for (const name of excludedCopyNames(mapping)) {
899
+ if (await exists(path.join(destination, name)))
900
+ preserved.push({ directory: destination, name, source: path.join(workspace, 'preserved', module, name) });
901
+ }
902
+ }
705
903
  if (stagedArtifact)
706
- copies.push({ source: path.join(stagedArtifact.expanded, mapping.source), destination });
904
+ copies.push({ source: path.join(stagedArtifact.expanded, mapping.source), destination, exclude: excludedCopyNames(mapping) });
707
905
  copiedDirectories.push(mapping.destination);
906
+ // A native out-of-process service has no assembly and is declared as an
907
+ // empty node: the Server loads it from that entry and supplies the rest.
908
+ if (mapping.role === 'service') {
909
+ if (mapping.service)
910
+ entries.push({ destination: mapping.destination, assemblies: [], path: mapping.destination, role: 'service', service: mapping.service });
911
+ continue;
912
+ }
708
913
  const assembly = stagedArtifact?.assemblies.get(mapping.destination) ?? knownAssembly(configured, mapping, this.platform);
709
914
  // Plugins stay relative to the environment root; a worker entry does too,
710
915
  // and its service keeps using the deployed destination as working directory.
711
- if (mapping.role === 'plugin' || mapping.role === 'worker') {
916
+ // The Server service the Monitor starts is kept in step the same way.
917
+ if (mapping.role === 'plugin' || mapping.role === 'worker' || mapping.role === 'server') {
712
918
  const entry = { destination: mapping.destination, assemblies: mapping.assemblies, path: path.posix.join(mapping.destination, assembly) };
713
- if (mapping.role === 'worker') {
714
- entry.role = 'worker';
919
+ if (mapping.role === 'worker' || mapping.role === 'server') {
920
+ entry.role = mapping.role;
715
921
  if (mapping.service)
716
922
  entry.service = mapping.service;
717
923
  }
718
924
  entries.push(entry);
719
925
  }
926
+ // The Monitor resolves its configuration from its own working directory,
927
+ // so its package assets belong in the canonical `conf` and the directory
928
+ // itself has to point back at it once the Monitor tree is replaced.
929
+ if (mapping.role === 'monitor' && stagedArtifact) {
930
+ const assets = path.join(stagedArtifact.expanded, 'conf');
931
+ if ((await fs.lstat(assets).catch(() => undefined))?.isDirectory())
932
+ configurationAssets = { source: assets, destination: path.join(entry.root, 'conf') };
933
+ configurationLink = path.join(destination, 'conf');
934
+ }
720
935
  }
721
936
  if (module === 'dashboard' && stagedArtifact) {
722
937
  const contents = path.join(stagedArtifact.expanded, 'Web Contents');
@@ -726,13 +941,14 @@ class EnvironmentUpdater {
726
941
  removals.push(destination);
727
942
  removedDirectories.push('Web Contents');
728
943
  }
729
- copies.push({ source: contents, destination });
944
+ copies.push({ source: contents, destination, exclude: [] });
730
945
  copiedDirectories.push('Web Contents');
731
946
  }
732
947
  }
733
948
  plans.push({
734
949
  module, previous, next: { version: remoteArtifact.version }, artifact: remoteArtifact, staged: stagedArtifact,
735
- entries, copies, removals, copiedDirectories, removedDirectories,
950
+ entries, copies, removals, preserved, ...(configurationAssets ? { configurationAssets } : {}), ...(configurationLink ? { configurationLink } : {}),
951
+ copiedDirectories, removedDirectories,
736
952
  });
737
953
  }
738
954
  const changes = plans.map(plan => ({ module: plan.module, previous: plan.previous, next: plan.next, copiedDirectories: plan.copiedDirectories, removedDirectories: plan.removedDirectories }));
@@ -757,20 +973,46 @@ class EnvironmentUpdater {
757
973
  nextDownloads.push(download);
758
974
  else
759
975
  nextDownloads[downloadIndex] = download;
976
+ // The requirements the old artifact needed are gone with it: re-read
977
+ // the ones the new package actually declares, per deployed mapping.
978
+ requirements = requirements.filter(line => !line.endsWith(` (${plan.module})`));
979
+ for (const mapping of plan.artifact.mappings) {
980
+ if (mapping.role === 'service')
981
+ continue;
982
+ for (const framework of await (0, builder_1.runtimeRequirements)(path.join(plan.staged.expanded, mapping.source))) {
983
+ requirements.push(`${framework.name} ${framework.version} (${plan.module})`);
984
+ }
985
+ }
760
986
  }
761
987
  delete moduleSources[plan.module];
762
988
  }
763
989
  if (plan.local)
764
990
  moduleSources[plan.module] = manifestSource(plan.module, plan.local, moduleSources[plan.module]);
765
- if (!installed.has(plan.module) && plan.module !== 'common' && selectedModules)
766
- selectedModules.push(plan.module);
991
+ // First installing a module records it the way `create-env` would: a
992
+ // selectable one joins the selection, a core component or an automatic
993
+ // dependency joins the automatically added list instead.
994
+ if (!installed.has(plan.module)) {
995
+ if (selectable.has(plan.module)) {
996
+ if (selectedModules)
997
+ selectedModules.push(plan.module);
998
+ }
999
+ else if (autoAddedModules)
1000
+ autoAddedModules.push(plan.module);
1001
+ }
767
1002
  }
768
1003
  if (selectedModules) {
769
1004
  const unique = [...new Set(selectedModules)].sort();
770
1005
  selectedModules.length = 0;
771
1006
  selectedModules.push(...unique);
772
1007
  }
773
- const nextManifest = { ...manifest, artifacts: nextArtifacts, downloads: nextDownloads };
1008
+ if (autoAddedModules) {
1009
+ const unique = [...new Set(autoAddedModules)].sort();
1010
+ autoAddedModules.length = 0;
1011
+ autoAddedModules.push(...unique);
1012
+ }
1013
+ const nextManifest = { ...manifest, artifacts: nextArtifacts, downloads: nextDownloads, requirements: [...new Set(requirements)] };
1014
+ if (autoAddedModules)
1015
+ nextManifest.autoAddedModules = autoAddedModules;
774
1016
  if (Object.keys(moduleSources).length)
775
1017
  nextManifest.moduleSources = moduleSources;
776
1018
  else
@@ -789,18 +1031,49 @@ class EnvironmentUpdater {
789
1031
  else
790
1032
  resources[recorded] = { ...resources[recorded], ...record };
791
1033
  nextManifest.resources = resources;
792
- const modules = [...new Set([...entry.modules, ...plans.filter(plan => !installed.has(plan.module)).map(plan => plan.module)])].sort();
1034
+ const modules = [...new Set([...entry.modules, ...plans.filter(plan => !installed.has(plan.module) && selectable.has(plan.module)).map(plan => plan.module)])].sort();
793
1035
  const localModules = Object.keys(moduleSources).sort();
794
1036
  if (state === 'running')
795
1037
  warnings.push(`${entry.name} is running; restart it to load the updated modules.`);
796
1038
  for (const item of skipped)
797
1039
  warnings.push(`${item.module} has a local source and was not updated from artifacts.`);
1040
+ // The Server and Monitor packages each ship the XSD assets for a
1041
+ // configuration schema, so replacing one of them alone leaves the
1042
+ // environment mixing two package generations for the same configuration.
1043
+ const runtime = plans.filter(plan => plan.module === 'server' || plan.module === 'monitoring').map(plan => plan.module).sort();
1044
+ if (runtime.length === 1)
1045
+ warnings.push(`Only ${runtime[0]} was refreshed; refresh ${runtime[0] === 'server' ? 'monitoring' : 'server'} in the same call to keep both packages on one configuration schema.`);
1046
+ if (parsed.some(spec => spec.module === 'documents') && !parsed.some(spec => spec.module === 'collaboration')) {
1047
+ warnings.push('documents was refreshed without collaboration; the smart documents plugin may expect the deployed collaboration service version, so refresh collaboration too.');
1048
+ }
798
1049
  if (!dryRun) {
799
1050
  for (const plan of plans) {
1051
+ // Take the environment-owned settings aside first, and drop the Monitor
1052
+ // configuration junction before the directory holding it: a recursive
1053
+ // delete must never reach the canonical `conf` the junction points at.
1054
+ for (const file of plan.preserved) {
1055
+ await fs.mkdir(path.dirname(file.source), { recursive: true });
1056
+ await fs.copyFile(path.join(file.directory, file.name), file.source);
1057
+ }
1058
+ if (plan.configurationLink) {
1059
+ const existing = await fs.lstat(plan.configurationLink).catch(() => undefined);
1060
+ if (existing?.isSymbolicLink())
1061
+ await fs.unlink(plan.configurationLink);
1062
+ else
1063
+ await fs.rm(plan.configurationLink, { recursive: true, force: true });
1064
+ }
800
1065
  for (const removal of plan.removals)
801
1066
  await fs.rm(removal, { recursive: true, force: true });
802
1067
  for (const copy of plan.copies)
803
- await (0, builder_1.copyTree)(copy.source, copy.destination);
1068
+ await (0, builder_1.copyTree)(copy.source, copy.destination, copy.exclude);
1069
+ for (const file of plan.preserved)
1070
+ await fs.copyFile(file.source, path.join(file.directory, file.name));
1071
+ if (plan.configurationAssets) {
1072
+ onProgress?.(`Refreshing Monitor configuration assets in ${path.relative(entry.root, plan.configurationAssets.destination)}`);
1073
+ await mergeConfigurationAssets(plan.configurationAssets.source, plan.configurationAssets.destination);
1074
+ }
1075
+ if (plan.configurationLink)
1076
+ await fs.symlink(path.join(entry.root, 'conf'), plan.configurationLink, 'junction');
804
1077
  }
805
1078
  // The staged resources are written with the module changes, before the
806
1079
  // manifest that records them; a failure rolls the layout back.