@worca/ui 0.43.0 → 0.45.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.
@@ -81,3 +81,107 @@ export function readLocalSettings(settingsPath) {
81
81
  return {};
82
82
  }
83
83
  }
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
+
130
+ /**
131
+ * Read the full effective settings stack the way the Python runtime resolves
132
+ * it: user-global base → user-global .local → project base → project .local.
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.
137
+ *
138
+ * `globalSettingsPath` is passed in so callers can substitute it during
139
+ * tests; production callers thread the result of `paths.globalSettingsPath()`.
140
+ */
141
+ export function readEffectiveSettings(projectSettingsPath, globalSettingsPath) {
142
+ const userLayers = [];
143
+ const projectLayers = [];
144
+ if (globalSettingsPath) {
145
+ try {
146
+ userLayers.push(JSON.parse(readFileSync(globalSettingsPath, 'utf8')));
147
+ } catch {
148
+ /* missing or invalid — treat as empty */
149
+ }
150
+ const globalLocal = localPathFor(globalSettingsPath);
151
+ if (existsSync(globalLocal)) {
152
+ try {
153
+ userLayers.push(JSON.parse(readFileSync(globalLocal, 'utf8')));
154
+ } catch {
155
+ /* invalid — skip */
156
+ }
157
+ }
158
+ }
159
+ if (projectSettingsPath) {
160
+ try {
161
+ projectLayers.push(JSON.parse(readFileSync(projectSettingsPath, 'utf8')));
162
+ } catch {
163
+ /* missing or invalid — treat as empty */
164
+ }
165
+ const projectLocal = localPathFor(projectSettingsPath);
166
+ if (existsSync(projectLocal)) {
167
+ try {
168
+ projectLayers.push(JSON.parse(readFileSync(projectLocal, 'utf8')));
169
+ } catch {
170
+ /* invalid — skip */
171
+ }
172
+ }
173
+ }
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;
187
+ }
@@ -378,6 +378,73 @@ function exportGist(projectRoot, id) {
378
378
  return match ? match[0] : null;
379
379
  }
380
380
 
381
+ function _parseResolutionsHeader(req) {
382
+ const raw = req.headers['x-resolutions'];
383
+ if (!raw) return null;
384
+ try {
385
+ const parsed = JSON.parse(raw);
386
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
387
+ return parsed;
388
+ }
389
+ } catch {
390
+ /* fall through */
391
+ }
392
+ return null;
393
+ }
394
+
395
+ function _writeResolutionsFile(dir, resolutions) {
396
+ if (!resolutions || Object.keys(resolutions).length === 0) return null;
397
+ const path = join(dir, 'resolutions.json');
398
+ writeFileSync(path, JSON.stringify(resolutions), 'utf8');
399
+ return path;
400
+ }
401
+
402
+ function _baseImportArgs(
403
+ bundlePath,
404
+ dstTier,
405
+ resolutionsPath,
406
+ onModelConflict,
407
+ bundleLabel,
408
+ ) {
409
+ const args = [
410
+ 'import',
411
+ '--from',
412
+ bundlePath,
413
+ '--scope',
414
+ dstTier,
415
+ '--non-interactive',
416
+ ];
417
+ if (resolutionsPath) {
418
+ args.push('--resolutions', resolutionsPath);
419
+ }
420
+ if (onModelConflict) {
421
+ args.push('--on-model-conflict', onModelConflict);
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
+ }
429
+ return args;
430
+ }
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
+
381
448
  function handleZipImport(req, res) {
382
449
  const dstTier = req.query.dst_tier;
383
450
  if (!isValidTier(dstTier)) {
@@ -389,18 +456,25 @@ function handleZipImport(req, res) {
389
456
  if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
390
457
 
391
458
  const zipBuffer = req.body;
392
- const tmpPath = join(
393
- tmpdir(),
394
- `worca-import-${Math.random().toString(36).slice(2)}.zip`,
395
- );
459
+ const resolutions = _parseResolutionsHeader(req);
460
+ const onModelConflict = req.query.on_model_conflict || null;
461
+ const bundleLabel = _readBundleLabel(req);
462
+ const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
463
+ const tmpPath = join(dir, 'bundle.zip');
396
464
  try {
397
465
  writeFileSync(tmpPath, zipBuffer);
466
+ const resolutionsPath = _writeResolutionsFile(dir, resolutions);
398
467
  const stdout = runWorcaTemplates(
399
468
  req.project.projectRoot,
400
- ['import', '--from', tmpPath, '--scope', dstTier, '--non-interactive'],
469
+ _baseImportArgs(
470
+ tmpPath,
471
+ dstTier,
472
+ resolutionsPath,
473
+ onModelConflict,
474
+ bundleLabel,
475
+ ),
401
476
  { timeout: 60000 },
402
477
  );
403
- // Best-effort summary: the CLI may print imported template ids to stdout
404
478
  const imported = [];
405
479
  for (const line of (stdout || '').split('\n')) {
406
480
  const m = line.match(/imported[:\s]+([a-z0-9_-]+)/i);
@@ -412,16 +486,76 @@ function handleZipImport(req, res) {
412
486
  .status(statusForCliCode(err.cliCode))
413
487
  .json({ ok: false, error: err.message, code: err.cliCode });
414
488
  } finally {
489
+ cleanupTemp(dir);
490
+ }
491
+ }
492
+
493
+ function handleImportPreview(req, res) {
494
+ const dstTier = req.query.dst_tier;
495
+ if (!isValidTier(dstTier)) {
496
+ return res.status(400).json({
497
+ ok: false,
498
+ error: `dst_tier must be one of: ${TIERS.join(', ')}`,
499
+ });
500
+ }
501
+ if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
502
+
503
+ const ctype = req.headers['content-type'] || '';
504
+ const dir = mkdtempSync(join(tmpdir(), 'worca-import-preview-'));
505
+ try {
506
+ let bundlePath;
507
+ if (ctype.startsWith('application/zip')) {
508
+ bundlePath = join(dir, 'bundle.zip');
509
+ writeFileSync(bundlePath, req.body);
510
+ } else {
511
+ const { bundle } = req.body || {};
512
+ if (!bundle || typeof bundle !== 'object') {
513
+ return res
514
+ .status(400)
515
+ .json({ ok: false, error: 'bundle must be a JSON object' });
516
+ }
517
+ bundlePath = join(dir, 'bundle.json');
518
+ writeFileSync(bundlePath, JSON.stringify(bundle), 'utf8');
519
+ }
520
+ const stdout = runWorcaTemplates(
521
+ req.project.projectRoot,
522
+ [
523
+ 'import',
524
+ '--from',
525
+ bundlePath,
526
+ '--scope',
527
+ dstTier,
528
+ '--non-interactive',
529
+ '--preview',
530
+ ],
531
+ { timeout: 30000 },
532
+ );
415
533
  try {
416
- rmSync(tmpPath, { force: true });
534
+ const payload = JSON.parse(stdout);
535
+ res.json({ ok: true, ...payload });
417
536
  } catch {
418
- /* ignore cleanup errors */
537
+ res.status(500).json({
538
+ ok: false,
539
+ error: 'CLI preview output was not valid JSON',
540
+ raw: stdout,
541
+ });
419
542
  }
543
+ } catch (err) {
544
+ res
545
+ .status(statusForCliCode(err.cliCode))
546
+ .json({ ok: false, error: err.message, code: err.cliCode });
547
+ } finally {
548
+ cleanupTemp(dir);
420
549
  }
421
550
  }
422
551
 
423
552
  function handleJsonImport(req, res) {
424
- const { bundle, dst_tier: dstTier } = req.body || {};
553
+ const {
554
+ bundle,
555
+ dst_tier: dstTier,
556
+ resolutions,
557
+ on_model_conflict: onModelConflict,
558
+ } = req.body || {};
425
559
  if (!bundle || typeof bundle !== 'object') {
426
560
  return res
427
561
  .status(400)
@@ -434,8 +568,13 @@ function handleJsonImport(req, res) {
434
568
  });
435
569
  }
436
570
  if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
571
+ const bundleLabel = _readBundleLabel(req);
437
572
  try {
438
- const result = importBundle(req.project.projectRoot, bundle, dstTier);
573
+ const result = importBundle(req.project.projectRoot, bundle, dstTier, {
574
+ resolutions,
575
+ onModelConflict,
576
+ bundleLabel,
577
+ });
439
578
  res.json({ ok: true, ...result });
440
579
  } catch (err) {
441
580
  res
@@ -444,17 +583,25 @@ function handleJsonImport(req, res) {
444
583
  }
445
584
  }
446
585
 
447
- function importBundle(projectRoot, bundle, tier) {
586
+ function importBundle(projectRoot, bundle, tier, opts = {}) {
448
587
  if (!bundle || !Array.isArray(bundle.templates)) {
449
588
  throw new Error('Bundle must contain a "templates" array');
450
589
  }
590
+ const { resolutions, onModelConflict, bundleLabel } = opts;
451
591
  const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
452
592
  const bundlePath = join(dir, 'bundle.json');
453
593
  try {
454
594
  writeFileSync(bundlePath, JSON.stringify(bundle), 'utf8');
595
+ const resolutionsPath = _writeResolutionsFile(dir, resolutions);
455
596
  runWorcaTemplates(
456
597
  projectRoot,
457
- ['import', '--from', bundlePath, '--scope', tier, '--non-interactive'],
598
+ _baseImportArgs(
599
+ bundlePath,
600
+ tier,
601
+ resolutionsPath,
602
+ onModelConflict,
603
+ bundleLabel,
604
+ ),
458
605
  { timeout: 60000 },
459
606
  );
460
607
  const targetDir = dirForTier(projectRoot, tier);
@@ -593,6 +740,12 @@ export function createTemplatesRoutes() {
593
740
  },
594
741
  );
595
742
 
743
+ router.post(
744
+ '/templates/import/preview',
745
+ expressRaw({ type: 'application/zip', limit: '1mb' }),
746
+ (req, res) => handleImportPreview(req, res),
747
+ );
748
+
596
749
  /**
597
750
  * POST /api/projects/:projectId/templates/validate
598
751
  * Body: { config }