@guanjinke/artifact-client 1.7.1 → 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.
Files changed (50) hide show
  1. package/README.md +8 -1
  2. package/dist/cli/environment.d.ts +10 -1
  3. package/dist/cli/environment.d.ts.map +1 -1
  4. package/dist/cli/environment.js +24 -0
  5. package/dist/cli/environment.js.map +1 -1
  6. package/dist/cli/setup.js +9 -4
  7. package/dist/cli/setup.js.map +1 -1
  8. package/dist/environment/builder.d.ts +4 -0
  9. package/dist/environment/builder.d.ts.map +1 -1
  10. package/dist/environment/builder.js +16 -1
  11. package/dist/environment/builder.js.map +1 -1
  12. package/dist/environment/package-resource.d.ts +95 -0
  13. package/dist/environment/package-resource.d.ts.map +1 -0
  14. package/dist/environment/package-resource.js +323 -0
  15. package/dist/environment/package-resource.js.map +1 -0
  16. package/dist/environment/resource.d.ts +36 -0
  17. package/dist/environment/resource.d.ts.map +1 -0
  18. package/dist/environment/resource.js +101 -0
  19. package/dist/environment/resource.js.map +1 -0
  20. package/dist/environment/types.d.ts +7 -0
  21. package/dist/environment/types.d.ts.map +1 -1
  22. package/dist/environment/update.d.ts +34 -4
  23. package/dist/environment/update.d.ts.map +1 -1
  24. package/dist/environment/update.js +364 -44
  25. package/dist/environment/update.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/web/static/app.js +1 -1
  29. package/package.json +3 -2
  30. package/resources/3DScene/capeHill.hdr +0 -0
  31. package/resources/3DScene/defaultGround.jpg +0 -0
  32. package/resources/3DScene/environmentSpecular.env +0 -0
  33. package/resources/3DScene/flare.png +0 -0
  34. package/resources/3DScene/geometry1.png +0 -0
  35. package/resources/3DScene/geometry10.png +0 -0
  36. package/resources/3DScene/geometry11.png +0 -0
  37. package/resources/3DScene/geometry12.png +0 -0
  38. package/resources/3DScene/geometry13.png +0 -0
  39. package/resources/3DScene/geometry2.png +0 -0
  40. package/resources/3DScene/geometry3.png +0 -0
  41. package/resources/3DScene/geometry4.png +0 -0
  42. package/resources/3DScene/geometry5.png +0 -0
  43. package/resources/3DScene/geometry6.png +0 -0
  44. package/resources/3DScene/geometry7.png +0 -0
  45. package/resources/3DScene/geometry8.png +0 -0
  46. package/resources/3DScene/geometry9.png +0 -0
  47. package/resources/3DScene/lilienstein.hdr +0 -0
  48. package/resources/3DScene/sky.hdr +0 -0
  49. package/resources/3DScene/studio.env +0 -0
  50. package/resources/3DScene/wastelandCloudsPureSky.hdr +0 -0
@@ -42,10 +42,13 @@ 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");
48
50
  const posix_process_1 = require("./posix-process");
51
+ const package_resource_1 = require("./package-resource");
49
52
  const atomic_1 = require("../utils/atomic");
50
53
  const local_path_1 = require("../utils/local-path");
51
54
  /** Same parser options as the creation path, so a rewrite round-trips identically. */
@@ -54,19 +57,48 @@ const builderOptions = { ignoreAttributes: false, format: true, suppressEmptyNod
54
57
  const windowsPath = (value, platform) => (0, local_path_1.nativeSeparators)(value, platform);
55
58
  /** The separator the recorded entries use on the target platform. */
56
59
  const separatorOf = (platform) => (platform === 'win32' ? '\\' : '/');
57
- /** Modules `update-env` may replace; exported so the web interface lists the same set. */
58
- 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())]);
59
97
  const installationDirectory = (root, destination) => path.join(root, ...destination.split('/'));
60
98
  function validateUpdatableModule(module) {
61
- if (exports.updatableModules.includes(module))
99
+ if (Object.prototype.hasOwnProperty.call(catalog_1.catalog, module))
62
100
  return;
63
- if (module === 'server')
64
- throw new Error('Module server cannot be updated: it defines the environment runtime and configuration schema. Recreate the environment with create-env instead.');
65
- if (module === 'monitoring')
66
- throw new Error('Module monitoring cannot be updated: it defines the configuration schema and monitor runtime. Recreate the environment with create-env instead.');
67
- if (module === 'collaboration')
68
- 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.');
69
- 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(', ')}.`);
70
102
  }
71
103
  /** `all` is the only allowed pseudo-module: every configured module from remote. */
72
104
  const allModules = 'all';
@@ -213,6 +245,12 @@ function stringList(raw) {
213
245
  throw new Error('Invalid environment configuration.');
214
246
  return list;
215
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;
216
254
  /** Replace or append one `<sys:string>` path list, matching by destination or assembly name. */
217
255
  function rewritePathList(values, entries, platform) {
218
256
  const strings = values.map(value => value.trim());
@@ -246,7 +284,10 @@ function rewritePathList(values, entries, platform) {
246
284
  * is returned. Dashboard stays the first `Plugins` entry; every other entry keeps
247
285
  * its order. A worker service keeps all of its other fields, including
248
286
  * `Disabled`; a configuration without that node is updated through
249
- * `<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.
250
291
  */
251
292
  function patchEnvironmentConfiguration(xml, entries, platform = process.platform) {
252
293
  if (typeof xml !== 'string' || /<!DOCTYPE|<!ENTITY/i.test(xml) || fast_xml_parser_1.XMLValidator.validate(xml) !== true)
@@ -255,7 +296,7 @@ function patchEnvironmentConfiguration(xml, entries, platform = process.platform
255
296
  const server = document?.SystemConfig?.Services?.Server;
256
297
  if (!server || typeof server !== 'object' || Array.isArray(server))
257
298
  throw new Error('Environment configuration has no Server service.');
258
- const pluginEntries = entries.filter(entry => entry.role !== 'worker');
299
+ const pluginEntries = entries.filter(entry => entry.role === undefined || entry.role === 'plugin');
259
300
  if (server.Plugins !== undefined || pluginEntries.length) {
260
301
  const plugins = server.Plugins === undefined || server.Plugins === '' ? {} : server.Plugins;
261
302
  if (typeof plugins !== 'object' || Array.isArray(plugins))
@@ -278,18 +319,29 @@ function patchEnvironmentConfiguration(xml, entries, platform = process.platform
278
319
  throw new Error('Invalid environment configuration.');
279
320
  pluginWorkers['sys:string'] = rewritePathList(stringList(pluginWorkers['sys:string']), workers, platform);
280
321
  server.PluginWorkers = pluginWorkers;
281
- const services = document?.SystemConfig?.Services;
282
- for (const entry of workers) {
283
- if (!entry.service || !services || typeof services !== 'object' || Array.isArray(services))
284
- continue;
285
- const service = services[entry.service];
286
- 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))
287
339
  continue;
288
- const node = service;
289
- // 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
290
342
  // as its base directory: keep the service in step without touching Disabled.
291
- node.WorkingDirectory = windowsPath((0, local_path_1.directoryOf)(entry.path, platform), platform);
292
- 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);
293
345
  }
294
346
  }
295
347
  const output = new fast_xml_parser_1.XMLBuilder(builderOptions).build(document);
@@ -330,6 +382,10 @@ async function readManifest(root) {
330
382
  }
331
383
  if (manifest.selectedModules !== undefined && (!Array.isArray(manifest.selectedModules) || manifest.selectedModules.some(module => typeof module !== 'string')))
332
384
  throw new Error('Environment manifest is unreadable.');
385
+ // Only the entry name is required: the resource list is written by this package
386
+ // for the package's own trees, and unknown extra fields are preserved as-is.
387
+ if (manifest.resources !== undefined && (!Array.isArray(manifest.resources) || manifest.resources.some(resource => !resource || typeof resource !== 'object' || Array.isArray(resource) || typeof resource.name !== 'string')))
388
+ throw new Error('Environment manifest is unreadable.');
333
389
  if (manifest.moduleSources !== undefined && (!manifest.moduleSources || typeof manifest.moduleSources !== 'object' || Array.isArray(manifest.moduleSources)))
334
390
  throw new Error('Environment manifest is unreadable.');
335
391
  for (const source of Object.values(manifest.moduleSources ?? {})) {
@@ -417,6 +473,8 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
417
473
  const source = path.join(expanded, mapping.source);
418
474
  if (!(await fs.lstat(source)).isDirectory())
419
475
  throw new Error('Required source directory missing.');
476
+ if (mapping.role === 'service' && mapping.assemblies.length === 0)
477
+ continue;
420
478
  const present = [];
421
479
  for (const name of mapping.assemblies) {
422
480
  try {
@@ -431,6 +489,12 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
431
489
  if (present.length !== 1)
432
490
  throw new Error(`Expected one runtime assembly in ${mapping.destination || 'root'}.`);
433
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')));
434
498
  }
435
499
  onProgress?.(`${label}: Archive and layout checks complete`);
436
500
  return { ...artifact, sha256, expanded, assemblies };
@@ -439,6 +503,96 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
439
503
  throw new Error(`Module update failed for ${artifact.plugin}/${artifact.version}/${artifact.name} (download, archive or layout): ${error.message}`);
440
504
  }
441
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
+ }
442
596
  /** Mappings of one module that a local source may override. */
443
597
  const localMappings = (module) => catalog_1.catalog[module].mappings.filter(mapping => mapping.role === 'plugin' || mapping.role === 'worker');
444
598
  const expectedAssemblies = (module) => [...new Set(localMappings(module).flatMap(mapping => mapping.assemblies))];
@@ -463,6 +617,11 @@ function mappingForAssembly(module, assembly) {
463
617
  */
464
618
  async function resolveLocalSource(entry, module, assembly, input) {
465
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.`);
466
625
  const source = path.resolve(input);
467
626
  const stat = await lstatOrUndefined(source);
468
627
  if (!stat)
@@ -546,12 +705,14 @@ class EnvironmentUpdater {
546
705
  registry;
547
706
  processes;
548
707
  platform;
708
+ bundledResources;
549
709
  onProgress;
550
710
  constructor(dependencies = {}) {
551
711
  this.dependencies = dependencies;
552
712
  this.registry = dependencies.registry ?? new registry_1.EnvironmentRegistry();
553
713
  this.processes = dependencies.processes ?? (process.platform === 'win32' ? new process_1.WindowsProcessBackend() : new posix_process_1.PosixProcessBackend());
554
714
  this.platform = dependencies.platform ?? process.platform;
715
+ this.bundledResources = dependencies.bundledResourcesDirectory ?? (0, package_resource_1.bundledResourcesDirectory)();
555
716
  this.onProgress = dependencies.onProgress;
556
717
  }
557
718
  async update(name, specs, clientFactory, options = {}) {
@@ -559,8 +720,9 @@ class EnvironmentUpdater {
559
720
  const onProgress = options.onProgress ?? this.onProgress;
560
721
  onProgress?.('Validating module specs');
561
722
  let parsed = normalizeModuleSpecs(specs);
562
- if (!parsed.length)
563
- throw new Error('At least one module spec is required: use --modules all, dashboard, reporting or dashboard=D:\\path\\Wyn.Dashboards.Plugin.dll.');
723
+ // No module spec is a valid request: it (re)lays out the bundled resources
724
+ // alone, which is how an environment created before they were bundled gets
725
+ // them without touching a single module.
564
726
  const entry = await this.registry.get(name);
565
727
  onProgress?.(`Validating environment ${entry.name} at ${entry.root}`);
566
728
  await (0, validate_1.validateEnvironmentRoot)(entry, this.registry.directory);
@@ -580,7 +742,7 @@ class EnvironmentUpdater {
580
742
  skipped.push({ module, reason: 'local' });
581
743
  const selected = configured.filter(module => !localModules.has(module));
582
744
  if (!selected.length)
583
- 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'}.`);
584
746
  parsed = selected.map(module => ({ module }));
585
747
  onProgress?.(`Updating all configured modules from remote: ${selected.join(', ')}${skipped.length ? ` (skipping local: ${skipped.map(item => item.module).join(', ')})` : ''}`);
586
748
  }
@@ -617,10 +779,39 @@ class EnvironmentUpdater {
617
779
  throw new Error(`Cannot update modules from a remote source while ${entry.name} is ${state}: deployed files may be in use. Run \`afm env stop ${entry.name}\` first.`);
618
780
  if (!remote.length && state !== 'ready' && state !== 'stopped' && state !== 'running')
619
781
  throw new Error(`Cannot update modules while ${entry.name} is ${state}: the runtime identity must be verified before its files are changed. Run \`afm env stop ${entry.name}\` first.`);
782
+ // The packaged 3D scene assets belong to every environment, not only to one
783
+ // that create-env assembled: the plan below finds the files an environment is
784
+ // still missing (or that differ) and the apply writes them with the rest of
785
+ // the update. Planning touches nothing and runs before any download or write,
786
+ // so an incomplete installation fails loudly with the path it expected and
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');
794
+ const resourceSource = (0, package_resource_1.bundledResourceDirectory)(package_resource_1.sceneResourceName, this.bundledResources);
795
+ const resourceDestination = path.join(entry.root, ...package_resource_1.sceneResourceDestination.split('/'));
796
+ onProgress?.(`Checking bundled 3D scene resources in ${package_resource_1.sceneResourceDestination}`);
797
+ const resourcePlan = await (0, package_resource_1.planBundledResource)(resourceSource, resourceDestination, refreshesServer);
798
+ const resourceWrites = resourcePlan.added + resourcePlan.updated;
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 };
800
+ const resourceSummary = `${resourcePlan.files.length - resourceWrites} identical, ${resourcePlan.added} missing, ${resourcePlan.updated} different`;
801
+ onProgress?.(`Bundled 3D scene resources in ${package_resource_1.sceneResourceDestination}: ${dryRun ? 'dry run; ' : ''}${resourceSummary}`);
620
802
  const downloads = [...(manifest.downloads ?? [])];
621
- const selectedModules = manifest.selectedModules === undefined ? undefined : [...manifest.selectedModules];
803
+ const selectedModules = stringListOrUndefined(manifest.selectedModules);
804
+ const autoAddedModules = stringListOrUndefined(manifest.autoAddedModules);
622
805
  const moduleSources = { ...(manifest.moduleSources ?? {}) };
623
- const workspace = remote.length && !dryRun ? await fs.mkdtemp(path.join(path.dirname(entry.root), '.afm-env-')) : undefined;
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') : [];
814
+ const workspace = !dryRun && (remote.length > 0 || resourceWrites > 0) ? await fs.mkdtemp(path.join(path.dirname(entry.root), '.afm-env-')) : undefined;
624
815
  const warnings = [];
625
816
  try {
626
817
  const resolved = new Map();
@@ -636,6 +827,23 @@ class EnvironmentUpdater {
636
827
  staged.set(spec.module, await stageRemoteArtifact(client, resolved.get(spec.module), workspace, onProgress));
637
828
  }
638
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
+ }
639
847
  const plans = [];
640
848
  // A module may carry several local specs: plan it once, for all its targets.
641
849
  for (const module of [...new Set(parsed.map(spec => spec.module))]) {
@@ -663,7 +871,7 @@ class EnvironmentUpdater {
663
871
  });
664
872
  plans.push({
665
873
  module, previous, next: { local: override.map(target => target.path).join(', ') }, local: override,
666
- entries, copies: [], removals, copiedDirectories: [], removedDirectories,
874
+ entries, copies: [], removals, preserved: [], copiedDirectories: [], removedDirectories,
667
875
  });
668
876
  continue;
669
877
  }
@@ -671,30 +879,59 @@ class EnvironmentUpdater {
671
879
  const stagedArtifact = staged.get(module);
672
880
  const copies = [];
673
881
  const removals = [];
882
+ const preserved = [];
674
883
  const copiedDirectories = [];
675
884
  const removedDirectories = [];
676
885
  const entries = [];
886
+ let configurationAssets;
887
+ let configurationLink;
677
888
  for (const mapping of remoteArtifact.mappings) {
678
889
  const destination = installationDirectory(entry.root, mapping.destination);
679
890
  if (await exists(destination)) {
680
891
  removals.push(destination);
681
892
  removedDirectories.push(mapping.destination);
682
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
+ }
683
903
  if (stagedArtifact)
684
- copies.push({ source: path.join(stagedArtifact.expanded, mapping.source), destination });
904
+ copies.push({ source: path.join(stagedArtifact.expanded, mapping.source), destination, exclude: excludedCopyNames(mapping) });
685
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
+ }
686
913
  const assembly = stagedArtifact?.assemblies.get(mapping.destination) ?? knownAssembly(configured, mapping, this.platform);
687
914
  // Plugins stay relative to the environment root; a worker entry does too,
688
915
  // and its service keeps using the deployed destination as working directory.
689
- 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') {
690
918
  const entry = { destination: mapping.destination, assemblies: mapping.assemblies, path: path.posix.join(mapping.destination, assembly) };
691
- if (mapping.role === 'worker') {
692
- entry.role = 'worker';
919
+ if (mapping.role === 'worker' || mapping.role === 'server') {
920
+ entry.role = mapping.role;
693
921
  if (mapping.service)
694
922
  entry.service = mapping.service;
695
923
  }
696
924
  entries.push(entry);
697
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
+ }
698
935
  }
699
936
  if (module === 'dashboard' && stagedArtifact) {
700
937
  const contents = path.join(stagedArtifact.expanded, 'Web Contents');
@@ -704,13 +941,14 @@ class EnvironmentUpdater {
704
941
  removals.push(destination);
705
942
  removedDirectories.push('Web Contents');
706
943
  }
707
- copies.push({ source: contents, destination });
944
+ copies.push({ source: contents, destination, exclude: [] });
708
945
  copiedDirectories.push('Web Contents');
709
946
  }
710
947
  }
711
948
  plans.push({
712
949
  module, previous, next: { version: remoteArtifact.version }, artifact: remoteArtifact, staged: stagedArtifact,
713
- entries, copies, removals, copiedDirectories, removedDirectories,
950
+ entries, copies, removals, preserved, ...(configurationAssets ? { configurationAssets } : {}), ...(configurationLink ? { configurationLink } : {}),
951
+ copiedDirectories, removedDirectories,
714
952
  });
715
953
  }
716
954
  const changes = plans.map(plan => ({ module: plan.module, previous: plan.previous, next: plan.next, copiedDirectories: plan.copiedDirectories, removedDirectories: plan.removedDirectories }));
@@ -735,41 +973,123 @@ class EnvironmentUpdater {
735
973
  nextDownloads.push(download);
736
974
  else
737
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
+ }
738
986
  }
739
987
  delete moduleSources[plan.module];
740
988
  }
741
989
  if (plan.local)
742
990
  moduleSources[plan.module] = manifestSource(plan.module, plan.local, moduleSources[plan.module]);
743
- if (!installed.has(plan.module) && plan.module !== 'common' && selectedModules)
744
- 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
+ }
745
1002
  }
746
1003
  if (selectedModules) {
747
1004
  const unique = [...new Set(selectedModules)].sort();
748
1005
  selectedModules.length = 0;
749
1006
  selectedModules.push(...unique);
750
1007
  }
751
- 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;
752
1016
  if (Object.keys(moduleSources).length)
753
1017
  nextManifest.moduleSources = moduleSources;
754
1018
  else
755
1019
  delete nextManifest.moduleSources;
756
1020
  if (selectedModules)
757
1021
  nextManifest.selectedModules = selectedModules;
758
- const modules = [...new Set([...entry.modules, ...plans.filter(plan => !installed.has(plan.module)).map(plan => plan.module)])].sort();
1022
+ // The resource is recorded in place, in exactly the shape create-env writes:
1023
+ // a second update finds the entry and refreshes it instead of adding a
1024
+ // duplicate. The result carries the added/updated counts; the manifest keeps
1025
+ // the durable four-field description.
1026
+ const resources = [...(manifest.resources ?? [])];
1027
+ const recorded = resources.findIndex(candidate => candidate.name === package_resource_1.sceneResourceName);
1028
+ const record = { name: package_resource_1.sceneResourceName, source: resource.source, destination: resource.destination, files: resource.files };
1029
+ if (recorded < 0)
1030
+ resources.push(record);
1031
+ else
1032
+ resources[recorded] = { ...resources[recorded], ...record };
1033
+ nextManifest.resources = resources;
1034
+ const modules = [...new Set([...entry.modules, ...plans.filter(plan => !installed.has(plan.module) && selectable.has(plan.module)).map(plan => plan.module)])].sort();
759
1035
  const localModules = Object.keys(moduleSources).sort();
760
1036
  if (state === 'running')
761
1037
  warnings.push(`${entry.name} is running; restart it to load the updated modules.`);
762
1038
  for (const item of skipped)
763
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
+ }
764
1049
  if (!dryRun) {
765
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
+ }
766
1065
  for (const removal of plan.removals)
767
1066
  await fs.rm(removal, { recursive: true, force: true });
768
1067
  for (const copy of plan.copies)
769
- 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');
1077
+ }
1078
+ // The staged resources are written with the module changes, before the
1079
+ // manifest that records them; a failure rolls the layout back.
1080
+ if (resourceWrites) {
1081
+ if (!workspace)
1082
+ throw new Error('Missing staging workspace for the bundled resource layout.');
1083
+ onProgress?.(`Laying out bundled 3D scene resources into ${package_resource_1.sceneResourceDestination}`);
1084
+ const applied = await (0, package_resource_1.applyBundledResource)(resourceSource, resourceDestination, resourcePlan, path.join(workspace, 'replaced-resources'));
1085
+ if (applied.added.length !== resourcePlan.added || applied.updated.length !== resourcePlan.updated)
1086
+ throw new Error('Bundled resource layout did not match its plan.');
770
1087
  }
771
- onProgress?.('Writing configuration, manifest and registry record');
772
- await writeAtomic(path.join(entry.root, 'conf', 'Wyn.conf'), configuration);
1088
+ // A resource-only update (no module spec) leaves the Server configuration
1089
+ // exactly as it is: the assets need no plugin, worker or service entry.
1090
+ onProgress?.(plans.length ? 'Writing configuration, manifest and registry record' : 'Writing manifest and registry record');
1091
+ if (plans.length)
1092
+ await writeAtomic(path.join(entry.root, 'conf', 'Wyn.conf'), configuration);
773
1093
  await writeJsonAtomic(path.join(entry.root, 'environment.lock.json'), nextManifest);
774
1094
  await this.registry.update(entries => {
775
1095
  const index = entries.findIndex(candidate => candidate.id === entry.id);
@@ -784,10 +1104,10 @@ class EnvironmentUpdater {
784
1104
  delete entries[index].localModules;
785
1105
  });
786
1106
  }
787
- const restartRequired = !dryRun && changes.length > 0;
1107
+ const restartRequired = !dryRun && (changes.length > 0 || resourceWrites > 0);
788
1108
  if (restartRequired)
789
- onProgress?.(`${entry.name}: modules updated; restart the environment to load the change (no service was started).`);
790
- return { name: entry.name, root: entry.root, dryRun, changes, skipped, restartRequired, warnings, localTargets: plans.flatMap(plan => plan.local ?? []) };
1109
+ onProgress?.(`${entry.name}: ${changes.length ? 'modules updated' : 'bundled resources laid out'}; restart the environment to load the change (no service was started).`);
1110
+ return { name: entry.name, root: entry.root, dryRun, changes, skipped, restartRequired, warnings, localTargets: plans.flatMap(plan => plan.local ?? []), resource };
791
1111
  }
792
1112
  finally {
793
1113
  if (workspace)