@underpostnet/cyberia 3.3.73 → 3.3.76

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 (228) hide show
  1. package/.env.example +17 -5
  2. package/.github/workflows/coverall.cyberia.ci.yml +5 -0
  3. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  4. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  5. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  6. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  7. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  8. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  9. package/.github/workflows/publish.cyberia.ci.yml +44 -0
  10. package/AGENTS.md +61 -12
  11. package/CHANGELOG.md +126 -41
  12. package/CLI-HELP.md +19 -12
  13. package/Dockerfile +1 -1
  14. package/Dockerfile.dev +1 -1
  15. package/Dockerfile.test +2 -2
  16. package/README.md +28 -28
  17. package/bin/build.js +37 -17
  18. package/bin/cyberia.js +1247 -1373
  19. package/bin/index.js +1247 -1373
  20. package/compose.env +17 -5
  21. package/conf.js +7 -3
  22. package/deploy/cyberia-client/deploy.sh +10 -11
  23. package/deploy/cyberia-client/package.sh +0 -1
  24. package/deploy/cyberia-client/state.sh +3 -2
  25. package/deploy/cyberia-server/deploy.sh +10 -11
  26. package/deploy/cyberia-server/package.sh +0 -1
  27. package/deploy/cyberia-server/state.sh +3 -2
  28. package/deploy/dd-cyberia/deploy.sh +5 -5
  29. package/deploy/dd-cyberia/init.sh +15 -14
  30. package/deploy/dd-cyberia/package.sh +0 -1
  31. package/deploy/dd-cyberia/state.sh +2 -2
  32. package/deploy/dd-cyberia/sync-deploy.sh +139 -75
  33. package/deploy/lib/config.sh +29 -0
  34. package/deploy/lib/github-actions-logging.sh +92 -76
  35. package/deploy/lib/host.sh +27 -18
  36. package/deploy/lib/state.sh +4 -1
  37. package/deploy/pwa-microservices-template/deploy.sh +10 -8
  38. package/deploy/release/deploy.sh +0 -2
  39. package/deployment.yaml +1 -1
  40. package/docker-compose.yml +23 -17
  41. package/hardhat/package-lock.json +2 -2
  42. package/hardhat/package.json +1 -1
  43. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  44. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  45. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  46. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  47. package/manifests/deployment/mongo-express/deployment.yaml +46 -27
  48. package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
  49. package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
  50. package/manifests/deployment/mongo-express/service.yaml +16 -0
  51. package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
  52. package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
  53. package/package.json +19 -17
  54. package/scripts/test-monitor.sh +3 -3
  55. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
  56. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
  57. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
  58. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
  59. package/src/api/cyberia-action/cyberia-action.router.js +2 -2
  60. package/src/api/cyberia-action/cyberia-action.service.js +3 -4
  61. package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
  62. package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
  63. package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
  64. package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
  65. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
  66. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
  67. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
  68. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
  69. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
  70. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  71. package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
  72. package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
  73. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
  74. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
  75. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
  76. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
  77. package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
  78. package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
  79. package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
  80. package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
  81. package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
  82. package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
  83. package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
  84. package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
  85. package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
  86. package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
  87. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
  88. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
  89. package/src/api/cyberia-map/cyberia-map.service.js +6 -2
  90. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
  91. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
  92. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
  93. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
  94. package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
  95. package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
  96. package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
  97. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
  98. package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
  99. package/src/api/file/file.ref.js +153 -0
  100. package/src/api/file/file.ref.json +11 -2
  101. package/src/api/object-layer/object-layer.model.js +47 -12
  102. package/src/api/object-layer/object-layer.router.js +10 -3
  103. package/src/api/object-layer/object-layer.service.js +113 -120
  104. package/src/cli/app.js +12 -1
  105. package/src/cli/client.js +2 -2
  106. package/src/cli/cluster.js +7 -0
  107. package/src/cli/deploy.js +4 -4
  108. package/src/cli/docker-compose.js +1 -1
  109. package/src/cli/fs-selection.js +115 -0
  110. package/src/cli/fs.js +158 -342
  111. package/src/cli/index.js +22 -10
  112. package/src/cli/repository.js +28 -0
  113. package/src/cli/run.js +71 -197
  114. package/src/cli/test.js +4 -0
  115. package/src/cli/wireguard.js +32 -31
  116. package/src/client/components/core/Docs.js +77 -100
  117. package/src/client/components/core/Modal.js +56 -1
  118. package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
  119. package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
  120. package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
  121. package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
  122. package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
  123. package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
  124. package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
  125. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
  126. package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
  127. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
  128. package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
  129. package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
  130. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
  131. package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
  132. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
  133. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
  134. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
  135. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
  136. package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
  137. package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
  138. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  139. package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
  140. package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
  141. package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
  142. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
  143. package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
  144. package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
  145. package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
  146. package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
  147. package/src/client/services/object-layer/object-layer.management.js +6 -10
  148. package/src/client/services/object-layer/object-layer.service.js +3 -1
  149. package/src/client-builder/client-build-docs.js +52 -52
  150. package/src/client-builder/client-build.js +67 -44
  151. package/src/client-builder/client-bundle.js +347 -0
  152. package/src/db/mongo/MongoExpress.js +187 -0
  153. package/src/grpc/cyberia/grpc-server.js +5 -7
  154. package/src/index.js +1 -1
  155. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
  156. package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
  157. package/src/projects/cyberia/besu-genesis-generator.js +5 -14
  158. package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
  159. package/src/projects/cyberia/catalog-cyberia.js +3 -2
  160. package/src/projects/cyberia/gemini-client.js +3 -3
  161. package/src/projects/cyberia/generate-saga.js +4 -11
  162. package/src/projects/cyberia/hot-reload-trigger.js +17 -28
  163. package/src/projects/cyberia/instance-backup.js +210 -0
  164. package/src/projects/cyberia/instance-data.js +177 -257
  165. package/src/projects/cyberia/map-preview-generator.js +32 -68
  166. package/src/projects/cyberia/object-layer.js +152 -319
  167. package/src/projects/cyberia/seed-audio.js +139 -0
  168. package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
  169. package/src/projects/cyberia/semantic-layer-generator.js +2 -18
  170. package/src/projects/cyberia/stat-balance.js +81 -0
  171. package/src/projects/cyberia/stat-commands.js +23 -0
  172. package/src/projects/cyberia/stat-contract-generator.js +101 -0
  173. package/src/runtime/cyberia-client/Dockerfile +8 -4
  174. package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
  175. package/src/runtime/cyberia-server/Dockerfile +9 -2
  176. package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
  177. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  178. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  179. package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
  180. package/src/runtime/engine-cyberia/compose.env +17 -5
  181. package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
  182. package/src/server/build/coverage.js +113 -42
  183. package/src/server/build/package.js +55 -12
  184. package/src/server/build/testing.js +59 -9
  185. package/src/server/network/middlewares.js +8 -2
  186. package/src/server/ops/logger.js +70 -35
  187. package/src/server/runtime/conf.js +4 -2
  188. package/src/server/storage/data-query.js +16 -0
  189. package/src/server/storage/downloader.js +62 -14
  190. package/src/server/storage/zip.js +153 -0
  191. package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
  192. package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
  193. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
  194. package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
  195. package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
  196. package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
  197. package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
  198. package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
  199. package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
  200. package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
  201. package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
  202. package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
  203. package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
  204. package/test/integration/app/cyberia/seed-audio.test.js +275 -0
  205. package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
  206. package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
  207. package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
  208. package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
  209. package/test/unit/client-build-docs.test.js +97 -70
  210. package/test/unit/client-bundle.test.js +313 -0
  211. package/test/unit/conf-resolution.test.js +17 -0
  212. package/test/unit/coverage-artifact.test.js +120 -29
  213. package/test/unit/cyberia/instance-backup.test.js +183 -0
  214. package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
  215. package/test/unit/cyberia/publish-workflow.test.js +22 -0
  216. package/test/unit/cyberia/stat-balance.test.js +69 -0
  217. package/test/unit/deploy-log-table.test.js +76 -5
  218. package/test/unit/downloader-integrity.test.js +98 -0
  219. package/test/unit/file-reference-registry.test.js +141 -0
  220. package/test/unit/fs-storage-paths.test.js +596 -0
  221. package/test/unit/logger-redaction.test.js +29 -0
  222. package/test/unit/package.test.js +70 -16
  223. package/test/unit/prepare-host.test.js +84 -1
  224. package/test/unit/test-tiers.test.js +27 -0
  225. package/test/unit/zip-archive.test.js +142 -0
  226. package/vitest.config.js +11 -3
  227. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
  228. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
@@ -15,13 +15,18 @@ import { loggerFactory } from '../../server/ops/logger.js';
15
15
  import { ObjectLayerRenderFramesDto } from '../object-layer-render-frames/object-layer-render-frames.model.js';
16
16
  import { FileFactory } from '../file/file.service.js';
17
17
  import fs from 'fs-extra';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import sharp from 'sharp';
21
+ import { validateStats } from '../../client/components/cyberia/SharedDefaultsCyberia.js';
18
22
  import { ObjectLayerDto } from './object-layer.model.js';
19
23
  import { ObjectLayerEngine } from '../../projects/cyberia/object-layer.js';
20
24
  import { shellExec } from '../../server/runtime/process.js';
21
25
  import { DataQuery } from '../../server/storage/data-query.js';
22
26
  import { AtlasSpriteSheetService } from '../atlas-sprite-sheet/atlas-sprite-sheet.service.js';
27
+ import { AtlasSpriteSheetStore } from '../../projects/cyberia/atlas-sprite-sheet-store.js';
23
28
  import { IpfsClient } from '../../projects/cyberia/ipfs-client.js';
24
- import { createPinRecord, removePinRecordsAndUnpin } from '../ipfs/ipfs.service.js';
29
+ import { removePinRecordsAndUnpin } from '../ipfs/ipfs.service.js';
25
30
 
26
31
  /**
27
32
  * Logger instance for this module.
@@ -45,8 +50,8 @@ class ObjectLayerService {
45
50
  * - `/metadata/:itemType/:itemId` — Create an object layer from uploaded frames and metadata.
46
51
  * - Default — Create an object layer directly from the request body.
47
52
  *
48
- * The `/metadata` and default routes delegate to {@link ObjectLayerEngine.createObjectLayerDocuments}
49
- * for document creation, atlas generation, SHA-256 computation, and IPFS pinning.
53
+ * The `/metadata` and default routes delegate to {@link ObjectLayerEngine.persistObjectLayerDocuments}
54
+ * for the document write, atlas generation, SHA-256 computation, and IPFS pinning.
50
55
  *
51
56
  * @async
52
57
  * @function post
@@ -160,22 +165,12 @@ class ObjectLayerService {
160
165
  metadataOverride: req.body,
161
166
  });
162
167
 
163
- const { objectLayer } = await ObjectLayerEngine.createObjectLayerDocuments({
168
+ const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
164
169
  ObjectLayer,
165
170
  ObjectLayerRenderFrames,
166
171
  objectLayerRenderFramesData,
167
172
  objectLayerData,
168
- createOptions: {
169
- generateAtlas: true,
170
- atlasServiceContext: {
171
- req,
172
- res,
173
- options,
174
- AtlasSpriteSheetService,
175
- IpfsClient,
176
- createPinRecord,
177
- },
178
- },
173
+ persistOptions: { options },
179
174
  });
180
175
 
181
176
  return objectLayer;
@@ -191,44 +186,32 @@ class ObjectLayerService {
191
186
  bodyData.data.render.cid = '';
192
187
  bodyData.data.render.metadataCid = '';
193
188
 
194
- // If has render frames, generate atlas + CIDs BEFORE creating the ObjectLayer
189
+ // Stage the atlas CIDs before the write, so the document is never stored without them.
195
190
  if (bodyData.objectLayerRenderFramesId) {
196
191
  const renderFramesDoc = await ObjectLayerRenderFrames.findById(bodyData.objectLayerRenderFramesId);
197
192
  if (renderFramesDoc) {
198
193
  try {
199
- const stagingOL = {
200
- data: bodyData.data,
201
- objectLayerRenderFramesId: renderFramesDoc,
202
- };
203
- const result = await AtlasSpriteSheetService.generate(
204
- { objectLayer: stagingOL, auth: req.auth },
205
- res,
194
+ const { atlasDoc, atlasCid, atlasMetadataCid } = await AtlasSpriteSheetStore.persist({
195
+ itemKey: bodyData.data.item.id,
196
+ objectLayerRenderFrames: renderFramesDoc,
206
197
  options,
207
- { skipObjectLayerSave: true },
208
- );
209
- bodyData.data.render.cid = result.atlasCid;
210
- bodyData.data.render.metadataCid = result.atlasMetadataCid;
211
- bodyData.atlasSpriteSheetId = result.atlasDoc._id;
198
+ });
199
+ bodyData.data.render.cid = atlasCid;
200
+ bodyData.data.render.metadataCid = atlasMetadataCid;
201
+ bodyData.atlasSpriteSheetId = atlasDoc._id;
212
202
  } catch (atlasError) {
213
203
  logger.error('Failed to auto-generate atlas for new ObjectLayer:', atlasError);
214
204
  }
215
205
  }
216
206
  }
217
207
 
218
- // Pin data JSON to IPFS
219
- try {
220
- const itemId = bodyData.data.item.id;
221
- const mfsPath = `/object-layer/${itemId}/${itemId}_data.json`;
222
- const ipfsResult = await IpfsClient.addJsonToIpfs(bodyData.data, `${itemId}_data.json`, mfsPath);
223
- if (ipfsResult) {
224
- bodyData.cid = ipfsResult.cid;
225
- await createPinRecord({ cid: ipfsResult.cid, resourceType: 'object-layer-data', mfsPath, options });
226
- }
227
- } catch (ipfsError) {
228
- logger.warn('Failed to pin data JSON to IPFS:', ipfsError.message);
229
- }
208
+ const dataCid = await ObjectLayerEngine.pinObjectLayerData({
209
+ data: bodyData.data,
210
+ itemId: bodyData.data.item.id,
211
+ options,
212
+ });
213
+ if (dataCid) bodyData.cid = dataCid;
230
214
 
231
- // Atomic create/replace – ObjectLayer is fully populated with all CIDs
232
215
  return await (await ObjectLayer.upsertByItemId(bodyData)).populate('objectLayerRenderFramesId');
233
216
  };
234
217
 
@@ -257,25 +240,33 @@ class ObjectLayerService {
257
240
  /** @type {import('./object-layer.model.js').ObjectLayerModel} */
258
241
  const ObjectLayer = DataBaseProviderService.getModel('ObjectLayer', options);
259
242
 
260
- // GET /search-item-ids?q=<partial> - Fast partial match search on data.item.id
243
+ // Object layers are addressed by item id, the key the editor navigates by.
244
+ const keyFilter = (key) => DataQuery.naturalKeyFilter('data.item.id', key);
245
+
246
+ // GET /search-item-ids - item identity, by prefix (`q`, for type-ahead) or by exact ids
247
+ // (`ids`, comma separated). Carries the item's type because that is what addresses its
248
+ // sprite directory; an id alone cannot be previewed.
261
249
  if (req.path.startsWith('/search-item-ids')) {
262
250
  const q = (req.query.q || '').trim();
263
- if (!q) return { itemIds: [] };
264
- // Escape regex special characters for safe partial matching
265
- const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
266
- const results = await ObjectLayer.find(
267
- { 'data.item.id': { $regex: escaped, $options: 'i' } },
268
- { 'data.item.id': 1, _id: 0 },
269
- )
270
- .limit(20)
251
+ const ids = String(req.query.ids || '')
252
+ .split(',')
253
+ .map((id) => id.trim())
254
+ .filter(Boolean);
255
+ if (!q && ids.length === 0) return { items: [] };
256
+ const query = ids.length
257
+ ? { 'data.item.id': { $in: ids } }
258
+ : // Escape regex special characters for safe partial matching
259
+ { 'data.item.id': { $regex: q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' } };
260
+ const results = await ObjectLayer.find(query, { 'data.item.id': 1, 'data.item.type': 1, _id: 0 })
261
+ .limit(ids.length ? ids.length : 20)
271
262
  .lean();
272
- const itemIds = [...new Set(results.map((r) => r.data.item.id))];
273
- return { itemIds };
263
+ const byId = new Map(results.map((r) => [r.data.item.id, r.data.item.type || '']));
264
+ return { items: [...byId].map(([id, type]) => ({ id, type })) };
274
265
  }
275
266
 
276
267
  // GET /frame-counts/:id - Get frame counts for each direction using numeric codes
277
268
  if (req.path.startsWith('/frame-counts/')) {
278
- const objectLayer = await ObjectLayer.findById(req.params.id)
269
+ const objectLayer = await ObjectLayer.findOne(keyFilter(req.params.id))
279
270
  .select(ObjectLayerDto.select.getMetadata())
280
271
  .populate('objectLayerRenderFramesId');
281
272
  if (!objectLayer) {
@@ -318,7 +309,7 @@ class ObjectLayerService {
318
309
 
319
310
  // GET /render/:id - Get only render data for specific object layer
320
311
  if (req.path.startsWith('/render/')) {
321
- const objectLayer = await ObjectLayer.findById(req.params.id)
312
+ const objectLayer = await ObjectLayer.findOne(keyFilter(req.params.id))
322
313
  .select(ObjectLayerDto.select.getRender())
323
314
  .populate('objectLayerRenderFramesId');
324
315
  if (!objectLayer) {
@@ -333,7 +324,7 @@ class ObjectLayerService {
333
324
 
334
325
  // GET /metadata/:id - Get only metadata (no render frames/colors) for specific object layer
335
326
  if (req.path.startsWith('/metadata/')) {
336
- const objectLayer = await ObjectLayer.findById(req.params.id)
327
+ const objectLayer = await ObjectLayer.findOne(keyFilter(req.params.id))
337
328
  .select(ObjectLayerDto.select.getMetadata())
338
329
  .populate('objectLayerRenderFramesId', ObjectLayerRenderFramesDto.select.get());
339
330
  if (!objectLayer) {
@@ -385,22 +376,14 @@ class ObjectLayerService {
385
376
  }
386
377
 
387
378
  // GET / - Get paginated list of object layers
388
- const id = req.params.id || req.query.id;
389
- logger.info(`ObjectLayerService.get - filtering check - id: ${id}`);
390
- if (id && id !== 'undefined' && !['render', 'metadata', 'frame-counts'].includes(id)) {
391
- try {
392
- const objectLayer = await ObjectLayer.findById(id)
393
- .select(ObjectLayerDto.select.get())
394
- .populate('atlasSpriteSheetId', 'cid')
395
- .populate('objectLayerRenderFramesId', ObjectLayerRenderFramesDto.select.get());
396
- if (objectLayer) {
397
- logger.info(`ObjectLayerService.get - found record by id: ${id}`);
398
- return { data: [objectLayer], total: 1, page: 1, totalPages: 1 };
399
- }
400
- logger.warn(`ObjectLayerService.get - record NOT found for id: ${id}`);
401
- } catch (e) {
402
- logger.warn(`Invalid ID format or not found: ${id}`);
403
- }
379
+ const key = req.params.id || req.query.id;
380
+ if (key && key !== 'undefined' && !['render', 'metadata', 'frame-counts'].includes(key)) {
381
+ const objectLayer = await ObjectLayer.findOne(keyFilter(key))
382
+ .select(ObjectLayerDto.select.get())
383
+ .populate('atlasSpriteSheetId', 'cid')
384
+ .populate('objectLayerRenderFramesId', ObjectLayerRenderFramesDto.select.get());
385
+ if (objectLayer) return { data: [objectLayer], total: 1, page: 1, totalPages: 1 };
386
+ logger.warn(`ObjectLayerService.get - no object layer for key: ${key}`);
404
387
  }
405
388
 
406
389
  const { query, sort, skip, limit, page } = DataQuery.parse(req.query);
@@ -493,9 +476,14 @@ class ObjectLayerService {
493
476
  // Create temporary output file path
494
477
  const tempOutputPath = `${framesFolder}/${tmpOutputFileName}`;
495
478
 
479
+ // img2webp takes one frame size, and frames of one direction may differ: the game
480
+ // stretches each to the entity's cell box, so the preview stretches them to the
481
+ // largest one — nearest-neighbour, since a scaled frame is still pixel art.
482
+ const stagedFolder = await ObjectLayerService.stageUniformFrames(framesFolder, pngFiles);
483
+
496
484
  try {
497
485
  // Change to the frames directory and execute img2webp command
498
- const cmd = `cd "${framesFolder}" && img2webp -d ${frameDuration} -loop 0 *.png -o ${tmpOutputFileName}`;
486
+ const cmd = `cd "${stagedFolder ?? framesFolder}" && img2webp -d ${frameDuration} -loop 0 *.png -o "${path.resolve(tempOutputPath)}"`;
499
487
 
500
488
  logger.info(`Executing command: ${cmd}`);
501
489
 
@@ -546,9 +534,41 @@ class ObjectLayerService {
546
534
  }
547
535
 
548
536
  throw error;
537
+ } finally {
538
+ if (stagedFolder) await fs.remove(stagedFolder);
549
539
  }
550
540
  };
551
541
 
542
+ /**
543
+ * Stages copies of the frames resized to one common size when they differ, or returns null
544
+ * when they already share one and can be encoded in place.
545
+ * @function stageUniformFrames
546
+ * @memberof ObjectLayerService
547
+ * @param {string} framesFolder - Directory holding the direction's PNG frames.
548
+ * @param {string[]} pngFiles - Frame file names, in animation order.
549
+ * @returns {Promise<string|null>} Temporary directory with the resized frames, or null.
550
+ */
551
+ static stageUniformFrames = async (framesFolder, pngFiles) => {
552
+ const sizes = await Promise.all(
553
+ pngFiles.map(async (file) => {
554
+ const { width, height } = await sharp(`${framesFolder}/${file}`).metadata();
555
+ return { file, width, height };
556
+ }),
557
+ );
558
+ const width = Math.max(...sizes.map((size) => size.width));
559
+ const height = Math.max(...sizes.map((size) => size.height));
560
+ if (sizes.every((size) => size.width === width && size.height === height)) return null;
561
+
562
+ const stagedFolder = await fs.mkdtemp(path.join(os.tmpdir(), 'ol-webp-'));
563
+ for (const { file } of sizes) {
564
+ await sharp(`${framesFolder}/${file}`)
565
+ .resize(width, height, { fit: 'fill', kernel: 'nearest' })
566
+ .toFile(`${stagedFolder}/${file}`);
567
+ }
568
+ logger.info(`Staged ${pngFiles.length} frame(s) at ${width}x${height} for webp encoding`);
569
+ return stagedFolder;
570
+ };
571
+
552
572
  /**
553
573
  * PUT handler for updating object layers, their frame images, and metadata.
554
574
  *
@@ -557,8 +577,8 @@ class ObjectLayerService {
557
577
  * - `/:id/metadata/:itemType/:itemId` — Update metadata and reprocess all frames.
558
578
  * - `/:id` — Standard update from request body.
559
579
  *
560
- * The `/metadata` route delegates to {@link ObjectLayerEngine.updateObjectLayerDocuments}
561
- * for document update, atlas regeneration, SHA-256 computation, and IPFS pinning.
580
+ * The `/metadata` route delegates to {@link ObjectLayerEngine.persistObjectLayerDocuments}
581
+ * for the document write, atlas regeneration, SHA-256 computation, and IPFS pinning.
562
582
  *
563
583
  * @async
564
584
  * @function put
@@ -652,7 +672,6 @@ class ObjectLayerService {
652
672
 
653
673
  // PUT /:id/metadata/:itemType/:itemId - Update object layer metadata and reprocess all frames
654
674
  if (req.path.includes('/metadata/')) {
655
- const objectLayerId = req.params.id;
656
675
  const itemType = req.params.itemType;
657
676
  const itemId = req.params.itemId;
658
677
 
@@ -683,24 +702,12 @@ class ObjectLayerService {
683
702
  metadataOverride: req.body,
684
703
  });
685
704
 
686
- // Update documents using engine method (with atlas generation)
687
- const { objectLayer } = await ObjectLayerEngine.updateObjectLayerDocuments({
688
- objectLayerId,
705
+ const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
689
706
  ObjectLayer,
690
707
  ObjectLayerRenderFrames,
691
708
  objectLayerRenderFramesData,
692
709
  objectLayerData,
693
- updateOptions: {
694
- generateAtlas: true,
695
- atlasServiceContext: {
696
- req,
697
- res,
698
- options,
699
- AtlasSpriteSheetService,
700
- IpfsClient,
701
- createPinRecord,
702
- },
703
- },
710
+ persistOptions: { options },
704
711
  });
705
712
 
706
713
  return objectLayer;
@@ -715,25 +722,19 @@ class ObjectLayerService {
715
722
  // Apply body updates to staging data in memory
716
723
  const updateData = { ...req.body };
717
724
  const stagingData = updateData.data || existingOL.data.toObject();
725
+ stagingData.stats = validateStats(stagingData.stats);
718
726
  if (!stagingData.render) stagingData.render = {};
719
727
 
720
- // Use existing render frames if available
721
- const renderFramesData = existingOL.objectLayerRenderFramesId;
722
- if (renderFramesData) {
728
+ if (existingOL.objectLayerRenderFramesId) {
723
729
  try {
724
- const stagingOL = {
725
- data: stagingData,
726
- objectLayerRenderFramesId: renderFramesData,
727
- };
728
- const result = await AtlasSpriteSheetService.generate(
729
- { objectLayer: stagingOL, auth: req.auth },
730
- res,
730
+ const { atlasDoc, atlasCid, atlasMetadataCid } = await AtlasSpriteSheetStore.persist({
731
+ itemKey: stagingData.item.id,
732
+ objectLayerRenderFrames: existingOL.objectLayerRenderFramesId,
731
733
  options,
732
- { skipObjectLayerSave: true },
733
- );
734
- stagingData.render.cid = result.atlasCid;
735
- stagingData.render.metadataCid = result.atlasMetadataCid;
736
- updateData.atlasSpriteSheetId = result.atlasDoc._id;
734
+ });
735
+ stagingData.render.cid = atlasCid;
736
+ stagingData.render.metadataCid = atlasMetadataCid;
737
+ updateData.atlasSpriteSheetId = atlasDoc._id;
737
738
  } catch (atlasError) {
738
739
  logger.error('Failed to auto-update atlas for ObjectLayer:', atlasError);
739
740
  }
@@ -742,25 +743,17 @@ class ObjectLayerService {
742
743
  updateData.data = stagingData;
743
744
  updateData.sha256 = ObjectLayerEngine.computeSha256(stagingData);
744
745
 
745
- // Pin data JSON to IPFS
746
- try {
747
- const itemId = stagingData.item.id;
748
- const mfsPath = `/object-layer/${itemId}/${itemId}_data.json`;
749
- const ipfsResult = await IpfsClient.addJsonToIpfs(stagingData, `${itemId}_data.json`, mfsPath);
750
- if (ipfsResult) {
751
- updateData.cid = ipfsResult.cid;
752
- await createPinRecord({ cid: ipfsResult.cid, resourceType: 'object-layer-data', mfsPath, options });
753
- }
754
- } catch (ipfsError) {
755
- logger.warn('Failed to pin data JSON to IPFS:', ipfsError.message);
756
- }
746
+ const dataCid = await ObjectLayerEngine.pinObjectLayerData({
747
+ data: stagingData,
748
+ itemId: stagingData.item.id,
749
+ options,
750
+ });
751
+ if (dataCid) updateData.cid = dataCid;
757
752
 
758
- // Atomic update with all CIDs populated
759
- let updatedObjectLayer = await ObjectLayer.findByIdAndUpdate(req.params.id, updateData, {
753
+ return await ObjectLayer.findByIdAndUpdate(req.params.id, updateData, {
760
754
  returnDocument: 'after',
755
+ runValidators: true,
761
756
  }).populate('objectLayerRenderFramesId');
762
-
763
- return updatedObjectLayer;
764
757
  };
765
758
 
766
759
  /**
package/src/cli/app.js CHANGED
@@ -205,14 +205,25 @@ class UnderpostApp {
205
205
  // The resolution loadConf performed, not `ociEnv()`, which forces the overlay on: a
206
206
  // skipped overlay and an applied one were indistinguishable in the pod log.
207
207
  const resolved = deployEnvContentFactory(deployId, context.env, `${context.args?.['sub-conf'] ?? ''}`.trim());
208
+ const inContainer = Underpost.state.isInsideContainer();
208
209
  logger.info('Deployment environment loaded', {
209
210
  deployId,
210
211
  instanceId: instanceId || null,
211
212
  source: envPath,
212
- inContainer: Underpost.state.isInsideContainer(),
213
+ inContainer,
213
214
  ociOverlay: resolved.overlay,
214
215
  keys: Object.keys(values).length,
215
216
  });
217
+ // A container that resolves no overlay runs on the host-scoped endpoints of the base file —
218
+ // a loopback database, a local cache — and fails minutes later at the first connection,
219
+ // pointing at the endpoint rather than at the conf checkout that is missing the overlay.
220
+ // Named rather than thrown: a deployment is allowed to have no overlay for an environment.
221
+ if (inContainer && !instanceId && !resolved.overlay)
222
+ logger.warn('No OCI env overlay resolved; container is running on host-scoped values', {
223
+ deployId,
224
+ env: context.env,
225
+ expected: deployOciEnvFilePath(deployId, context.env),
226
+ });
216
227
  return { source: envPath, keys: Object.keys(values).length };
217
228
  },
218
229
 
package/src/cli/client.js CHANGED
@@ -83,7 +83,7 @@ class UnderpostClient {
83
83
  const env = deployEnvFactory(options, process.env.NODE_ENV || 'development');
84
84
  process.env.NODE_ENV = env;
85
85
  if (options.mergeZip) {
86
- mergeClientBuildZip({
86
+ await mergeClientBuildZip({
87
87
  buildPrefix: options.mergeZip,
88
88
  logger,
89
89
  });
@@ -91,7 +91,7 @@ class UnderpostClient {
91
91
  }
92
92
 
93
93
  if (options.unzip) {
94
- unzipClientBuild({
94
+ await unzipClientBuild({
95
95
  buildPrefix: options.unzip,
96
96
  logger,
97
97
  });
@@ -31,6 +31,7 @@ import {
31
31
  } from '../server/network/underpost-ingress.js';
32
32
  import { MONGODB_DEFAULT_REPLICA_COUNT } from '../db/mongo/MongooseDB.js';
33
33
  import { MongoBootstrap } from '../db/mongo/MongoBootstrap.js';
34
+ import { MongoExpress } from '../db/mongo/MongoExpress.js';
34
35
  import os from 'os';
35
36
  import fs from 'fs-extra';
36
37
  import Underpost from '../index.js';
@@ -82,6 +83,7 @@ class UnderpostCluster {
82
83
  * @param {object} [options] - Configuration options for cluster initialization.
83
84
  * @param {boolean} [options.mongodb=false] - Deploy MongoDB.
84
85
  * @param {boolean} [options.mongodb4=false] - Deploy MongoDB 4.4.
86
+ * @param {boolean} [options.mongoExpress=false] - Deploy the mongo-express web client for inspecting the MongoDB StatefulSet collections.
85
87
  * @param {string} [options.serviceHost=''] - Set a custom host/IP for exposed MongoDB and Valkey clients.
86
88
  * @param {boolean} [options.mariadb=false] - Deploy MariaDB.
87
89
  * @param {boolean} [options.mysql=false] - Deploy MySQL.
@@ -129,6 +131,7 @@ class UnderpostCluster {
129
131
  options = {
130
132
  mongodb: false,
131
133
  mongodb4: false,
134
+ mongoExpress: false,
132
135
  serviceHost: '',
133
136
  mariadb: false,
134
137
  mysql: false,
@@ -509,6 +512,10 @@ class UnderpostCluster {
509
512
  });
510
513
  }
511
514
 
515
+ // After both StatefulSet branches, so one invocation can bring up MongoDB and its inspector
516
+ // and the client reads the auth mode from the set this run just applied.
517
+ if (options.mongoExpress) await MongoExpress.deploy({ namespace: options.namespace, underpostRoot, options });
518
+
512
519
  // Installing one stack must never break the other. Whichever is already
513
520
  // present decides whether this install takes the node's 80/443 for itself
514
521
  // or joins the underpost ingress, so it is read before anything is applied.
package/src/cli/deploy.js CHANGED
@@ -147,10 +147,10 @@ const GATEWAY_DURATION_UNITS = [
147
147
  ];
148
148
 
149
149
  /**
150
- * Converts an HTTPProxy duration (`300000ms`, `10s`, `infinity`) into a Gateway
150
+ * Converts an HTTPProxy duration (`10000ms`, `10s`, `infinity`) into a Gateway
151
151
  * API Duration. The Gateway API grammar allows at most 5 digits per component,
152
152
  * so a value that overflows in one unit is re-expressed in a coarser one
153
- * (`300000ms` → `5m`); `infinity` maps to `0s`, which disables the timeout.
153
+ * (`10000ms` → `5m`); `infinity` maps to `0s`, which disables the timeout.
154
154
  * @param {string|number} value - Source duration.
155
155
  * @returns {string|null} Gateway API Duration, or null when unset/unparsable.
156
156
  */
@@ -617,7 +617,7 @@ spec:
617
617
  * @param {string} options.namespace - Kubernetes namespace for the deployment (defaults to "default").
618
618
  * @param {string} [options.versions] - Comma-separated list of versions to deploy.
619
619
  * @param {string} [options.cmd] - Custom initialization command for deploymentYamlPartsFactory (comma-separated commands).
620
- * @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
620
+ * @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "10000ms", "infinity").
621
621
  * @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
622
622
  * @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
623
623
  * @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
@@ -2185,7 +2185,7 @@ ${rules}`;
2185
2185
  * @param {boolean} options.disableUpdateVolume - Whether to disable volume updates.
2186
2186
  * @param {boolean} options.disableUpdateUnderpostConfig - Whether to disable Underpost config updates.
2187
2187
  * @param {string} [options.namespace] - Kubernetes namespace for the deployment (defaults to "default").
2188
- * @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
2188
+ * @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "10000ms", "infinity").
2189
2189
  * @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
2190
2190
  * @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
2191
2191
  * @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
@@ -192,7 +192,7 @@ services:
192
192
  MONGO_IMAGE=mongo:latest
193
193
  VALKEY_IMAGE=valkey/valkey:latest
194
194
  APP_IMAGE=underpost/underpost-engine
195
- APP_TAG=v3.3.73
195
+ APP_TAG=v3.3.76
196
196
  PROXY_IMAGE=nginx:stable-alpine
197
197
  PROMETHEUS_IMAGE=prom/prometheus:latest
198
198
  GRAFANA_IMAGE=grafana/grafana:latest
@@ -0,0 +1,115 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import UnderpostRepository from './repository.js';
4
+
5
+ const manifestFilterNames = ['key', 'fromKey', 'toKey', 'keyRegex'];
6
+
7
+ const normalizeStoragePath = (value) => {
8
+ if (value === undefined || value === '') return '';
9
+ const absolute = path.resolve(value);
10
+ const relative = path.relative(process.cwd(), absolute);
11
+ return (
12
+ path.isAbsolute(value) && (relative === '..' || relative.startsWith(`..${path.sep}`)) ? absolute : relative || '.'
13
+ )
14
+ .split(path.sep)
15
+ .join('/');
16
+ };
17
+
18
+ const isWithinScope = (key, scope) => {
19
+ if (scope === undefined || scope === '') return true;
20
+ const relative = path.relative(path.resolve(scope), path.resolve(key));
21
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
22
+ };
23
+
24
+ const validateSelectionOptions = (scope, options) => {
25
+ if (options.git && options.tracked) throw new Error('Use either --git or --tracked.');
26
+ if (!options.tracked && !scope) throw new Error('Provide a filesystem path, or use --tracked.');
27
+ if (options.rm && options.pull) throw new Error('Use either --rm or --pull.');
28
+ if (options.omitUnzip && !options.pull) throw new Error('--omit-unzip requires --pull.');
29
+ };
30
+
31
+ const filterManifestEntries = (entries, options = {}, scope) => {
32
+ const keys = entries.map(([key]) => key);
33
+ const keyIndex = (name) => {
34
+ const index = keys.indexOf(options[name]);
35
+ if (index < 0) throw new Error(`Manifest key not found for ${name}: ${options[name]}`);
36
+ return index;
37
+ };
38
+ const start = options.fromKey === undefined ? 0 : keyIndex('fromKey');
39
+ const end = options.toKey === undefined ? keys.length - 1 : keyIndex('toKey');
40
+ if (start > end && (options.fromKey !== undefined || options.toKey !== undefined))
41
+ throw new Error('--from-key must precede or equal --to-key.');
42
+
43
+ let regex;
44
+ if (options.keyRegex !== undefined) {
45
+ try {
46
+ regex = new RegExp(options.keyRegex);
47
+ } catch {
48
+ throw new Error(`Invalid --key-regex: ${options.keyRegex}`);
49
+ }
50
+ }
51
+ const matches = (key) => isWithinScope(key, scope) && (!regex || regex.test(key));
52
+ if (options.key !== undefined) {
53
+ const index = keyIndex('key');
54
+ if (index < start || index > end || !matches(options.key))
55
+ throw new Error('--key conflicts with the manifest range, path scope, or regex.');
56
+ }
57
+ return entries
58
+ .slice(start, end + 1)
59
+ .filter(([key]) => matches(key) && (options.key === undefined || key === options.key));
60
+ };
61
+
62
+ const resolveFilesystemSelection = (scope) => {
63
+ const files = [];
64
+ const visit = (target) => {
65
+ const stats = fs.statSync(target);
66
+ if (stats.isFile()) files.push(normalizeStoragePath(target));
67
+ else if (stats.isDirectory()) {
68
+ for (const entry of fs
69
+ .readdirSync(target, { withFileTypes: true })
70
+ .sort((a, b) => a.name.localeCompare(b.name))) {
71
+ const child = path.join(target, entry.name);
72
+ // Do not follow directory links during traversal.
73
+ if (entry.isSymbolicLink()) {
74
+ if (fs.statSync(child, { throwIfNoEntry: false })?.isFile()) files.push(normalizeStoragePath(child));
75
+ } else visit(child);
76
+ }
77
+ } else throw new Error(`Storage requires a regular file or directory: ${target}`);
78
+ };
79
+ visit(scope);
80
+ return files;
81
+ };
82
+
83
+ const resolveGitSelection = (files, scope) => {
84
+ const context = fs.statSync(scope).isDirectory() ? scope : path.dirname(scope);
85
+ const tracked = new Set(UnderpostRepository.API.getTrackedFiles(context));
86
+ return files.filter((file) => tracked.has(path.resolve(file)));
87
+ };
88
+
89
+ const resolveTrackedSelection = (entries) => entries.map(([key]) => key);
90
+
91
+ const resolveSelection = (scope, manifest, options = {}) => {
92
+ validateSelectionOptions(scope, options);
93
+ const entries = Object.entries(manifest);
94
+ const filtered = filterManifestEntries(entries, options, scope);
95
+ if (options.tracked) return resolveTrackedSelection(filtered);
96
+
97
+ let files = resolveFilesystemSelection(scope);
98
+ if (options.git) files = resolveGitSelection(files, scope);
99
+ const manifestKeys = new Map(entries.map(([key]) => [normalizeStoragePath(key), key]));
100
+ files = files.map((file) => manifestKeys.get(file) ?? file);
101
+ if (manifestFilterNames.some((name) => options[name] !== undefined)) {
102
+ const selected = new Set(resolveTrackedSelection(filtered));
103
+ files = files.filter((file) => selected.has(file));
104
+ }
105
+ return files;
106
+ };
107
+
108
+ export {
109
+ normalizeStoragePath,
110
+ filterManifestEntries,
111
+ resolveFilesystemSelection,
112
+ resolveGitSelection,
113
+ resolveTrackedSelection,
114
+ resolveSelection,
115
+ };