@guanjinke/artifact-client 1.4.0 → 1.5.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.
@@ -67,19 +67,26 @@ const allModules = 'all';
67
67
  function parseModuleSpec(value) {
68
68
  const equals = value.indexOf('=');
69
69
  if (equals >= 0) {
70
- const module = value.slice(0, equals).trim().toLowerCase();
71
- const localPath = value.slice(equals + 1).trim();
70
+ const token = value.slice(0, equals).trim();
71
+ const hash = token.indexOf('#');
72
+ const module = (hash >= 0 ? token.slice(0, hash) : token).trim().toLowerCase();
73
+ const assembly = hash >= 0 ? token.slice(hash + 1).trim() : undefined;
72
74
  if (!module)
73
75
  throw new Error(`Invalid module spec "${value}": missing module name.`);
76
+ if (assembly !== undefined && !assembly)
77
+ throw new Error(`Invalid module spec "${value}": missing assembly name after #.`);
78
+ if (assembly !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(assembly))
79
+ throw new Error(`Invalid module spec "${value}": invalid assembly file name.`);
74
80
  if (module === allModules)
75
81
  throw new Error(`Invalid module spec "${value}": all selects every configured module from remote and cannot point at a local source.`);
76
82
  // The module token itself may not carry a pinned version: `=` and `:` select
77
83
  // different sources. A Windows drive letter inside the path is not that.
78
84
  if (module.includes(':'))
79
85
  throw new Error(`Invalid module spec "${value}": a local source (=) cannot also pin a version (:).`);
86
+ const localPath = value.slice(equals + 1).trim();
80
87
  if (!localPath)
81
88
  throw new Error(`Invalid module spec "${value}": missing local DLL path.`);
82
- return { module, localPath };
89
+ return { module, localPath, ...(assembly !== undefined ? { assembly } : {}) };
83
90
  }
84
91
  const colon = value.indexOf(':');
85
92
  if (colon >= 0) {
@@ -100,8 +107,16 @@ function parseModuleSpec(value) {
100
107
  return { module: allModules };
101
108
  return { module };
102
109
  }
103
- /** Record one parsed spec, enforcing "all alone" and duplicate rules. */
110
+ /**
111
+ * Record one parsed spec, enforcing "all alone" and duplicate rules.
112
+ *
113
+ * A module may be named several times only when every one of its specs is a
114
+ * local source: each local spec resolves to one or more targets, and the update
115
+ * refuses a target that is selected twice. A remote spec replaces the whole
116
+ * module, so it may never share a module with another spec.
117
+ */
104
118
  function pushSpec(specs, seen, spec) {
119
+ const kind = spec.localPath === undefined ? 'remote' : 'local';
105
120
  if (spec.module === allModules) {
106
121
  if (seen.has(allModules))
107
122
  throw new Error('Duplicate module in one update: all.');
@@ -112,10 +127,11 @@ function pushSpec(specs, seen, spec) {
112
127
  validateUpdatableModule(spec.module);
113
128
  if (seen.has(allModules))
114
129
  throw new Error('Module spec all cannot be combined with individual modules.');
115
- if (seen.has(spec.module))
130
+ const previous = seen.get(spec.module);
131
+ if (previous !== undefined && (previous === 'remote' || kind === 'remote'))
116
132
  throw new Error(`Duplicate module in one update: ${spec.module}.`);
117
133
  }
118
- seen.add(spec.module);
134
+ seen.set(spec.module, kind);
119
135
  specs.push(spec);
120
136
  }
121
137
  /**
@@ -130,7 +146,7 @@ function parseModuleSpecs(values) {
130
146
  if (!Array.isArray(values))
131
147
  throw new Error('Module specs must be an array of strings.');
132
148
  const specs = [];
133
- const seen = new Set();
149
+ const seen = new Map();
134
150
  for (const value of values) {
135
151
  const text = String(value ?? '');
136
152
  for (const fragment of text.includes('=') ? text.split(',') : text.split(/[\s,]+/)) {
@@ -146,7 +162,7 @@ function normalizeModuleSpecs(values) {
146
162
  if (!Array.isArray(values))
147
163
  throw new Error('Module specs must be an array.');
148
164
  const specs = [];
149
- const seen = new Set();
165
+ const seen = new Map();
150
166
  const push = (spec) => pushSpec(specs, seen, spec);
151
167
  for (const value of values) {
152
168
  if (typeof value === 'string') {
@@ -159,10 +175,11 @@ function normalizeModuleSpecs(values) {
159
175
  const module = typeof value.module === 'string' ? value.module.trim().toLowerCase() : '';
160
176
  const version = value.version === undefined ? undefined : String(value.version);
161
177
  const localPath = value.localPath === undefined ? undefined : String(value.localPath);
178
+ const assembly = value.assembly === undefined ? undefined : String(value.assembly);
162
179
  if (!module)
163
180
  throw new Error('Invalid module spec: missing module name.');
164
181
  if (module === allModules) {
165
- if (version !== undefined || localPath !== undefined)
182
+ if (version !== undefined || localPath !== undefined || assembly !== undefined)
166
183
  throw new Error('Module spec all cannot pin a version or point at a local source.');
167
184
  push({ module: allModules });
168
185
  continue;
@@ -171,37 +188,28 @@ function normalizeModuleSpecs(values) {
171
188
  throw new Error(`Invalid version for module ${module}.`);
172
189
  if (version !== undefined && localPath !== undefined)
173
190
  throw new Error(`Module ${module} cannot combine a local path (=) with a pinned version (:).`);
191
+ if (assembly !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(assembly))
192
+ throw new Error(`Invalid assembly file name for module ${module}.`);
193
+ if (assembly !== undefined && localPath === undefined)
194
+ throw new Error(`Module ${module} names an assembly (#) without a local path (=).`);
174
195
  if (version === undefined && localPath === undefined) {
175
196
  push({ module });
176
197
  continue;
177
198
  }
178
- push({ module, ...(version !== undefined ? { version } : {}), ...(localPath !== undefined ? { localPath } : {}) });
199
+ push({ module, ...(version !== undefined ? { version } : {}), ...(localPath !== undefined ? { localPath } : {}), ...(assembly !== undefined ? { assembly } : {}) });
179
200
  }
180
201
  return specs;
181
202
  }
182
- /**
183
- * Rewrite the `<Server><Plugins>` list of a Wyn configuration document.
184
- *
185
- * The document is parsed with the creation path's parser options, only the
186
- * `sys:string` plugin list is mutated, and the result is rebuilt with the same
187
- * builder options and re-validated before it is returned. Dashboard stays the
188
- * first entry; unknown existing entries keep their order.
189
- */
190
- function patchEnvironmentConfiguration(xml, entries) {
191
- if (typeof xml !== 'string' || /<!DOCTYPE|<!ENTITY/i.test(xml) || fast_xml_parser_1.XMLValidator.validate(xml) !== true)
192
- throw new Error('Invalid environment configuration.');
193
- const document = new fast_xml_parser_1.XMLParser(parserOptions).parse(xml);
194
- const server = document?.SystemConfig?.Services?.Server;
195
- if (!server || typeof server !== 'object' || Array.isArray(server))
196
- throw new Error('Environment configuration has no Server service.');
197
- const plugins = server.Plugins ?? {};
198
- if (typeof plugins !== 'object' || Array.isArray(plugins))
199
- throw new Error('Invalid environment configuration.');
200
- const raw = plugins['sys:string'];
203
+ /** Read one `<sys:string>` list, accepting a single string instead of an array. */
204
+ function stringList(raw) {
201
205
  const list = raw === undefined ? [] : Array.isArray(raw) ? [...raw] : [raw];
202
206
  if (list.some(value => typeof value !== 'string'))
203
207
  throw new Error('Invalid environment configuration.');
204
- const strings = list.map(value => value.trim());
208
+ return list;
209
+ }
210
+ /** Replace or append one `<sys:string>` path list, matching by destination or assembly name. */
211
+ function rewritePathList(values, entries) {
212
+ const strings = values.map(value => value.trim());
205
213
  const used = new Set();
206
214
  const appended = [];
207
215
  for (const entry of entries) {
@@ -220,16 +228,64 @@ function patchEnvironmentConfiguration(xml, entries) {
220
228
  strings[index] = windowsPath(entry.path);
221
229
  }
222
230
  }
223
- const combined = [...strings, ...appended];
224
- // Keep Dashboard first even when it is a local build path rather than a deployed one.
225
- const dashboard = combined.findIndex(value => {
226
- const folded = windowsPath(value).toLowerCase();
227
- return folded.startsWith('plugins\\dashboard\\') || path.win32.basename(folded) === 'wyn.dashboards.plugin.dll';
228
- });
229
- if (dashboard > 0)
230
- combined.unshift(combined.splice(dashboard, 1)[0]);
231
- plugins['sys:string'] = combined;
232
- server.Plugins = plugins;
231
+ return [...strings, ...appended];
232
+ }
233
+ /**
234
+ * Rewrite the `<Server><Plugins>`, `<Server><PluginWorkers>` and worker service
235
+ * entries of a Wyn configuration document.
236
+ *
237
+ * The document is parsed with the creation path's parser options, only the
238
+ * `sys:string` path lists and the matching worker service nodes are mutated, and
239
+ * the result is rebuilt with the same builder options and re-validated before it
240
+ * is returned. Dashboard stays the first `Plugins` entry; every other entry keeps
241
+ * its order. A worker service keeps all of its other fields, including
242
+ * `Disabled`; a configuration without that node is updated through
243
+ * `<PluginWorkers>` alone.
244
+ */
245
+ function patchEnvironmentConfiguration(xml, entries) {
246
+ if (typeof xml !== 'string' || /<!DOCTYPE|<!ENTITY/i.test(xml) || fast_xml_parser_1.XMLValidator.validate(xml) !== true)
247
+ throw new Error('Invalid environment configuration.');
248
+ const document = new fast_xml_parser_1.XMLParser(parserOptions).parse(xml);
249
+ const server = document?.SystemConfig?.Services?.Server;
250
+ if (!server || typeof server !== 'object' || Array.isArray(server))
251
+ throw new Error('Environment configuration has no Server service.');
252
+ const pluginEntries = entries.filter(entry => entry.role !== 'worker');
253
+ if (server.Plugins !== undefined || pluginEntries.length) {
254
+ const plugins = server.Plugins === undefined || server.Plugins === '' ? {} : server.Plugins;
255
+ if (typeof plugins !== 'object' || Array.isArray(plugins))
256
+ throw new Error('Invalid environment configuration.');
257
+ const combined = rewritePathList(stringList(plugins['sys:string']), pluginEntries);
258
+ // Keep Dashboard first even when it is a local build path rather than a deployed one.
259
+ const dashboard = combined.findIndex(value => {
260
+ const folded = windowsPath(value).toLowerCase();
261
+ return folded.startsWith('plugins\\dashboard\\') || path.win32.basename(folded) === 'wyn.dashboards.plugin.dll';
262
+ });
263
+ if (dashboard > 0)
264
+ combined.unshift(combined.splice(dashboard, 1)[0]);
265
+ plugins['sys:string'] = combined;
266
+ server.Plugins = plugins;
267
+ }
268
+ const workers = entries.filter(entry => entry.role === 'worker');
269
+ if (workers.length) {
270
+ const pluginWorkers = server.PluginWorkers === undefined || server.PluginWorkers === '' ? {} : server.PluginWorkers;
271
+ if (typeof pluginWorkers !== 'object' || Array.isArray(pluginWorkers))
272
+ throw new Error('Invalid environment configuration.');
273
+ pluginWorkers['sys:string'] = rewritePathList(stringList(pluginWorkers['sys:string']), workers);
274
+ server.PluginWorkers = pluginWorkers;
275
+ const services = document?.SystemConfig?.Services;
276
+ for (const entry of workers) {
277
+ if (!entry.service || !services || typeof services !== 'object' || Array.isArray(services))
278
+ continue;
279
+ const service = services[entry.service];
280
+ if (!service || typeof service !== 'object' || Array.isArray(service))
281
+ continue;
282
+ const node = service;
283
+ // The worker is loaded from the path list above and uses its own directory
284
+ // as its base directory: keep the service in step without touching Disabled.
285
+ node.WorkingDirectory = windowsPath(path.win32.dirname(entry.path));
286
+ node.Assembly = path.win32.basename(entry.path);
287
+ }
288
+ }
233
289
  const output = new fast_xml_parser_1.XMLBuilder(builderOptions).build(document);
234
290
  if (fast_xml_parser_1.XMLValidator.validate(output) !== true)
235
291
  throw new Error('Invalid environment configuration.');
@@ -270,8 +326,37 @@ async function readManifest(root) {
270
326
  throw new Error('Environment manifest is unreadable.');
271
327
  if (manifest.moduleSources !== undefined && (!manifest.moduleSources || typeof manifest.moduleSources !== 'object' || Array.isArray(manifest.moduleSources)))
272
328
  throw new Error('Environment manifest is unreadable.');
329
+ for (const source of Object.values(manifest.moduleSources ?? {})) {
330
+ if (!source || typeof source !== 'object' || Array.isArray(source) || source.type !== 'local' || typeof source.path !== 'string' || !Array.isArray(source.pluginDirectories) || source.pluginDirectories.some(directory => typeof directory !== 'string'))
331
+ throw new Error('Environment manifest is unreadable.');
332
+ if (source.targets !== undefined && (!Array.isArray(source.targets) || source.targets.some(target => !target || typeof target !== 'object' || target.kind !== 'plugin' && target.kind !== 'worker' || typeof target.destination !== 'string' || typeof target.assembly !== 'string' || typeof target.path !== 'string')))
333
+ throw new Error('Environment manifest is unreadable.');
334
+ }
273
335
  return manifest;
274
336
  }
337
+ /**
338
+ * Normalize a recorded module source into targets.
339
+ *
340
+ * The 1.4.0 shape (`path` plus `pluginDirectories`) is still written whenever it
341
+ * describes the state exactly; it always meant one plugin target per recorded
342
+ * directory.
343
+ */
344
+ function localTargetsOf(module, source) {
345
+ if (!source)
346
+ return [];
347
+ if (Array.isArray(source.targets) && source.targets.length)
348
+ return source.targets.map(target => ({ module, kind: target.kind, destination: target.destination, assembly: target.assembly, path: target.path }));
349
+ return (source.pluginDirectories ?? []).map(destination => ({ module, kind: 'plugin', destination, assembly: path.win32.basename(source.path), path: source.path }));
350
+ }
351
+ /** Record the local targets of one module, keeping earlier targets of that module. */
352
+ function manifestSource(module, targets, previous) {
353
+ const overridden = new Set(targets.map(target => target.destination.toLowerCase()));
354
+ const merged = [...localTargetsOf(module, previous).filter(target => !overridden.has(target.destination.toLowerCase())), ...targets];
355
+ const source = { type: 'local', path: merged[0].path, pluginDirectories: merged.filter(target => target.kind === 'plugin').map(target => target.destination) };
356
+ if (merged.length > 1 || merged[0].kind !== 'plugin')
357
+ source.targets = merged.map(({ kind, destination, assembly, path: file }) => ({ kind, destination, assembly, path: file }));
358
+ return source;
359
+ }
275
360
  /** Write JSON (or any text) through `<file>.afm-tmp` and an atomic rename. */
276
361
  async function writeAtomic(file, content) {
277
362
  const temporary = `${file}.afm-tmp`;
@@ -348,45 +433,98 @@ async function stageRemoteArtifact(client, artifact, workspace, onProgress) {
348
433
  throw new Error(`Module update failed for ${artifact.plugin}/${artifact.version}/${artifact.name} (download, archive or layout): ${error.message}`);
349
434
  }
350
435
  }
351
- /** Resolve and validate a local plugin source for one module. */
352
- async function resolveLocalSource(entry, module, mappings, input) {
353
- const plugins = mappings.filter(mapping => mapping.role === 'plugin');
354
- if (plugins.length !== 1)
355
- throw new Error(`Local sources are not supported for module ${module}: it deploys ${plugins.length} plugin directories (${plugins.map(mapping => mapping.destination).join(', ')}). Use a remote update instead.`);
356
- const mapping = plugins[0];
357
- const target = path.resolve(input);
358
- const stat = await fs.lstat(target).catch((error) => { if (error.code === 'ENOENT')
359
- return undefined; throw error; });
436
+ /** Mappings of one module that a local source may override. */
437
+ const localMappings = (module) => catalog_1.catalog[module].mappings.filter(mapping => mapping.role === 'plugin' || mapping.role === 'worker');
438
+ const expectedAssemblies = (module) => [...new Set(localMappings(module).flatMap(mapping => mapping.assemblies))];
439
+ const lstatOrUndefined = (target) => fs.lstat(target).catch((error) => { if (error.code === 'ENOENT')
440
+ return undefined; throw error; });
441
+ /** Locate the single mapping of `module` whose known assemblies include `assembly`. */
442
+ function mappingForAssembly(module, assembly) {
443
+ const wanted = assembly.toLowerCase();
444
+ const matches = localMappings(module).filter(mapping => mapping.assemblies.some(name => name.toLowerCase() === wanted));
445
+ if (matches.length !== 1)
446
+ throw new Error(`Local target ${assembly} is not one assembly of module ${module}. Expected one of ${expectedAssemblies(module).join(', ')}.`);
447
+ return { mapping: matches[0], assembly: matches[0].assemblies.find(name => name.toLowerCase() === wanted) };
448
+ }
449
+ /**
450
+ * Resolve one local source into the module targets it overrides.
451
+ *
452
+ * A DLL selects the mapping whose assembly file name it matches. A directory
453
+ * selects every mapping that holds exactly one of its assemblies, so a partial
454
+ * developer build only overrides what it really contains; the other mappings are
455
+ * reported as inactive and stay deployed. `module#<assembly>=` names the target
456
+ * explicitly, for a build whose file name matches no assembly at all.
457
+ */
458
+ async function resolveLocalSource(entry, module, assembly, input) {
459
+ const mappings = localMappings(module);
460
+ const source = path.resolve(input);
461
+ const stat = await lstatOrUndefined(source);
360
462
  if (!stat)
361
- throw new Error(`Local source not found: ${target}.`);
362
- let dll;
463
+ throw new Error(`Local source not found: ${source}.`);
363
464
  if (stat.isSymbolicLink())
364
- throw new Error(`Local source must be a regular file or directory: ${target}.`);
465
+ throw new Error(`Local source must be a regular file or directory: ${source}.`);
466
+ const selected = [];
467
+ const inactive = [];
365
468
  if (stat.isFile()) {
366
- if (path.extname(target).toLowerCase() !== '.dll')
367
- throw new Error(`Local source must be a .dll file: ${target}.`);
368
- dll = target;
469
+ if (path.extname(source).toLowerCase() !== '.dll')
470
+ throw new Error(`Local source must be a .dll file: ${source}.`);
471
+ if (assembly === undefined) {
472
+ const name = path.win32.basename(source).toLowerCase();
473
+ const matches = mappings.filter(mapping => mapping.assemblies.some(known => known.toLowerCase() === name));
474
+ if (matches.length !== 1)
475
+ throw new Error(`Local source ${source} matches ${matches.length} targets of module ${module}. Expected one of ${expectedAssemblies(module).join(', ')}; select one explicitly with ${module}#<assembly>=<path>.`);
476
+ selected.push({ mapping: matches[0], assembly: matches[0].assemblies.find(known => known.toLowerCase() === name), path: source });
477
+ inactive.push(...mappings.filter(mapping => mapping !== matches[0]).map(mapping => mapping.destination));
478
+ }
479
+ else {
480
+ const named = mappingForAssembly(module, assembly);
481
+ // The explicit form exists for a build whose file name matches no assembly;
482
+ // a file that is another target's assembly is still a mistake.
483
+ const basename = path.win32.basename(source);
484
+ const conflicts = mappings.filter(mapping => mapping !== named.mapping && mapping.assemblies.some(known => known.toLowerCase() === basename.toLowerCase()));
485
+ if (conflicts.length)
486
+ throw new Error(`Local source ${source} is an assembly of ${conflicts.map(mapping => mapping.destination).join(', ')}, not of ${named.mapping.destination}: ${module}#${assembly}= selects ${named.mapping.destination}.`);
487
+ selected.push({ mapping: named.mapping, assembly: named.assembly, path: source });
488
+ inactive.push(...mappings.filter(mapping => mapping !== named.mapping).map(mapping => mapping.destination));
489
+ }
369
490
  }
370
491
  else if (stat.isDirectory()) {
371
- const found = [];
372
- for (const assembly of mapping.assemblies) {
373
- const candidate = path.join(target, assembly);
374
- const candidateStat = await fs.lstat(candidate).catch((error) => { if (error.code === 'ENOENT')
375
- return undefined; throw error; });
376
- if (candidateStat?.isFile() && !candidateStat.isSymbolicLink())
377
- found.push(candidate);
492
+ const named = assembly === undefined ? undefined : mappingForAssembly(module, assembly);
493
+ for (const mapping of named ? [named.mapping] : mappings) {
494
+ const present = [];
495
+ for (const name of named ? [named.assembly] : mapping.assemblies) {
496
+ const candidate = path.join(source, name);
497
+ const candidateStat = await lstatOrUndefined(candidate);
498
+ if (candidateStat?.isFile() && !candidateStat.isSymbolicLink())
499
+ present.push({ assembly: name, path: candidate });
500
+ }
501
+ if (present.length === 1)
502
+ selected.push({ mapping, ...present[0] });
503
+ else
504
+ inactive.push(mapping.destination);
505
+ }
506
+ // An explicit selection leaves every other mapping of the module deployed.
507
+ if (named)
508
+ inactive.push(...mappings.filter(mapping => mapping !== named.mapping).map(mapping => mapping.destination));
509
+ if (!selected.length) {
510
+ if (named)
511
+ throw new Error(`Local source directory ${source} does not contain ${named.assembly}.`);
512
+ throw new Error(`Local source directory ${source} must contain exactly one of ${expectedAssemblies(module).join(', ')} for module ${module}; select one explicitly with ${module}#<assembly>=<path>.`);
378
513
  }
379
- if (found.length !== 1)
380
- throw new Error(`Local source directory ${target} must contain exactly one of ${mapping.assemblies.join(', ')}; found ${found.length}.`);
381
- dll = found[0];
382
514
  }
383
515
  else
384
- throw new Error(`Local source must be a regular .dll file or a directory: ${target}.`);
385
- const deployed = installationDirectory(entry.root, mapping.destination);
386
- const relative = path.relative(deployed.toLowerCase(), dll.toLowerCase());
387
- if (relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative))
388
- throw new Error(`Local source ${dll} is inside the deployed plugin directory ${deployed}; point at the developer build output outside the environment.`);
389
- return { dll, pluginDirectories: [mapping.destination] };
516
+ throw new Error(`Local source must be a regular .dll file or a directory: ${source}.`);
517
+ const targets = selected.map(item => {
518
+ const deployed = installationDirectory(entry.root, item.mapping.destination);
519
+ const relative = path.relative(deployed.toLowerCase(), item.path.toLowerCase());
520
+ if (relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative))
521
+ throw new Error(`Local source ${item.path} is inside the deployed ${item.mapping.role === 'worker' ? 'worker' : 'plugin'} directory ${deployed}; point at the developer build output outside the environment.`);
522
+ const target = { module, kind: item.mapping.role, destination: item.mapping.destination, assembly: item.assembly, path: item.path };
523
+ if (item.mapping.service)
524
+ target.service = item.mapping.service;
525
+ return target;
526
+ });
527
+ return { targets, inactive, source };
390
528
  }
391
529
  /**
392
530
  * Update selected modules of a registered environment.
@@ -442,12 +580,23 @@ class EnvironmentUpdater {
442
580
  onProgress?.(`Updating all configured modules from remote: ${selected.join(', ')}${skipped.length ? ` (skipping local: ${skipped.map(item => item.module).join(', ')})` : ''}`);
443
581
  }
444
582
  const localOverrides = new Map();
583
+ const selectedTargets = new Set();
445
584
  for (const spec of parsed) {
446
585
  if (spec.module === 'sheets' && entry.edition !== 'en')
447
586
  throw new Error(`Module sheets requires an EN edition environment; ${entry.name} is ${entry.edition}.`);
448
587
  if (spec.localPath === undefined)
449
588
  continue;
450
- localOverrides.set(spec.module, await resolveLocalSource(entry, spec.module, catalog_1.catalog[spec.module].mappings, spec.localPath));
589
+ const resolution = await resolveLocalSource(entry, spec.module, spec.assembly, spec.localPath);
590
+ for (const destination of resolution.inactive)
591
+ onProgress?.(`${spec.module}: ${destination} stays deployed (not selected by ${resolution.source})`);
592
+ const targets = localOverrides.get(spec.module) ?? [];
593
+ for (const target of resolution.targets) {
594
+ if (selectedTargets.has(target.destination.toLowerCase()))
595
+ throw new Error(`Local target ${target.assembly} of module ${target.module} is selected twice in one update; give one spec per assembly.`);
596
+ selectedTargets.add(target.destination.toLowerCase());
597
+ targets.push(target);
598
+ }
599
+ localOverrides.set(spec.module, targets);
451
600
  }
452
601
  const remote = parsed.filter(spec => spec.localPath === undefined);
453
602
  const state = await (0, validate_1.observeRuntimeState)(entry, this.processes);
@@ -476,26 +625,33 @@ class EnvironmentUpdater {
476
625
  }
477
626
  const configured = await fs.readFile(path.join(entry.root, 'conf', 'Wyn.conf'), 'utf8');
478
627
  const plans = [];
479
- for (const spec of parsed) {
480
- const module = spec.module;
628
+ // A module may carry several local specs: plan it once, for all its targets.
629
+ for (const module of [...new Set(parsed.map(spec => spec.module))]) {
481
630
  const artifact = artifacts.find(candidate => candidate.module === module);
482
631
  const previous = moduleSources[module] ? { local: moduleSources[module].path } : { version: artifact?.version };
483
- const pluginMappings = catalog_1.catalog[module].mappings.filter(mapping => mapping.role === 'plugin');
484
632
  const override = localOverrides.get(module);
485
633
  if (override) {
486
634
  const removals = [];
487
635
  const removedDirectories = [];
488
- for (const mapping of pluginMappings) {
489
- const destination = installationDirectory(entry.root, mapping.destination);
636
+ for (const target of override) {
637
+ const destination = installationDirectory(entry.root, target.destination);
490
638
  if (!await exists(destination))
491
639
  continue;
492
640
  removals.push(destination);
493
- removedDirectories.push(mapping.destination);
641
+ removedDirectories.push(target.destination);
494
642
  }
643
+ const entries = override.map(target => {
644
+ const entry = { destination: target.destination, assemblies: [target.assembly], path: target.path };
645
+ if (target.kind === 'worker') {
646
+ entry.role = 'worker';
647
+ if (target.service)
648
+ entry.service = target.service;
649
+ }
650
+ return entry;
651
+ });
495
652
  plans.push({
496
- module, previous, next: { local: override.dll }, local: override,
497
- entries: pluginMappings.map(mapping => ({ destination: mapping.destination, assemblies: mapping.assemblies, path: override.dll })),
498
- copies: [], removals, copiedDirectories: [], removedDirectories,
653
+ module, previous, next: { local: override.map(target => target.path).join(', ') }, local: override,
654
+ entries, copies: [], removals, copiedDirectories: [], removedDirectories,
499
655
  });
500
656
  continue;
501
657
  }
@@ -516,8 +672,17 @@ class EnvironmentUpdater {
516
672
  copies.push({ source: path.join(stagedArtifact.expanded, mapping.source), destination });
517
673
  copiedDirectories.push(mapping.destination);
518
674
  const assembly = stagedArtifact?.assemblies.get(mapping.destination) ?? knownAssembly(configured, mapping);
519
- if (mapping.role === 'plugin')
520
- entries.push({ destination: mapping.destination, assemblies: mapping.assemblies, path: path.posix.join(mapping.destination, assembly) });
675
+ // Plugins stay relative to the environment root; a worker entry does too,
676
+ // and its service keeps using the deployed destination as working directory.
677
+ if (mapping.role === 'plugin' || mapping.role === 'worker') {
678
+ const entry = { destination: mapping.destination, assemblies: mapping.assemblies, path: path.posix.join(mapping.destination, assembly) };
679
+ if (mapping.role === 'worker') {
680
+ entry.role = 'worker';
681
+ if (mapping.service)
682
+ entry.service = mapping.service;
683
+ }
684
+ entries.push(entry);
685
+ }
521
686
  }
522
687
  if (module === 'dashboard' && stagedArtifact) {
523
688
  const contents = path.join(stagedArtifact.expanded, 'Web Contents');
@@ -562,7 +727,7 @@ class EnvironmentUpdater {
562
727
  delete moduleSources[plan.module];
563
728
  }
564
729
  if (plan.local)
565
- moduleSources[plan.module] = { type: 'local', path: plan.local.dll, pluginDirectories: plan.local.pluginDirectories };
730
+ moduleSources[plan.module] = manifestSource(plan.module, plan.local, moduleSources[plan.module]);
566
731
  if (!installed.has(plan.module) && plan.module !== 'common' && selectedModules)
567
732
  selectedModules.push(plan.module);
568
733
  }
@@ -610,7 +775,7 @@ class EnvironmentUpdater {
610
775
  const restartRequired = !dryRun && changes.length > 0;
611
776
  if (restartRequired)
612
777
  onProgress?.(`${entry.name}: modules updated; restart the environment to load the change (no service was started).`);
613
- return { name: entry.name, root: entry.root, dryRun, changes, skipped, restartRequired, warnings };
778
+ return { name: entry.name, root: entry.root, dryRun, changes, skipped, restartRequired, warnings, localTargets: plans.flatMap(plan => plan.local ?? []) };
614
779
  }
615
780
  finally {
616
781
  if (workspace)
@@ -623,7 +788,8 @@ exports.EnvironmentUpdater = EnvironmentUpdater;
623
788
  function knownAssembly(xml, mapping) {
624
789
  try {
625
790
  const document = new fast_xml_parser_1.XMLParser(parserOptions).parse(xml);
626
- const raw = document?.SystemConfig?.Services?.Server?.Plugins?.['sys:string'];
791
+ const server = document?.SystemConfig?.Services?.Server;
792
+ const raw = mapping.role === 'worker' ? server?.PluginWorkers?.['sys:string'] : server?.Plugins?.['sys:string'];
627
793
  const list = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw];
628
794
  const prefix = windowsPath(mapping.destination).toLowerCase() + '\\';
629
795
  for (const value of list) {