@guanjinke/artifact-client 1.10.0 → 1.12.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.
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs/promises"));
40
40
  const path = __importStar(require("path"));
41
41
  const fast_xml_parser_1 = require("fast-xml-parser");
42
42
  const catalog_1 = require("./catalog");
43
+ const deferred_1 = require("./deferred");
43
44
  const archive_1 = require("./archive");
44
45
  const builder_1 = require("./builder");
45
46
  const config_1 = require("./config");
@@ -57,12 +58,14 @@ const windowsPath = (value, platform) => (0, local_path_1.nativeSeparators)(valu
57
58
  /** The separator the recorded entries use on the target platform. */
58
59
  const separatorOf = (platform) => (platform === 'win32' ? '\\' : '/');
59
60
  /**
60
- * Every catalog module an update may refresh. The create-only modules are
61
- * excluded: they seed data the product imports once, when the Server starts for
62
- * the first time, so an update never refreshes, preserves or re-deploys them —
63
- * and the web update picker reads this same list.
61
+ * Every catalog module a user may refresh. Both the create-only modules and the
62
+ * internal ones are excluded: a create-only module seeds a tree the Server owns
63
+ * (the first-start sample data) which no update can reproduce, and an internal
64
+ * module (the built-in 3D scene and map resources) is maintained by the updater
65
+ * itself — so an update never refreshes, preserves or re-deploys either on
66
+ * request. The web update picker reads this same list.
64
67
  */
65
- exports.updatableModules = Object.keys(catalog_1.catalog).filter(module => !catalog_1.catalog[module].createOnly).sort();
68
+ exports.updatableModules = Object.keys(catalog_1.catalog).filter(module => !catalog_1.catalog[module].createOnly && !catalog_1.catalog[module].internal).sort();
66
69
  /**
67
70
  * Server-root settings the environment owns.
68
71
  *
@@ -91,7 +94,49 @@ const requiredPackageFiles = {
91
94
  */
92
95
  const excludedCopyNames = (mapping) => mapping.role === 'server' ? [...serverOwnedFiles] : mapping.role === 'monitor' ? ['conf'] : [];
93
96
  /**
94
- * The modules a selection may name, from the same table `create-env` and the web
97
+ * Download the payload of a deferred module.
98
+ *
99
+ * It is published as files, not as an archive: the provider keeps the relative
100
+ * sub-path and the file is deployed exactly as downloaded, so nothing is
101
+ * extracted. Exactly one object is downloaded for `patch`. The public label is
102
+ * the only name in progress output or in errors.
103
+ */
104
+ async function stageReplacement(client, module, version, payloads, workspace, onProgress) {
105
+ const entry = catalog_1.catalog[module];
106
+ const label = (0, catalog_1.moduleLabel)(entry);
107
+ const download = path.join(workspace, 'download', label);
108
+ try {
109
+ await fs.mkdir(download, { recursive: true });
110
+ onProgress?.(`[${label}] ${label}/${version}: Downloading replacement`);
111
+ const files = [];
112
+ for (const payload of payloads) {
113
+ await client.downloadArtifact(entry.plugin, version, payload.name, download);
114
+ const source = path.join(download, ...payload.name.split('/'));
115
+ await (0, builder_1.regularFile)(source);
116
+ files.push({ name: payload.name, source, sha256: await (0, builder_1.hashFile)(source) });
117
+ }
118
+ onProgress?.(`[${label}] ${label}/${version}: Download complete; replacement ready`);
119
+ return { version, files };
120
+ }
121
+ catch (error) {
122
+ if (error instanceof Error && error.message.startsWith(`Module ${label} could not be applied:`))
123
+ throw error;
124
+ throw new Error(`Module update failed for ${label}/${version} (download or layout).`);
125
+ }
126
+ }
127
+ /** One environment-relative Server path inside a staged Server package. */
128
+ function stagedServerPath(staged, relative) {
129
+ const server = catalog_1.catalog.server.mappings.find(mapping => mapping.role === 'server');
130
+ const prefix = server ? `${server.destination}/` : '';
131
+ const inner = prefix && relative.startsWith(prefix) ? relative.slice(prefix.length) : relative;
132
+ return path.join(staged.expanded, ...(server?.source ? server.source.split('/') : []), ...inner.split('/'));
133
+ }
134
+ /** The version file inside a staged Server package, before it is laid out. */
135
+ function stagedServerFile(staged, module) {
136
+ const descriptor = catalog_1.catalog[module].deferred;
137
+ return stagedServerPath(staged, descriptor.versionFile);
138
+ }
139
+ /** The modules a selection may name, from the same table `create-env` and the web
95
140
  * interface read.
96
141
  *
97
142
  * Reading it through the call keeps this module loadable on its own: the policy
@@ -109,9 +154,14 @@ function validateUpdatableModule(module) {
109
154
  // `hasOwnProperty` keeps prototype names (`constructor`) out of the catalog.
110
155
  if (!Object.prototype.hasOwnProperty.call(catalog_1.catalog, module))
111
156
  throw new Error(`Unsupported module "${module}": supported modules are ${exports.updatableModules.join(', ')}.`);
112
- // A create-only module is refused before anything else happens — before the
113
- // registry, the manifest or the configuration is read — because no update can
114
- // produce it: it is deployed only while the environment is created.
157
+ // An internal module is refused before anything else happens — before the
158
+ // registry, the manifest or the configuration is read — because the user never
159
+ // selects it: the updater deploys it with the environment and re-deploys it
160
+ // together with the package whose directory holds its files.
161
+ if (catalog_1.catalog[module].internal)
162
+ throw new Error(`Module ${module} is internal: it is deployed with the environment and refreshed automatically with the Server package, so it cannot be selected or updated directly.`);
163
+ // A create-only module is refused for the same reason: no update can produce
164
+ // it, because it is deployed only while the environment is created.
115
165
  if (catalog_1.catalog[module].createOnly)
116
166
  throw new Error(`Module ${module} is create-only: it is deployed only when the environment is created and is never refreshed, preserved or re-deployed by update-env. Recreate the environment with create-env to get it again.`);
117
167
  }
@@ -178,6 +228,11 @@ function pushSpec(specs, seen, spec) {
178
228
  }
179
229
  else {
180
230
  validateUpdatableModule(spec.module);
231
+ // A deferred module's version is always the one recorded by the deployed (or
232
+ // newly staged) Server package. Pinning a different one would either be
233
+ // ignored or produce a mismatched environment, so it is refused.
234
+ if (catalog_1.catalog[spec.module].deferred && spec.version !== undefined)
235
+ throw new Error(`Module ${spec.module} takes its version from the deployed Server package: a pinned version is not supported.`);
181
236
  if (seen.has(allModules))
182
237
  throw new Error('Module spec all cannot be combined with individual modules.');
183
238
  const previous = seen.get(spec.module);
@@ -553,14 +608,17 @@ function environmentSchema(manifest, configuration) {
553
608
  * that policy. Returns the warning text, or undefined when the package is
554
609
  * complete.
555
610
  */
556
- async function verifyStagedPackage(module, staged, schema) {
611
+ async function verifyStagedPackage(module, staged, schema, required = requiredPackageFiles[module] ?? []) {
557
612
  const label = `${staged.plugin}/${staged.version}/${staged.name}`;
558
- for (const name of requiredPackageFiles[module] ?? []) {
613
+ for (const name of required) {
559
614
  try {
560
615
  await (0, builder_1.regularFile)(path.join(staged.expanded, name));
561
616
  }
617
+ // The Server's required file is the one the selectable patch replaces, so the
618
+ // message stays generic: its name must not be user-visible. Every other
619
+ // module's required file name is public and useful.
562
620
  catch {
563
- throw new Error(`Module update failed for ${label}: the package is missing ${name}.`);
621
+ throw new Error(`Module update failed for ${label}: the package is missing ${module === 'server' ? 'a required runtime file' : name}.`);
564
622
  }
565
623
  }
566
624
  const relative = `xsd/${schema}/Wyn.conf.xsd`;
@@ -636,10 +694,17 @@ function mappingForAssembly(module, assembly) {
636
694
  * explicitly, for a build whose file name matches no assembly at all.
637
695
  */
638
696
  async function resolveLocalSource(entry, module, assembly, input) {
639
- // The built-in resource module is never a developer-build target: it ships a
640
- // static asset tree that no local DLL could stand in for, so a local source is
641
- // refused on its own terms before any path is even looked at. Remote specs
642
- // (latest, pinned and `all`) keep working.
697
+ // A deferred module is a published replacement for one file the Server package
698
+ // owns; a developer build has no mapping the configuration could point at, so
699
+ // a local source is refused on its own terms before any path is looked at.
700
+ // The message names the module only.
701
+ if (catalog_1.catalog[module].deferred)
702
+ throw new Error(`Module ${module} is refreshed from its published artifact only and has no local source.`);
703
+ // Any built-in trailing module is never a developer-build target either: it
704
+ // ships a static asset tree no local DLL could stand in for. Both are refused
705
+ // as explicit specs before this point (one internal, one create-only); this
706
+ // guard is the safety net for a trailing module a user could select, whose
707
+ // remote specs would keep working.
643
708
  if (plan_1.trailingModules.includes(module))
644
709
  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\`).`);
645
710
  const mappings = localMappings(module);
@@ -789,12 +854,15 @@ class EnvironmentUpdater {
789
854
  localOverrides.set(spec.module, targets);
790
855
  }
791
856
  const remote = parsed.filter(spec => spec.localPath === undefined);
792
- // The built-in resource module deploys its trees *inside* the Server
793
- // directory, and a Server refresh replaces that whole directory. An
794
- // environment that has it therefore re-deploys it from its own artifact
795
- // whenever the Server is refreshed in the same call: a remote-only module has
796
- // no local copy to restore the trees from. A create-only trailing module is
797
- // never re-deployed: the Server refresh simply drops what it had seeded.
857
+ // Internal modules are never named by a spec (they are refused while the
858
+ // module specs are parsed) and `all` never selects them, but the updater
859
+ // still deploys them on its own. `dashboard-resource` ships the 3D scene and
860
+ // map trees *inside* the Server directory, and a Server refresh replaces that
861
+ // whole directory: an environment that has it therefore re-deploys it from
862
+ // its own artifact whenever the Server is refreshed in the same call, because
863
+ // there is no other copy to restore the trees from. A create-only trailing
864
+ // module is different: it seeds data the product imported once, so a Server
865
+ // refresh simply drops what it had seeded.
798
866
  const autoRefreshed = [];
799
867
  if (remote.some(spec => spec.module === 'server')) {
800
868
  for (const module of plan_1.trailingModules) {
@@ -806,6 +874,21 @@ class EnvironmentUpdater {
806
874
  onProgress?.(`${module} is refreshed with the Server package: its trees deploy inside the Server directory`);
807
875
  }
808
876
  }
877
+ // A Server refresh restores the unpatched file the Server package ships, so
878
+ // an installed deferred module is re-applied in the same call. Its version is
879
+ // resolved from the *new* package below, never from the old tree. Without a
880
+ // Server refresh it is only applied when the caller names it explicitly.
881
+ const autoReplaced = [];
882
+ if (remote.some(spec => spec.module === 'server')) {
883
+ for (const module of Object.keys(catalog_1.catalog)) {
884
+ if (!catalog_1.catalog[module].deferred || !installed.has(module) || parsed.some(spec => spec.module === module))
885
+ continue;
886
+ parsed.push({ module });
887
+ remote.push({ module });
888
+ autoReplaced.push(module);
889
+ onProgress?.(`${module} is re-applied with the Server package: the new package replaces the file it patches`);
890
+ }
891
+ }
809
892
  // Smart documents cannot run without the collaboration service, which
810
893
  // create-env deploys and declares in the Server configuration alongside
811
894
  // documents. Installing documents into an environment with no collaboration
@@ -836,15 +919,57 @@ class EnvironmentUpdater {
836
919
  try {
837
920
  const resolved = new Map();
838
921
  const staged = new Map();
922
+ const replacements = new Map();
839
923
  if (remote.length) {
840
924
  const client = clientFactory();
841
- for (const spec of remote) {
925
+ const direct = remote.filter(spec => !catalog_1.catalog[spec.module].deferred);
926
+ for (const spec of direct) {
842
927
  onProgress?.(`Resolving ${catalog_1.catalog[spec.module].plugin}`);
843
928
  resolved.set(spec.module, await resolveRemoteArtifact(client, entry, spec));
844
929
  }
845
930
  if (!dryRun)
846
- for (const spec of remote)
931
+ for (const spec of direct)
847
932
  staged.set(spec.module, await stageRemoteArtifact(client, resolved.get(spec.module), editionOf(entry), workspace, onProgress));
933
+ // A deferred module resolves after the direct artifacts are staged, so a
934
+ // Server refresh contributes the *new* package's version. Without a
935
+ // Server refresh the already deployed tree is the authority. A dry-run
936
+ // that refreshes the Server cannot know that version without downloading
937
+ // the package, so it reports the deferred resolution and stops there.
938
+ for (const spec of remote.filter(candidate => catalog_1.catalog[candidate.module].deferred)) {
939
+ const module = spec.module;
940
+ const source = catalog_1.catalog[module];
941
+ const label = (0, catalog_1.moduleLabel)(source);
942
+ const server = staged.get('server');
943
+ let version;
944
+ if (server) {
945
+ version = await (0, deferred_1.deferredVersionAt)(source, stagedServerFile(server, module));
946
+ // The replacement must overwrite an existing regular file in the new
947
+ // package; checking the staged tree catches a package that no longer
948
+ // ships it before anything has been written.
949
+ for (const mapping of (0, deferred_1.replacementMappings)(source)) {
950
+ const file = stagedServerPath(server, mapping.destination);
951
+ const stat = await lstatOrUndefined(file);
952
+ if (!stat)
953
+ throw new Error(`Module ${label} could not be applied: the Server package holds no file to replace.`);
954
+ if (!stat.isFile() || stat.isSymbolicLink())
955
+ throw new Error(`Module ${label} could not be applied: the Server package holds no regular file to replace.`);
956
+ }
957
+ }
958
+ else if (remote.some(candidate => candidate.module === 'server')) {
959
+ onProgress?.(`${label}: version resolves from the refreshed Server package at execution time`);
960
+ replacements.set(module, { version: '' });
961
+ continue;
962
+ }
963
+ else
964
+ version = await (0, deferred_1.deferredVersion)(source, entry.root);
965
+ const payloads = await (0, deferred_1.deferredPayload)(source, version, client);
966
+ if (dryRun) {
967
+ onProgress?.(`${label}/${version}: resolved from the deployed Server package; nothing downloaded`);
968
+ replacements.set(module, { version });
969
+ continue;
970
+ }
971
+ replacements.set(module, await stageReplacement(client, module, version, payloads, workspace, onProgress));
972
+ }
848
973
  }
849
974
  const configured = await fs.readFile(path.join(entry.root, 'conf', 'Wyn.conf'), 'utf8');
850
975
  // A Server or Monitor package is only usable when it still serves the
@@ -856,11 +981,16 @@ class EnvironmentUpdater {
856
981
  if (remote.some(spec => spec.module === 'server' || spec.module === 'monitoring') && !schema) {
857
982
  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.`);
858
983
  }
984
+ // A planned deferred module validates the exact Server file it replaces,
985
+ // with a message that names only the module; the Server's own required-file
986
+ // check would otherwise report the same missing file by its internal name.
987
+ const deferredPlanned = remote.some(spec => catalog_1.catalog[spec.module].deferred);
859
988
  for (const spec of remote) {
860
989
  const stagedArtifact = staged.get(spec.module);
861
990
  if (!stagedArtifact || !schema || !(spec.module in requiredPackageFiles))
862
991
  continue;
863
- const warning = await verifyStagedPackage(spec.module, stagedArtifact, schema);
992
+ const required = spec.module === 'server' && deferredPlanned ? [] : requiredPackageFiles[spec.module];
993
+ const warning = await verifyStagedPackage(spec.module, stagedArtifact, schema, required);
864
994
  if (warning)
865
995
  warnings.push(warning);
866
996
  }
@@ -868,9 +998,11 @@ class EnvironmentUpdater {
868
998
  // A module may carry several local specs: plan it once, for all its targets.
869
999
  // The built-in trailing modules are planned last, in the same order
870
1000
  // `create-env` assembles: their trees live inside other modules'
871
- // directories, so a Server replacement must not wipe them again.
872
- const planOrder = [...new Set(parsed.map(spec => spec.module))]
873
- .sort((left, right) => Number(plan_1.trailingModules.includes(left)) - Number(plan_1.trailingModules.includes(right)));
1001
+ // directories, so a Server replacement must not wipe them again. A deferred
1002
+ // module runs after `server` even when the caller wrote it first: the Server
1003
+ // directory must hold the new package before it is patched.
1004
+ const rank = (module) => plan_1.trailingModules.includes(module) ? 2 : catalog_1.catalog[module].deferred ? 1 : 0;
1005
+ const planOrder = [...new Set(parsed.map(spec => spec.module))].sort((left, right) => rank(left) - rank(right));
874
1006
  for (const module of planOrder) {
875
1007
  const artifact = artifacts.find(candidate => candidate.module === module);
876
1008
  const previous = moduleSources[module] ? { local: moduleSources[module].path } : { version: artifact?.version };
@@ -900,6 +1032,37 @@ class EnvironmentUpdater {
900
1032
  });
901
1033
  continue;
902
1034
  }
1035
+ // A deferred module replaces one existing regular file and contributes
1036
+ // no directory, plugin, worker, service or runtime requirement. Its
1037
+ // version and payload were resolved above; nothing about the provider is
1038
+ // recorded, and the target is checked before any write.
1039
+ if (catalog_1.catalog[module].deferred) {
1040
+ const replacement = replacements.get(module);
1041
+ const label = (0, catalog_1.moduleLabel)(catalog_1.catalog[module]);
1042
+ const version = replacement?.version || undefined;
1043
+ const files = [];
1044
+ if (replacement?.files?.length) {
1045
+ const mappings = (0, deferred_1.replacementMappings)(catalog_1.catalog[module]);
1046
+ for (const file of replacement.files) {
1047
+ const matches = mappings.filter(mapping => mapping.source === file.name);
1048
+ if (matches.length !== 1)
1049
+ throw new Error(`Module ${label} could not be applied: the replacement target is not configured.`);
1050
+ const destination = installationDirectory(entry.root, matches[0].destination);
1051
+ const stat = await lstatOrUndefined(destination);
1052
+ if (!stat)
1053
+ throw new Error(`Module ${label} could not be applied: the Server tree holds no file to replace.`);
1054
+ if (!stat.isFile() || stat.isSymbolicLink())
1055
+ throw new Error(`Module ${label} could not be applied: the Server tree holds no regular file to replace.`);
1056
+ files.push({ source: file.source, destination, sha256: file.sha256 });
1057
+ }
1058
+ }
1059
+ plans.push({
1060
+ module, previous, next: version ? { version } : {},
1061
+ ...(files.length ? { replacement: { version: version, files } } : {}),
1062
+ entries: [], copies: [], removals: [], preserved: [], copiedDirectories: [], removedDirectories: [],
1063
+ });
1064
+ continue;
1065
+ }
903
1066
  const remoteArtifact = resolved.get(module);
904
1067
  const stagedArtifact = staged.get(module);
905
1068
  const copies = [];
@@ -1018,6 +1181,28 @@ class EnvironmentUpdater {
1018
1181
  }
1019
1182
  delete moduleSources[plan.module];
1020
1183
  }
1184
+ if (plan.replacement) {
1185
+ // The record keeps the public label, the resolved version and the
1186
+ // payload's SHA256. No remote path and no mapping are written: both
1187
+ // would expose a name the module hides.
1188
+ const label = (0, catalog_1.moduleLabel)(catalog_1.catalog[plan.module]);
1189
+ const replacement = { module: plan.module, plugin: label, version: plan.replacement.version, name: label };
1190
+ const index = nextArtifacts.findIndex(candidate => candidate.module === plan.module);
1191
+ if (index < 0)
1192
+ nextArtifacts.push(replacement);
1193
+ else
1194
+ nextArtifacts[index] = { ...nextArtifacts[index], ...replacement };
1195
+ for (const file of plan.replacement.files) {
1196
+ const download = { plugin: label, version: plan.replacement.version, name: label, sha256: file.sha256 };
1197
+ const downloadIndex = nextDownloads.findIndex(candidate => candidate.plugin === label);
1198
+ if (downloadIndex < 0)
1199
+ nextDownloads.push(download);
1200
+ else
1201
+ nextDownloads[downloadIndex] = download;
1202
+ }
1203
+ requirements = requirements.filter(line => !line.endsWith(` (${plan.module})`));
1204
+ delete moduleSources[plan.module];
1205
+ }
1021
1206
  if (plan.local)
1022
1207
  moduleSources[plan.module] = manifestSource(plan.module, plan.local, moduleSources[plan.module]);
1023
1208
  // First installing a module records it the way `create-env` would: a
@@ -1097,6 +1282,8 @@ class EnvironmentUpdater {
1097
1282
  }
1098
1283
  if (autoRefreshed.length)
1099
1284
  warnings.push(`${autoRefreshed.join(', ')} was refreshed with the Server package: its trees live inside the Server directory and would otherwise be lost.`);
1285
+ if (autoReplaced.length)
1286
+ warnings.push(`${autoReplaced.join(', ')} was re-applied with the Server package: the new package ships the file it replaces, so it was applied again.`);
1100
1287
  if (removedCreateOnly.length)
1101
1288
  warnings.push(`${removedCreateOnly.join(', ')} was removed with the Server package: it is deployed only when an environment is created.`);
1102
1289
  if (!dryRun) {
@@ -1121,6 +1308,25 @@ class EnvironmentUpdater {
1121
1308
  await (0, builder_1.copyTree)(copy.source, copy.destination, copy.exclude);
1122
1309
  for (const file of plan.preserved)
1123
1310
  await fs.copyFile(file.source, path.join(file.directory, file.name));
1311
+ // The replacement is applied after the directory it lives in has been
1312
+ // copied, so a Server refresh cannot overwrite it again. The target is
1313
+ // re-checked here and every error names the module only.
1314
+ if (plan.replacement) {
1315
+ const label = (0, catalog_1.moduleLabel)(catalog_1.catalog[plan.module]);
1316
+ try {
1317
+ for (const file of plan.replacement.files) {
1318
+ const stat = await fs.lstat(file.destination).catch(() => undefined);
1319
+ if (!stat)
1320
+ throw new Error('missing');
1321
+ if (!stat.isFile() || stat.isSymbolicLink())
1322
+ throw new Error('not a regular file');
1323
+ await fs.copyFile(file.source, file.destination);
1324
+ }
1325
+ }
1326
+ catch {
1327
+ throw new Error(`Module ${label} could not be applied: the Server tree holds no regular file to replace.`);
1328
+ }
1329
+ }
1124
1330
  if (plan.configurationAssets) {
1125
1331
  onProgress?.(`Refreshing Monitor configuration assets in ${path.relative(entry.root, plan.configurationAssets.destination)}`);
1126
1332
  await mergeConfigurationAssets(plan.configurationAssets.source, plan.configurationAssets.destination);
@@ -1129,9 +1335,10 @@ class EnvironmentUpdater {
1129
1335
  await fs.symlink(path.join(entry.root, 'conf'), plan.configurationLink, 'junction');
1130
1336
  }
1131
1337
  // Only a plan that really contributes something to the configuration
1132
- // rewrites it: the built-in dashboard-resource module adds no plugin,
1133
- // worker or service entry, so refreshing it alone leaves `conf/Wyn.conf`
1134
- // exactly as it is instead of round-tripping it through the XML builder.
1338
+ // rewrites it: a module that adds no plugin, worker or service entry
1339
+ // leaves `conf/Wyn.conf` exactly as it is instead of round-tripping it
1340
+ // through the XML builder. (The built-in modules are create-only, so an
1341
+ // update never plans one.)
1135
1342
  const configurationChanged = plans.some(plan => plan.entries.length > 0 || plan.configurationAssets !== undefined || plan.configurationLink !== undefined);
1136
1343
  onProgress?.(configurationChanged ? 'Writing configuration, manifest and registry record' : 'Writing manifest and registry record');
1137
1344
  if (configurationChanged)