@worca/ui 0.44.0 → 0.46.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.
@@ -82,28 +82,75 @@ export function readLocalSettings(settingsPath) {
82
82
  }
83
83
  }
84
84
 
85
+ // Cross-tier merge atomic paths. Entries at these paths replace wholesale
86
+ // when the project tier defines them — they do NOT deep-merge across
87
+ // user-global ↔ project. Within-tier id+env still merges (the settings.json /
88
+ // settings.local.json sibling pair is one logical tier). Mirrors the Python
89
+ // _ATOMIC_LEAF_PATHS list in src/worca/utils/settings.py.
90
+ const ATOMIC_LEAF_PATHS = [
91
+ ['worca', 'models'],
92
+ ['worca', 'pricing', 'models'],
93
+ ];
94
+
95
+ function replaceAtomicSubkeys(merged, override, path) {
96
+ let nodeMerged = merged;
97
+ let nodeOverride = override;
98
+ for (const segment of path) {
99
+ if (
100
+ !nodeMerged ||
101
+ typeof nodeMerged !== 'object' ||
102
+ Array.isArray(nodeMerged) ||
103
+ !(segment in nodeMerged)
104
+ )
105
+ return;
106
+ if (
107
+ !nodeOverride ||
108
+ typeof nodeOverride !== 'object' ||
109
+ Array.isArray(nodeOverride) ||
110
+ !(segment in nodeOverride)
111
+ )
112
+ return;
113
+ nodeMerged = nodeMerged[segment];
114
+ nodeOverride = nodeOverride[segment];
115
+ }
116
+ if (
117
+ !nodeMerged ||
118
+ typeof nodeMerged !== 'object' ||
119
+ Array.isArray(nodeMerged) ||
120
+ !nodeOverride ||
121
+ typeof nodeOverride !== 'object' ||
122
+ Array.isArray(nodeOverride)
123
+ )
124
+ return;
125
+ for (const [key, value] of Object.entries(nodeOverride)) {
126
+ nodeMerged[key] = value;
127
+ }
128
+ }
129
+
85
130
  /**
86
131
  * Read the full effective settings stack the way the Python runtime resolves
87
132
  * it: user-global base → user-global .local → project base → project .local.
88
- * Each layer deep-merges over the previous. Missing files are treated as
89
- * empty objects. Used by UI surfaces (model alias dropdowns, etc.) that must
90
- * mirror what `worca.utils.settings.resolve_model` sees at run time.
133
+ * Each layer deep-merges over the previous, EXCEPT entries under
134
+ * worca.models.* and worca.pricing.models.* which replace wholesale across
135
+ * tiers (project shadows user shadows builtin per alias). Missing files are
136
+ * treated as empty objects.
91
137
  *
92
138
  * `globalSettingsPath` is passed in so callers can substitute it during
93
139
  * tests; production callers thread the result of `paths.globalSettingsPath()`.
94
140
  */
95
141
  export function readEffectiveSettings(projectSettingsPath, globalSettingsPath) {
96
- const layers = [];
142
+ const userLayers = [];
143
+ const projectLayers = [];
97
144
  if (globalSettingsPath) {
98
145
  try {
99
- layers.push(JSON.parse(readFileSync(globalSettingsPath, 'utf8')));
146
+ userLayers.push(JSON.parse(readFileSync(globalSettingsPath, 'utf8')));
100
147
  } catch {
101
148
  /* missing or invalid — treat as empty */
102
149
  }
103
150
  const globalLocal = localPathFor(globalSettingsPath);
104
151
  if (existsSync(globalLocal)) {
105
152
  try {
106
- layers.push(JSON.parse(readFileSync(globalLocal, 'utf8')));
153
+ userLayers.push(JSON.parse(readFileSync(globalLocal, 'utf8')));
107
154
  } catch {
108
155
  /* invalid — skip */
109
156
  }
@@ -111,18 +158,30 @@ export function readEffectiveSettings(projectSettingsPath, globalSettingsPath) {
111
158
  }
112
159
  if (projectSettingsPath) {
113
160
  try {
114
- layers.push(JSON.parse(readFileSync(projectSettingsPath, 'utf8')));
161
+ projectLayers.push(JSON.parse(readFileSync(projectSettingsPath, 'utf8')));
115
162
  } catch {
116
163
  /* missing or invalid — treat as empty */
117
164
  }
118
165
  const projectLocal = localPathFor(projectSettingsPath);
119
166
  if (existsSync(projectLocal)) {
120
167
  try {
121
- layers.push(JSON.parse(readFileSync(projectLocal, 'utf8')));
168
+ projectLayers.push(JSON.parse(readFileSync(projectLocal, 'utf8')));
122
169
  } catch {
123
170
  /* invalid — skip */
124
171
  }
125
172
  }
126
173
  }
127
- return layers.reduce((acc, layer) => deepMerge(acc, layer), {});
174
+ const userMerged = userLayers.reduce(
175
+ (acc, layer) => deepMerge(acc, layer),
176
+ {},
177
+ );
178
+ const projectMerged = projectLayers.reduce(
179
+ (acc, layer) => deepMerge(acc, layer),
180
+ {},
181
+ );
182
+ const merged = deepMerge(userMerged, projectMerged);
183
+ for (const path of ATOMIC_LEAF_PATHS) {
184
+ replaceAtomicSubkeys(merged, projectMerged, path);
185
+ }
186
+ return merged;
128
187
  }
@@ -404,6 +404,7 @@ function _baseImportArgs(
404
404
  dstTier,
405
405
  resolutionsPath,
406
406
  onModelConflict,
407
+ bundleLabel,
407
408
  ) {
408
409
  const args = [
409
410
  'import',
@@ -419,9 +420,31 @@ function _baseImportArgs(
419
420
  if (onModelConflict) {
420
421
  args.push('--on-model-conflict', onModelConflict);
421
422
  }
423
+ if (bundleLabel) {
424
+ // Pass the user-visible filename so `_imported_from` is stamped with
425
+ // (e.g.) `feature-glm-ds-bundle.zip` instead of the server-side temp
426
+ // name `bundle.zip`. Optional — falls back to source basename in CLI.
427
+ args.push('--bundle-label', bundleLabel);
428
+ }
422
429
  return args;
423
430
  }
424
431
 
432
+ // Bundle filename is forwarded by the UI as an HTTP header so the
433
+ // imported-from attribution badge shows the user-facing name rather than
434
+ // the server-side temp `bundle.zip`. Sanitize lightly: reject paths /
435
+ // slashes / oversized values to keep argv hygiene.
436
+ const _BUNDLE_LABEL_HEADER = 'x-bundle-filename';
437
+ function _readBundleLabel(req) {
438
+ const raw = req.headers[_BUNDLE_LABEL_HEADER];
439
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 256) {
440
+ return null;
441
+ }
442
+ if (raw.includes('/') || raw.includes('\\') || raw.includes('\0')) {
443
+ return null;
444
+ }
445
+ return raw;
446
+ }
447
+
425
448
  function handleZipImport(req, res) {
426
449
  const dstTier = req.query.dst_tier;
427
450
  if (!isValidTier(dstTier)) {
@@ -435,6 +458,7 @@ function handleZipImport(req, res) {
435
458
  const zipBuffer = req.body;
436
459
  const resolutions = _parseResolutionsHeader(req);
437
460
  const onModelConflict = req.query.on_model_conflict || null;
461
+ const bundleLabel = _readBundleLabel(req);
438
462
  const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
439
463
  const tmpPath = join(dir, 'bundle.zip');
440
464
  try {
@@ -442,7 +466,13 @@ function handleZipImport(req, res) {
442
466
  const resolutionsPath = _writeResolutionsFile(dir, resolutions);
443
467
  const stdout = runWorcaTemplates(
444
468
  req.project.projectRoot,
445
- _baseImportArgs(tmpPath, dstTier, resolutionsPath, onModelConflict),
469
+ _baseImportArgs(
470
+ tmpPath,
471
+ dstTier,
472
+ resolutionsPath,
473
+ onModelConflict,
474
+ bundleLabel,
475
+ ),
446
476
  { timeout: 60000 },
447
477
  );
448
478
  const imported = [];
@@ -538,10 +568,12 @@ function handleJsonImport(req, res) {
538
568
  });
539
569
  }
540
570
  if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
571
+ const bundleLabel = _readBundleLabel(req);
541
572
  try {
542
573
  const result = importBundle(req.project.projectRoot, bundle, dstTier, {
543
574
  resolutions,
544
575
  onModelConflict,
576
+ bundleLabel,
545
577
  });
546
578
  res.json({ ok: true, ...result });
547
579
  } catch (err) {
@@ -555,7 +587,7 @@ function importBundle(projectRoot, bundle, tier, opts = {}) {
555
587
  if (!bundle || !Array.isArray(bundle.templates)) {
556
588
  throw new Error('Bundle must contain a "templates" array');
557
589
  }
558
- const { resolutions, onModelConflict } = opts;
590
+ const { resolutions, onModelConflict, bundleLabel } = opts;
559
591
  const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
560
592
  const bundlePath = join(dir, 'bundle.json');
561
593
  try {
@@ -563,7 +595,13 @@ function importBundle(projectRoot, bundle, tier, opts = {}) {
563
595
  const resolutionsPath = _writeResolutionsFile(dir, resolutions);
564
596
  runWorcaTemplates(
565
597
  projectRoot,
566
- _baseImportArgs(bundlePath, tier, resolutionsPath, onModelConflict),
598
+ _baseImportArgs(
599
+ bundlePath,
600
+ tier,
601
+ resolutionsPath,
602
+ onModelConflict,
603
+ bundleLabel,
604
+ ),
567
605
  { timeout: 60000 },
568
606
  );
569
607
  const targetDir = dirForTier(projectRoot, tier);