@guanjinke/artifact-client 1.7.2 → 1.9.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 (54) hide show
  1. package/README.md +3 -6
  2. package/dist/cli/environment.d.ts +1 -10
  3. package/dist/cli/environment.d.ts.map +1 -1
  4. package/dist/cli/environment.js +0 -24
  5. package/dist/cli/environment.js.map +1 -1
  6. package/dist/cli/setup.js +5 -8
  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 +31 -20
  11. package/dist/environment/builder.js.map +1 -1
  12. package/dist/environment/catalog.d.ts +8 -0
  13. package/dist/environment/catalog.d.ts.map +1 -1
  14. package/dist/environment/catalog.js +25 -0
  15. package/dist/environment/catalog.js.map +1 -1
  16. package/dist/environment/package-resource.d.ts +7 -1
  17. package/dist/environment/package-resource.d.ts.map +1 -1
  18. package/dist/environment/package-resource.js +8 -2
  19. package/dist/environment/package-resource.js.map +1 -1
  20. package/dist/environment/plan.d.ts +26 -5
  21. package/dist/environment/plan.d.ts.map +1 -1
  22. package/dist/environment/plan.js +63 -11
  23. package/dist/environment/plan.js.map +1 -1
  24. package/dist/environment/types.d.ts +17 -9
  25. package/dist/environment/types.d.ts.map +1 -1
  26. package/dist/environment/update.d.ts +9 -29
  27. package/dist/environment/update.d.ts.map +1 -1
  28. package/dist/environment/update.js +375 -95
  29. package/dist/environment/update.js.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/web/static/app.js +1 -1
  33. package/package.json +2 -3
  34. package/resources/3DScene/capeHill.hdr +0 -0
  35. package/resources/3DScene/defaultGround.jpg +0 -0
  36. package/resources/3DScene/environmentSpecular.env +0 -0
  37. package/resources/3DScene/flare.png +0 -0
  38. package/resources/3DScene/geometry1.png +0 -0
  39. package/resources/3DScene/geometry10.png +0 -0
  40. package/resources/3DScene/geometry11.png +0 -0
  41. package/resources/3DScene/geometry12.png +0 -0
  42. package/resources/3DScene/geometry13.png +0 -0
  43. package/resources/3DScene/geometry2.png +0 -0
  44. package/resources/3DScene/geometry3.png +0 -0
  45. package/resources/3DScene/geometry4.png +0 -0
  46. package/resources/3DScene/geometry5.png +0 -0
  47. package/resources/3DScene/geometry6.png +0 -0
  48. package/resources/3DScene/geometry7.png +0 -0
  49. package/resources/3DScene/geometry8.png +0 -0
  50. package/resources/3DScene/geometry9.png +0 -0
  51. package/resources/3DScene/lilienstein.hdr +0 -0
  52. package/resources/3DScene/sky.hdr +0 -0
  53. package/resources/3DScene/studio.env +0 -0
  54. package/resources/3DScene/wastelandCloudsPureSky.hdr +0 -0
@@ -42,11 +42,12 @@ 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");
49
- const package_resource_1 = require("./package-resource");
50
51
  const atomic_1 = require("../utils/atomic");
51
52
  const local_path_1 = require("../utils/local-path");
52
53
  /** Same parser options as the creation path, so a rewrite round-trips identically. */
@@ -55,19 +56,54 @@ const builderOptions = { ignoreAttributes: false, format: true, suppressEmptyNod
55
56
  const windowsPath = (value, platform) => (0, local_path_1.nativeSeparators)(value, platform);
56
57
  /** The separator the recorded entries use on the target platform. */
57
58
  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'];
59
+ /** Every catalog module can be updated remotely; local overrides are excluded per environment. */
60
+ exports.updatableModules = Object.keys(catalog_1.catalog).sort();
61
+ /**
62
+ * Server-root settings the environment owns.
63
+ *
64
+ * `create-env` never copies them out of the archive, because bundled startup
65
+ * overrides must not decide how a generated environment runs. An update replaces
66
+ * the whole `Server` directory, so it carries the deployed files across the
67
+ * replacement instead of deleting them with the old version.
68
+ */
69
+ const serverOwnedFiles = ['appsettings.json', 'appsettings.development.json'];
70
+ /**
71
+ * Package files a staged artifact must provide before it may replace a deployed
72
+ * module. `create-env` checks the same ones while it assembles an environment, so
73
+ * an update cannot install something a fresh environment would have been refused.
74
+ */
75
+ const requiredPackageFiles = {
76
+ server: ['Wyn.Identity.Service.dll'],
77
+ monitoring: ['Wyn.Configuration.dll', 'appsettings.json'],
78
+ };
79
+ /**
80
+ * Package entries an update must not copy over a deployed directory.
81
+ *
82
+ * Besides the environment-owned Server settings, monitoring's own `conf`
83
+ * directory is excluded: its assets are merged into the canonical `conf/` and the
84
+ * directory itself has to become a junction to `conf/` again, not a second copy
85
+ * of the template the Monitor would then resolve configuration from.
86
+ */
87
+ const excludedCopyNames = (mapping) => mapping.role === 'server' ? [...serverOwnedFiles] : mapping.role === 'monitor' ? ['conf'] : [];
88
+ /**
89
+ * The modules a selection may name, from the same table `create-env` and the web
90
+ * interface read.
91
+ *
92
+ * Reading it through the call keeps this module loadable on its own: the policy
93
+ * is only needed while an update records a module it installs for the first time.
94
+ */
95
+ const selectableModules = () => new Set([...new Set(Object.values(plan_1.optionalModulesByEdition).flat())]);
60
96
  const installationDirectory = (root, destination) => path.join(root, ...destination.split('/'));
97
+ /**
98
+ * The edition an environment deploys. The registry records a free string and
99
+ * only `en` is special everywhere else in this module, so anything else counts
100
+ * as `cn`, exactly like the edition gate below does.
101
+ */
102
+ const editionOf = (entry) => entry.edition === 'en' ? 'en' : 'cn';
61
103
  function validateUpdatableModule(module) {
62
- if (exports.updatableModules.includes(module))
104
+ if (Object.prototype.hasOwnProperty.call(catalog_1.catalog, module))
63
105
  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.`);
106
+ throw new Error(`Unsupported module "${module}": supported modules are ${Object.keys(catalog_1.catalog).sort().join(', ')}.`);
71
107
  }
72
108
  /** `all` is the only allowed pseudo-module: every configured module from remote. */
73
109
  const allModules = 'all';
@@ -214,6 +250,12 @@ function stringList(raw) {
214
250
  throw new Error('Invalid environment configuration.');
215
251
  return list;
216
252
  }
253
+ /**
254
+ * One recorded module list of the manifest, or undefined when the environment
255
+ * records none. A field of the wrong shape is treated as absent rather than as an
256
+ * error: the update only ever adds to these lists.
257
+ */
258
+ const stringListOrUndefined = (raw) => Array.isArray(raw) && raw.every(item => typeof item === 'string') ? [...raw] : undefined;
217
259
  /** Replace or append one `<sys:string>` path list, matching by destination or assembly name. */
218
260
  function rewritePathList(values, entries, platform) {
219
261
  const strings = values.map(value => value.trim());
@@ -247,7 +289,10 @@ function rewritePathList(values, entries, platform) {
247
289
  * is returned. Dashboard stays the first `Plugins` entry; every other entry keeps
248
290
  * its order. A worker service keeps all of its other fields, including
249
291
  * `Disabled`; a configuration without that node is updated through
250
- * `<PluginWorkers>` alone.
292
+ * `<PluginWorkers>` alone. The Server service is kept in step the same way a
293
+ * worker is, and a native service the environment does not declare yet gets the
294
+ * empty node `create-env` writes, so a freshly installed collaboration service is
295
+ * actually loaded.
251
296
  */
252
297
  function patchEnvironmentConfiguration(xml, entries, platform = process.platform) {
253
298
  if (typeof xml !== 'string' || /<!DOCTYPE|<!ENTITY/i.test(xml) || fast_xml_parser_1.XMLValidator.validate(xml) !== true)
@@ -256,7 +301,7 @@ function patchEnvironmentConfiguration(xml, entries, platform = process.platform
256
301
  const server = document?.SystemConfig?.Services?.Server;
257
302
  if (!server || typeof server !== 'object' || Array.isArray(server))
258
303
  throw new Error('Environment configuration has no Server service.');
259
- const pluginEntries = entries.filter(entry => entry.role !== 'worker');
304
+ const pluginEntries = entries.filter(entry => entry.role === undefined || entry.role === 'plugin');
260
305
  if (server.Plugins !== undefined || pluginEntries.length) {
261
306
  const plugins = server.Plugins === undefined || server.Plugins === '' ? {} : server.Plugins;
262
307
  if (typeof plugins !== 'object' || Array.isArray(plugins))
@@ -279,18 +324,29 @@ function patchEnvironmentConfiguration(xml, entries, platform = process.platform
279
324
  throw new Error('Invalid environment configuration.');
280
325
  pluginWorkers['sys:string'] = rewritePathList(stringList(pluginWorkers['sys:string']), workers, platform);
281
326
  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))
327
+ }
328
+ // Every entry a service starts from an assembly: the embedded workers and the
329
+ // Server itself. Each service keeps all of its other fields, including
330
+ // `Disabled` and the Server's `Urls` and storage settings.
331
+ const hosted = entries.filter(entry => (entry.role === 'worker' || entry.role === 'server') && entry.service);
332
+ // A native out-of-process service carries no assembly and is declared empty,
333
+ // exactly like the product's own `conf/Wyn.conf`: the Server loads it itself.
334
+ const declared = entries.filter(entry => entry.role === 'service' && entry.service);
335
+ const services = document?.SystemConfig?.Services;
336
+ if ((hosted.length || declared.length) && services && typeof services === 'object' && !Array.isArray(services)) {
337
+ const nodes = services;
338
+ for (const entry of declared)
339
+ if (nodes[entry.service] === undefined)
340
+ nodes[entry.service] = {};
341
+ for (const entry of hosted) {
342
+ const node = nodes[entry.service];
343
+ if (!node || typeof node !== 'object' || Array.isArray(node))
288
344
  continue;
289
- const node = service;
290
- // The worker is loaded from the path list above and uses its own directory
345
+ const fields = node;
346
+ // The service is loaded from the path list above and uses its own directory
291
347
  // 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);
348
+ fields.WorkingDirectory = windowsPath((0, local_path_1.directoryOf)(entry.path, platform), platform);
349
+ fields.Assembly = (0, local_path_1.baseNameOf)(entry.path);
294
350
  }
295
351
  }
296
352
  const output = new fast_xml_parser_1.XMLBuilder(builderOptions).build(document);
@@ -331,10 +387,9 @@ async function readManifest(root) {
331
387
  }
332
388
  if (manifest.selectedModules !== undefined && (!Array.isArray(manifest.selectedModules) || manifest.selectedModules.some(module => typeof module !== 'string')))
333
389
  throw new Error('Environment manifest is unreadable.');
334
- // Only the entry name is required: the resource list is written by this package
335
- // for the package's own trees, and unknown extra fields are preserved as-is.
336
- if (manifest.resources !== undefined && (!Array.isArray(manifest.resources) || manifest.resources.some(resource => !resource || typeof resource !== 'object' || Array.isArray(resource) || typeof resource.name !== 'string')))
337
- throw new Error('Environment manifest is unreadable.');
390
+ // A stale `resources` key written by an older version is ignored here and
391
+ // dropped when the manifest is rewritten: the key belongs to the removed
392
+ // packaged-resource feature, not to the environment's configuration.
338
393
  if (manifest.moduleSources !== undefined && (!manifest.moduleSources || typeof manifest.moduleSources !== 'object' || Array.isArray(manifest.moduleSources)))
339
394
  throw new Error('Environment manifest is unreadable.');
340
395
  for (const source of Object.values(manifest.moduleSources ?? {})) {
@@ -403,7 +458,7 @@ async function resolveRemoteArtifact(client, entry, spec) {
403
458
  return { module, plugin: source.plugin, version, name, path: remotePath, mappings: source.mappings.map(mapping => ({ ...mapping, assemblies: [...mapping.assemblies] })) };
404
459
  }
405
460
  /** Download, hash, extract and verify one resolved artifact inside the staging workspace. */
406
- async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
461
+ async function stageRemoteArtifact(client, artifact, edition, workspace, onProgress) {
407
462
  const label = `[${artifact.module}] ${artifact.plugin}/${artifact.version}/${artifact.name}`;
408
463
  const download = path.join(workspace, 'download', artifact.module);
409
464
  const expanded = path.join(workspace, `expanded-${artifact.module}`);
@@ -418,10 +473,18 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
418
473
  onProgress?.(`${label}: Extracting and validating ZIP`);
419
474
  await (0, archive_1.extractArchive)(archive, expanded);
420
475
  const assemblies = new Map();
421
- for (const mapping of artifact.mappings) {
476
+ // A mapping of the other edition is never required: its source directory
477
+ // does not even have to be in the archive.
478
+ for (const mapping of (0, catalog_1.editionMappings)(artifact.mappings, edition)) {
422
479
  const source = path.join(expanded, mapping.source);
423
480
  if (!(await fs.lstat(source)).isDirectory())
424
481
  throw new Error('Required source directory missing.');
482
+ // A `resource` mapping is a static tree with no assembly to verify and no
483
+ // runtimeconfig to read; the Server only serves it.
484
+ if (mapping.role === 'resource')
485
+ continue;
486
+ if (mapping.role === 'service' && mapping.assemblies.length === 0)
487
+ continue;
425
488
  const present = [];
426
489
  for (const name of mapping.assemblies) {
427
490
  try {
@@ -436,6 +499,12 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
436
499
  if (present.length !== 1)
437
500
  throw new Error(`Expected one runtime assembly in ${mapping.destination || 'root'}.`);
438
501
  assemblies.set(mapping.destination, present[0]);
502
+ // A plugin is loaded into the Server's own runtime; every other managed
503
+ // entry point is started by the Monitor and needs its own runtimeconfig for
504
+ // the .NET host to resolve the framework at all — exactly what create-env
505
+ // requires before it deploys one.
506
+ if (mapping.role !== 'plugin')
507
+ await (0, builder_1.regularFile)(path.join(source, present[0].replace(/\.dll$/, '.runtimeconfig.json')));
439
508
  }
440
509
  onProgress?.(`${label}: Archive and layout checks complete`);
441
510
  return { ...artifact, sha256, expanded, assemblies };
@@ -444,6 +513,96 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
444
513
  throw new Error(`Module update failed for ${artifact.plugin}/${artifact.version}/${artifact.name} (download, archive or layout): ${error.message}`);
445
514
  }
446
515
  }
516
+ /**
517
+ * The configuration schema this environment already runs.
518
+ *
519
+ * `create-env` recorded the schema it detected from the Monitor template in the
520
+ * manifest; an environment whose manifest predates the field falls back to the
521
+ * namespace its live `conf/Wyn.conf` declares. A Server or Monitor update needs
522
+ * this to check that the new package ships the matching XSD assets.
523
+ */
524
+ function environmentSchema(manifest, configuration) {
525
+ if (manifest.schema === 'V91' || manifest.schema === 'V100')
526
+ return manifest.schema;
527
+ try {
528
+ return (0, config_1.detectConfigurationSchema)(configuration);
529
+ }
530
+ catch {
531
+ return undefined;
532
+ }
533
+ }
534
+ /**
535
+ * Check a staged Server or Monitor package against the deployed environment.
536
+ *
537
+ * `create-env` verifies the same things while it assembles: the Server's
538
+ * identity service, the Monitor's configuration assembly and settings, and the
539
+ * `xsd/<schema>/Wyn.conf.xsd` both packages ship for the configuration schema the
540
+ * environment declares. A Server package may legitimately omit its XSD — Wyn's
541
+ * configuration reader falls back to ordinary XML deserialization — which
542
+ * `create-env` records as a warning rather than rejecting, so the update keeps
543
+ * that policy. Returns the warning text, or undefined when the package is
544
+ * complete.
545
+ */
546
+ async function verifyStagedPackage(module, staged, schema) {
547
+ const label = `${staged.plugin}/${staged.version}/${staged.name}`;
548
+ for (const name of requiredPackageFiles[module] ?? []) {
549
+ try {
550
+ await (0, builder_1.regularFile)(path.join(staged.expanded, name));
551
+ }
552
+ catch {
553
+ throw new Error(`Module update failed for ${label}: the package is missing ${name}.`);
554
+ }
555
+ }
556
+ const relative = `xsd/${schema}/Wyn.conf.xsd`;
557
+ try {
558
+ await (0, builder_1.regularFile)(path.join(staged.expanded, relative));
559
+ return undefined;
560
+ }
561
+ catch (error) {
562
+ if (module === 'server' && error.code === 'ENOENT') {
563
+ return `Server package ${label} is missing ${relative}; Server will skip XSD validation. XML reader compatibility and runtime startup have not been verified.`;
564
+ }
565
+ throw new Error(`Configuration schema validation failed for ${label}: ${module === 'server' ? 'Server' : 'Monitor'} package is missing ${relative}.`);
566
+ }
567
+ }
568
+ /** Copy a tree over an existing one, replacing files instead of refusing a collision. */
569
+ async function overlayTree(source, destination) {
570
+ await fs.mkdir(destination, { recursive: true });
571
+ for (const entry of await fs.readdir(source, { withFileTypes: true })) {
572
+ const from = path.join(source, entry.name);
573
+ const to = path.join(destination, entry.name);
574
+ if (entry.isDirectory())
575
+ await overlayTree(from, to);
576
+ else if (entry.isFile())
577
+ await fs.copyFile(from, to);
578
+ else
579
+ throw new Error('Unexpected link in extracted archive.');
580
+ }
581
+ }
582
+ /**
583
+ * Merge a monitoring package's `conf` assets into the canonical configuration
584
+ * directory.
585
+ *
586
+ * `create-env` moves everything but the `Wyn.conf` template out of the Monitor
587
+ * package and leaves the environment's own `conf/Wyn.conf` as the single editable
588
+ * configuration, so the update refreshes the package's assets in place and never
589
+ * touches that file. An asset the new package no longer ships stays where it is:
590
+ * the directory also holds whatever the environment itself added.
591
+ */
592
+ async function mergeConfigurationAssets(source, destination) {
593
+ for (const entry of await fs.readdir(source, { withFileTypes: true })) {
594
+ if (entry.name.toLowerCase() === 'wyn.conf')
595
+ continue;
596
+ const from = path.join(source, entry.name);
597
+ const to = path.join(destination, entry.name);
598
+ if (entry.isDirectory())
599
+ await overlayTree(from, to);
600
+ else if (entry.isFile())
601
+ await fs.copyFile(from, to);
602
+ else
603
+ throw new Error('Unexpected link in extracted archive.');
604
+ }
605
+ }
447
606
  /** Mappings of one module that a local source may override. */
448
607
  const localMappings = (module) => catalog_1.catalog[module].mappings.filter(mapping => mapping.role === 'plugin' || mapping.role === 'worker');
449
608
  const expectedAssemblies = (module) => [...new Set(localMappings(module).flatMap(mapping => mapping.assemblies))];
@@ -467,7 +626,18 @@ function mappingForAssembly(module, assembly) {
467
626
  * explicitly, for a build whose file name matches no assembly at all.
468
627
  */
469
628
  async function resolveLocalSource(entry, module, assembly, input) {
629
+ // The built-in resource module is never a developer-build target: it ships a
630
+ // static asset tree that no local DLL could stand in for, so a local source is
631
+ // refused on its own terms before any path is even looked at. Remote specs
632
+ // (latest, pinned and `all`) keep working.
633
+ if (plan_1.trailingModules.includes(module))
634
+ throw new Error(`Module ${module} is built in and has no local source: it ships the 3D scene and map assets and is always deployed from its published artifact. Refresh it from the cloud (\`${module}\`, \`${module}:<version>\` or \`all\`).`);
470
635
  const mappings = localMappings(module);
636
+ // The Server, Monitor and collaboration deployments are whole services, not a
637
+ // plugin the Server loads: a developer build has no place to point the
638
+ // configuration at, so they are refreshed from their artifact only.
639
+ if (!mappings.length)
640
+ 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
641
  const source = path.resolve(input);
472
642
  const stat = await lstatOrUndefined(source);
473
643
  if (!stat)
@@ -551,14 +721,12 @@ class EnvironmentUpdater {
551
721
  registry;
552
722
  processes;
553
723
  platform;
554
- bundledResources;
555
724
  onProgress;
556
725
  constructor(dependencies = {}) {
557
726
  this.dependencies = dependencies;
558
727
  this.registry = dependencies.registry ?? new registry_1.EnvironmentRegistry();
559
728
  this.processes = dependencies.processes ?? (process.platform === 'win32' ? new process_1.WindowsProcessBackend() : new posix_process_1.PosixProcessBackend());
560
729
  this.platform = dependencies.platform ?? process.platform;
561
- this.bundledResources = dependencies.bundledResourcesDirectory ?? (0, package_resource_1.bundledResourcesDirectory)();
562
730
  this.onProgress = dependencies.onProgress;
563
731
  }
564
732
  async update(name, specs, clientFactory, options = {}) {
@@ -566,9 +734,8 @@ class EnvironmentUpdater {
566
734
  const onProgress = options.onProgress ?? this.onProgress;
567
735
  onProgress?.('Validating module specs');
568
736
  let parsed = normalizeModuleSpecs(specs);
569
- // No module spec is a valid request: it (re)lays out the bundled resources
570
- // alone, which is how an environment created before they were bundled gets
571
- // them without touching a single module.
737
+ if (!parsed.length)
738
+ throw new Error('At least one module spec is required: pass `all` or one or more module names (module, module:<version> or module=<local path>).');
572
739
  const entry = await this.registry.get(name);
573
740
  onProgress?.(`Validating environment ${entry.name} at ${entry.root}`);
574
741
  await (0, validate_1.validateEnvironmentRoot)(entry, this.registry.directory);
@@ -588,7 +755,7 @@ class EnvironmentUpdater {
588
755
  skipped.push({ module, reason: 'local' });
589
756
  const selected = configured.filter(module => !localModules.has(module));
590
757
  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)'}.`);
758
+ 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
759
  parsed = selected.map(module => ({ module }));
593
760
  onProgress?.(`Updating all configured modules from remote: ${selected.join(', ')}${skipped.length ? ` (skipping local: ${skipped.map(item => item.module).join(', ')})` : ''}`);
594
761
  }
@@ -612,6 +779,22 @@ class EnvironmentUpdater {
612
779
  localOverrides.set(spec.module, targets);
613
780
  }
614
781
  const remote = parsed.filter(spec => spec.localPath === undefined);
782
+ // The built-in resource modules deploy their trees *inside* the Server
783
+ // directory, and a Server refresh replaces that whole directory. An
784
+ // environment that has such a module therefore re-deploys it from its own
785
+ // artifact whenever the Server is refreshed in the same call: a remote-only
786
+ // module has no local copy to restore the trees from.
787
+ const autoRefreshed = [];
788
+ if (remote.some(spec => spec.module === 'server')) {
789
+ for (const module of plan_1.trailingModules) {
790
+ if (!installed.has(module) || parsed.some(spec => spec.module === module))
791
+ continue;
792
+ parsed.push({ module });
793
+ remote.push({ module });
794
+ autoRefreshed.push(module);
795
+ onProgress?.(`${module} is refreshed with the Server package: its trees deploy inside the Server directory`);
796
+ }
797
+ }
615
798
  // Smart documents cannot run without the collaboration service, which
616
799
  // create-env deploys and declares in the Server configuration alongside
617
800
  // documents. Installing documents into an environment with no collaboration
@@ -625,24 +808,19 @@ class EnvironmentUpdater {
625
808
  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.`);
626
809
  if (!remote.length && state !== 'ready' && state !== 'stopped' && state !== 'running')
627
810
  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.`);
628
- // The packaged 3D scene assets belong to every environment, not only to one
629
- // that create-env assembled: the plan below finds the files an environment is
630
- // still missing (or that differ) and the apply writes them with the rest of
631
- // the update. Planning touches nothing and runs before any download or write,
632
- // so an incomplete installation fails loudly with the path it expected and
633
- // leaves the environment exactly as it was.
634
- const resourceSource = (0, package_resource_1.bundledResourceDirectory)(package_resource_1.sceneResourceName, this.bundledResources);
635
- const resourceDestination = path.join(entry.root, ...package_resource_1.sceneResourceDestination.split('/'));
636
- onProgress?.(`Checking bundled 3D scene resources in ${package_resource_1.sceneResourceDestination}`);
637
- const resourcePlan = await (0, package_resource_1.planBundledResource)(resourceSource, resourceDestination);
638
- const resourceWrites = resourcePlan.added + resourcePlan.updated;
639
- 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
- const resourceSummary = `${resourcePlan.files.length - resourceWrites} identical, ${resourcePlan.added} missing, ${resourcePlan.updated} different`;
641
- onProgress?.(`Bundled 3D scene resources in ${package_resource_1.sceneResourceDestination}: ${dryRun ? 'dry run; ' : ''}${resourceSummary}`);
642
811
  const downloads = [...(manifest.downloads ?? [])];
643
- const selectedModules = manifest.selectedModules === undefined ? undefined : [...manifest.selectedModules];
812
+ const selectedModules = stringListOrUndefined(manifest.selectedModules);
813
+ const autoAddedModules = stringListOrUndefined(manifest.autoAddedModules);
644
814
  const moduleSources = { ...(manifest.moduleSources ?? {}) };
645
- const workspace = !dryRun && (remote.length > 0 || resourceWrites > 0) ? await fs.mkdtemp(path.join(path.dirname(entry.root), '.afm-env-')) : undefined;
815
+ // What a fresh environment would have selected for itself. An update records
816
+ // a first install the way `create-env` does: a selectable module joins the
817
+ // selection, a core component or an automatic dependency is added instead.
818
+ const selectable = selectableModules();
819
+ // The manifest records the .NET requirements read from each artifact's
820
+ // runtimeconfig files, so an update replaces the entries of the modules it
821
+ // refreshes and leaves every other module's alone.
822
+ let requirements = Array.isArray(manifest.requirements) ? manifest.requirements.filter((line) => typeof line === 'string') : [];
823
+ const workspace = !dryRun && remote.length > 0 ? await fs.mkdtemp(path.join(path.dirname(entry.root), '.afm-env-')) : undefined;
646
824
  const warnings = [];
647
825
  try {
648
826
  const resolved = new Map();
@@ -655,12 +833,34 @@ class EnvironmentUpdater {
655
833
  }
656
834
  if (!dryRun)
657
835
  for (const spec of remote)
658
- staged.set(spec.module, await stageRemoteArtifact(client, resolved.get(spec.module), workspace, onProgress));
836
+ staged.set(spec.module, await stageRemoteArtifact(client, resolved.get(spec.module), editionOf(entry), workspace, onProgress));
659
837
  }
660
838
  const configured = await fs.readFile(path.join(entry.root, 'conf', 'Wyn.conf'), 'utf8');
839
+ // A Server or Monitor package is only usable when it still serves the
840
+ // configuration schema this environment runs, so both are checked against
841
+ // the deployed schema before anything is planned around them.
842
+ const schema = remote.some(spec => spec.module === 'server' || spec.module === 'monitoring')
843
+ ? environmentSchema(manifest, configured)
844
+ : undefined;
845
+ if (remote.some(spec => spec.module === 'server' || spec.module === 'monitoring') && !schema) {
846
+ 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.`);
847
+ }
848
+ for (const spec of remote) {
849
+ const stagedArtifact = staged.get(spec.module);
850
+ if (!stagedArtifact || !schema || !(spec.module in requiredPackageFiles))
851
+ continue;
852
+ const warning = await verifyStagedPackage(spec.module, stagedArtifact, schema);
853
+ if (warning)
854
+ warnings.push(warning);
855
+ }
661
856
  const plans = [];
662
857
  // A module may carry several local specs: plan it once, for all its targets.
663
- for (const module of [...new Set(parsed.map(spec => spec.module))]) {
858
+ // The built-in trailing modules are planned last, in the same order
859
+ // `create-env` assembles: their trees live inside other modules'
860
+ // directories, so a Server replacement must not wipe them again.
861
+ const planOrder = [...new Set(parsed.map(spec => spec.module))]
862
+ .sort((left, right) => Number(plan_1.trailingModules.includes(left)) - Number(plan_1.trailingModules.includes(right)));
863
+ for (const module of planOrder) {
664
864
  const artifact = artifacts.find(candidate => candidate.module === module);
665
865
  const previous = moduleSources[module] ? { local: moduleSources[module].path } : { version: artifact?.version };
666
866
  const override = localOverrides.get(module);
@@ -685,7 +885,7 @@ class EnvironmentUpdater {
685
885
  });
686
886
  plans.push({
687
887
  module, previous, next: { local: override.map(target => target.path).join(', ') }, local: override,
688
- entries, copies: [], removals, copiedDirectories: [], removedDirectories,
888
+ entries, copies: [], removals, preserved: [], copiedDirectories: [], removedDirectories,
689
889
  });
690
890
  continue;
691
891
  }
@@ -693,30 +893,65 @@ class EnvironmentUpdater {
693
893
  const stagedArtifact = staged.get(module);
694
894
  const copies = [];
695
895
  const removals = [];
896
+ const preserved = [];
696
897
  const copiedDirectories = [];
697
898
  const removedDirectories = [];
698
899
  const entries = [];
699
- for (const mapping of remoteArtifact.mappings) {
900
+ let configurationAssets;
901
+ let configurationLink;
902
+ // Only the mappings this environment's edition deploys: the other
903
+ // edition's directory is never removed, copied or referenced.
904
+ for (const mapping of (0, catalog_1.editionMappings)(remoteArtifact.mappings, editionOf(entry))) {
700
905
  const destination = installationDirectory(entry.root, mapping.destination);
906
+ // A `resource` directory the module already deployed is replaced, never
907
+ // merged, and it contributes no plugin, worker, service or runtime entry.
701
908
  if (await exists(destination)) {
702
909
  removals.push(destination);
703
910
  removedDirectories.push(mapping.destination);
704
911
  }
912
+ // The Server settings the environment owns are never taken from the
913
+ // archive and must survive the replacement, so carry each one that is
914
+ // actually deployed across the directory swap.
915
+ if (stagedArtifact && mapping.role === 'server' && workspace) {
916
+ for (const name of excludedCopyNames(mapping)) {
917
+ if (await exists(path.join(destination, name)))
918
+ preserved.push({ directory: destination, name, source: path.join(workspace, 'preserved', module, name) });
919
+ }
920
+ }
705
921
  if (stagedArtifact)
706
- copies.push({ source: path.join(stagedArtifact.expanded, mapping.source), destination });
922
+ copies.push({ source: path.join(stagedArtifact.expanded, mapping.source), destination, exclude: excludedCopyNames(mapping) });
707
923
  copiedDirectories.push(mapping.destination);
924
+ if (mapping.role === 'resource')
925
+ continue;
926
+ // A native out-of-process service has no assembly and is declared as an
927
+ // empty node: the Server loads it from that entry and supplies the rest.
928
+ if (mapping.role === 'service') {
929
+ if (mapping.service)
930
+ entries.push({ destination: mapping.destination, assemblies: [], path: mapping.destination, role: 'service', service: mapping.service });
931
+ continue;
932
+ }
708
933
  const assembly = stagedArtifact?.assemblies.get(mapping.destination) ?? knownAssembly(configured, mapping, this.platform);
709
934
  // Plugins stay relative to the environment root; a worker entry does too,
710
935
  // and its service keeps using the deployed destination as working directory.
711
- if (mapping.role === 'plugin' || mapping.role === 'worker') {
936
+ // The Server service the Monitor starts is kept in step the same way.
937
+ if (mapping.role === 'plugin' || mapping.role === 'worker' || mapping.role === 'server') {
712
938
  const entry = { destination: mapping.destination, assemblies: mapping.assemblies, path: path.posix.join(mapping.destination, assembly) };
713
- if (mapping.role === 'worker') {
714
- entry.role = 'worker';
939
+ if (mapping.role === 'worker' || mapping.role === 'server') {
940
+ entry.role = mapping.role;
715
941
  if (mapping.service)
716
942
  entry.service = mapping.service;
717
943
  }
718
944
  entries.push(entry);
719
945
  }
946
+ // The Monitor resolves its configuration from its own working directory,
947
+ // so its package assets belong in the canonical `conf` and the directory
948
+ // itself has to point back at it once the Monitor tree is replaced.
949
+ if (mapping.role === 'monitor' && stagedArtifact) {
950
+ const assets = path.join(stagedArtifact.expanded, 'conf');
951
+ if ((await fs.lstat(assets).catch(() => undefined))?.isDirectory())
952
+ configurationAssets = { source: assets, destination: path.join(entry.root, 'conf') };
953
+ configurationLink = path.join(destination, 'conf');
954
+ }
720
955
  }
721
956
  if (module === 'dashboard' && stagedArtifact) {
722
957
  const contents = path.join(stagedArtifact.expanded, 'Web Contents');
@@ -726,13 +961,14 @@ class EnvironmentUpdater {
726
961
  removals.push(destination);
727
962
  removedDirectories.push('Web Contents');
728
963
  }
729
- copies.push({ source: contents, destination });
964
+ copies.push({ source: contents, destination, exclude: [] });
730
965
  copiedDirectories.push('Web Contents');
731
966
  }
732
967
  }
733
968
  plans.push({
734
969
  module, previous, next: { version: remoteArtifact.version }, artifact: remoteArtifact, staged: stagedArtifact,
735
- entries, copies, removals, copiedDirectories, removedDirectories,
970
+ entries, copies, removals, preserved, ...(configurationAssets ? { configurationAssets } : {}), ...(configurationLink ? { configurationLink } : {}),
971
+ copiedDirectories, removedDirectories,
736
972
  });
737
973
  }
738
974
  const changes = plans.map(plan => ({ module: plan.module, previous: plan.previous, next: plan.next, copiedDirectories: plan.copiedDirectories, removedDirectories: plan.removedDirectories }));
@@ -757,65 +993,109 @@ class EnvironmentUpdater {
757
993
  nextDownloads.push(download);
758
994
  else
759
995
  nextDownloads[downloadIndex] = download;
996
+ // The requirements the old artifact needed are gone with it: re-read
997
+ // the ones the new package actually declares, per deployed mapping.
998
+ // A resource mapping is a static tree: it declares none.
999
+ requirements = requirements.filter(line => !line.endsWith(` (${plan.module})`));
1000
+ for (const mapping of (0, catalog_1.editionMappings)(plan.artifact.mappings, editionOf(entry))) {
1001
+ if (mapping.role === 'service' || mapping.role === 'resource')
1002
+ continue;
1003
+ for (const framework of await (0, builder_1.runtimeRequirements)(path.join(plan.staged.expanded, mapping.source))) {
1004
+ requirements.push(`${framework.name} ${framework.version} (${plan.module})`);
1005
+ }
1006
+ }
760
1007
  }
761
1008
  delete moduleSources[plan.module];
762
1009
  }
763
1010
  if (plan.local)
764
1011
  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);
1012
+ // First installing a module records it the way `create-env` would: a
1013
+ // selectable one joins the selection, a core component or an automatic
1014
+ // dependency joins the automatically added list instead.
1015
+ if (!installed.has(plan.module)) {
1016
+ if (selectable.has(plan.module)) {
1017
+ if (selectedModules)
1018
+ selectedModules.push(plan.module);
1019
+ }
1020
+ else if (autoAddedModules)
1021
+ autoAddedModules.push(plan.module);
1022
+ }
767
1023
  }
768
1024
  if (selectedModules) {
769
1025
  const unique = [...new Set(selectedModules)].sort();
770
1026
  selectedModules.length = 0;
771
1027
  selectedModules.push(...unique);
772
1028
  }
773
- const nextManifest = { ...manifest, artifacts: nextArtifacts, downloads: nextDownloads };
1029
+ if (autoAddedModules) {
1030
+ const unique = [...new Set(autoAddedModules)].sort();
1031
+ autoAddedModules.length = 0;
1032
+ autoAddedModules.push(...unique);
1033
+ }
1034
+ const nextManifest = { ...manifest, artifacts: nextArtifacts, downloads: nextDownloads, requirements: [...new Set(requirements)] };
1035
+ if (autoAddedModules)
1036
+ nextManifest.autoAddedModules = autoAddedModules;
774
1037
  if (Object.keys(moduleSources).length)
775
1038
  nextManifest.moduleSources = moduleSources;
776
1039
  else
777
1040
  delete nextManifest.moduleSources;
778
1041
  if (selectedModules)
779
1042
  nextManifest.selectedModules = selectedModules;
780
- // The resource is recorded in place, in exactly the shape create-env writes:
781
- // a second update finds the entry and refreshes it instead of adding a
782
- // duplicate. The result carries the added/updated counts; the manifest keeps
783
- // the durable four-field description.
784
- const resources = [...(manifest.resources ?? [])];
785
- const recorded = resources.findIndex(candidate => candidate.name === package_resource_1.sceneResourceName);
786
- const record = { name: package_resource_1.sceneResourceName, source: resource.source, destination: resource.destination, files: resource.files };
787
- if (recorded < 0)
788
- resources.push(record);
789
- else
790
- resources[recorded] = { ...resources[recorded], ...record };
791
- nextManifest.resources = resources;
792
- const modules = [...new Set([...entry.modules, ...plans.filter(plan => !installed.has(plan.module)).map(plan => plan.module)])].sort();
1043
+ // A stale `resources` key from an older manifest is dropped here: the
1044
+ // packaged-resource feature is gone and nothing records it any more.
1045
+ delete nextManifest.resources;
1046
+ const modules = [...new Set([...entry.modules, ...plans.filter(plan => !installed.has(plan.module) && selectable.has(plan.module)).map(plan => plan.module)])].sort();
793
1047
  const localModules = Object.keys(moduleSources).sort();
794
1048
  if (state === 'running')
795
1049
  warnings.push(`${entry.name} is running; restart it to load the updated modules.`);
796
1050
  for (const item of skipped)
797
1051
  warnings.push(`${item.module} has a local source and was not updated from artifacts.`);
1052
+ // The Server and Monitor packages each ship the XSD assets for a
1053
+ // configuration schema, so replacing one of them alone leaves the
1054
+ // environment mixing two package generations for the same configuration.
1055
+ const runtime = plans.filter(plan => plan.module === 'server' || plan.module === 'monitoring').map(plan => plan.module).sort();
1056
+ if (runtime.length === 1)
1057
+ 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.`);
1058
+ if (parsed.some(spec => spec.module === 'documents') && !parsed.some(spec => spec.module === 'collaboration')) {
1059
+ warnings.push('documents was refreshed without collaboration; the smart documents plugin may expect the deployed collaboration service version, so refresh collaboration too.');
1060
+ }
1061
+ if (autoRefreshed.length)
1062
+ warnings.push(`${autoRefreshed.join(', ')} was refreshed with the Server package: its trees live inside the Server directory and would otherwise be lost.`);
798
1063
  if (!dryRun) {
799
1064
  for (const plan of plans) {
1065
+ // Take the environment-owned settings aside first, and drop the Monitor
1066
+ // configuration junction before the directory holding it: a recursive
1067
+ // delete must never reach the canonical `conf` the junction points at.
1068
+ for (const file of plan.preserved) {
1069
+ await fs.mkdir(path.dirname(file.source), { recursive: true });
1070
+ await fs.copyFile(path.join(file.directory, file.name), file.source);
1071
+ }
1072
+ if (plan.configurationLink) {
1073
+ const existing = await fs.lstat(plan.configurationLink).catch(() => undefined);
1074
+ if (existing?.isSymbolicLink())
1075
+ await fs.unlink(plan.configurationLink);
1076
+ else
1077
+ await fs.rm(plan.configurationLink, { recursive: true, force: true });
1078
+ }
800
1079
  for (const removal of plan.removals)
801
1080
  await fs.rm(removal, { recursive: true, force: true });
802
1081
  for (const copy of plan.copies)
803
- await (0, builder_1.copyTree)(copy.source, copy.destination);
804
- }
805
- // The staged resources are written with the module changes, before the
806
- // manifest that records them; a failure rolls the layout back.
807
- if (resourceWrites) {
808
- if (!workspace)
809
- throw new Error('Missing staging workspace for the bundled resource layout.');
810
- onProgress?.(`Laying out bundled 3D scene resources into ${package_resource_1.sceneResourceDestination}`);
811
- const applied = await (0, package_resource_1.applyBundledResource)(resourceSource, resourceDestination, resourcePlan, path.join(workspace, 'replaced-resources'));
812
- if (applied.added.length !== resourcePlan.added || applied.updated.length !== resourcePlan.updated)
813
- throw new Error('Bundled resource layout did not match its plan.');
1082
+ await (0, builder_1.copyTree)(copy.source, copy.destination, copy.exclude);
1083
+ for (const file of plan.preserved)
1084
+ await fs.copyFile(file.source, path.join(file.directory, file.name));
1085
+ if (plan.configurationAssets) {
1086
+ onProgress?.(`Refreshing Monitor configuration assets in ${path.relative(entry.root, plan.configurationAssets.destination)}`);
1087
+ await mergeConfigurationAssets(plan.configurationAssets.source, plan.configurationAssets.destination);
1088
+ }
1089
+ if (plan.configurationLink)
1090
+ await fs.symlink(path.join(entry.root, 'conf'), plan.configurationLink, 'junction');
814
1091
  }
815
- // A resource-only update (no module spec) leaves the Server configuration
816
- // exactly as it is: the assets need no plugin, worker or service entry.
817
- onProgress?.(plans.length ? 'Writing configuration, manifest and registry record' : 'Writing manifest and registry record');
818
- if (plans.length)
1092
+ // Only a plan that really contributes something to the configuration
1093
+ // rewrites it: the built-in dashboard-resource module adds no plugin,
1094
+ // worker or service entry, so refreshing it alone leaves `conf/Wyn.conf`
1095
+ // exactly as it is instead of round-tripping it through the XML builder.
1096
+ const configurationChanged = plans.some(plan => plan.entries.length > 0 || plan.configurationAssets !== undefined || plan.configurationLink !== undefined);
1097
+ onProgress?.(configurationChanged ? 'Writing configuration, manifest and registry record' : 'Writing manifest and registry record');
1098
+ if (configurationChanged)
819
1099
  await writeAtomic(path.join(entry.root, 'conf', 'Wyn.conf'), configuration);
820
1100
  await writeJsonAtomic(path.join(entry.root, 'environment.lock.json'), nextManifest);
821
1101
  await this.registry.update(entries => {
@@ -831,10 +1111,10 @@ class EnvironmentUpdater {
831
1111
  delete entries[index].localModules;
832
1112
  });
833
1113
  }
834
- const restartRequired = !dryRun && (changes.length > 0 || resourceWrites > 0);
1114
+ const restartRequired = !dryRun && changes.length > 0;
835
1115
  if (restartRequired)
836
- onProgress?.(`${entry.name}: ${changes.length ? 'modules updated' : 'bundled resources laid out'}; restart the environment to load the change (no service was started).`);
837
- return { name: entry.name, root: entry.root, dryRun, changes, skipped, restartRequired, warnings, localTargets: plans.flatMap(plan => plan.local ?? []), resource };
1116
+ onProgress?.(`${entry.name}: modules updated; restart the environment to load the change (no service was started).`);
1117
+ return { name: entry.name, root: entry.root, dryRun, changes, skipped, restartRequired, warnings, localTargets: plans.flatMap(plan => plan.local ?? []) };
838
1118
  }
839
1119
  finally {
840
1120
  if (workspace)