@worca/ui 0.44.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 +2421 -2228
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +332 -0
- package/package.json +1 -1
- package/server/models-routes.js +573 -0
- package/server/project-routes.js +5 -1
- package/server/settings-merge.js +68 -9
- package/server/templates-routes.js +41 -3
package/server/settings-merge.js
CHANGED
|
@@ -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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
142
|
+
const userLayers = [];
|
|
143
|
+
const projectLayers = [];
|
|
97
144
|
if (globalSettingsPath) {
|
|
98
145
|
try {
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
+
projectLayers.push(JSON.parse(readFileSync(projectLocal, 'utf8')));
|
|
122
169
|
} catch {
|
|
123
170
|
/* invalid — skip */
|
|
124
171
|
}
|
|
125
172
|
}
|
|
126
173
|
}
|
|
127
|
-
|
|
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(
|
|
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(
|
|
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);
|