@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.
- package/app/main.bundle.js +2483 -2206
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +386 -0
- package/package.json +1 -1
- package/server/models-routes.js +573 -0
- package/server/paths.js +11 -0
- package/server/project-routes.js +29 -2
- package/server/settings-merge.js +104 -0
- package/server/templates-routes.js +165 -12
package/server/settings-merge.js
CHANGED
|
@@ -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
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
534
|
+
const payload = JSON.parse(stdout);
|
|
535
|
+
res.json({ ok: true, ...payload });
|
|
417
536
|
} catch {
|
|
418
|
-
|
|
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 {
|
|
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
|
-
|
|
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 }
|