@worca/ui 0.42.0 → 0.44.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 +1572 -1477
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +95 -5
- package/package.json +1 -1
- package/server/file-access-aggregator.js +74 -2
- package/server/paths.js +11 -0
- package/server/project-routes.js +24 -1
- package/server/settings-merge.js +45 -0
- package/server/templates-routes.js +144 -13
package/app/styles.css
CHANGED
|
@@ -8170,6 +8170,60 @@ sl-dialog.markdown-dialog::part(body) {
|
|
|
8170
8170
|
color: var(--fg);
|
|
8171
8171
|
}
|
|
8172
8172
|
|
|
8173
|
+
.template-action-dialog .dialog-collisions {
|
|
8174
|
+
border-left: 3px solid var(--sl-color-warning-500, #f59e0b);
|
|
8175
|
+
padding: 10px 12px;
|
|
8176
|
+
background: var(--sl-color-warning-50, rgba(245, 158, 11, 0.05));
|
|
8177
|
+
border-radius: 4px;
|
|
8178
|
+
min-width: 0;
|
|
8179
|
+
}
|
|
8180
|
+
.template-action-dialog .dialog-collisions-header {
|
|
8181
|
+
margin-bottom: 6px;
|
|
8182
|
+
}
|
|
8183
|
+
.template-action-dialog .dialog-collision-list {
|
|
8184
|
+
list-style: none;
|
|
8185
|
+
margin: 10px 0 0;
|
|
8186
|
+
padding: 0;
|
|
8187
|
+
display: flex;
|
|
8188
|
+
flex-direction: column;
|
|
8189
|
+
gap: 8px;
|
|
8190
|
+
}
|
|
8191
|
+
.template-action-dialog .dialog-collision-row {
|
|
8192
|
+
display: grid;
|
|
8193
|
+
grid-template-columns: minmax(90px, 140px) minmax(0, 1fr) minmax(0, 1fr);
|
|
8194
|
+
align-items: center;
|
|
8195
|
+
gap: 8px;
|
|
8196
|
+
min-width: 0;
|
|
8197
|
+
}
|
|
8198
|
+
.template-action-dialog .dialog-collision-alias {
|
|
8199
|
+
min-width: 0;
|
|
8200
|
+
overflow: hidden;
|
|
8201
|
+
text-overflow: ellipsis;
|
|
8202
|
+
white-space: nowrap;
|
|
8203
|
+
}
|
|
8204
|
+
.template-action-dialog .dialog-collision-alias code {
|
|
8205
|
+
font-family: var(--sl-font-mono);
|
|
8206
|
+
font-size: 13px;
|
|
8207
|
+
color: var(--fg);
|
|
8208
|
+
}
|
|
8209
|
+
.template-action-dialog .collision-action-select,
|
|
8210
|
+
.template-action-dialog .collision-rename-input {
|
|
8211
|
+
width: 100%;
|
|
8212
|
+
min-width: 0;
|
|
8213
|
+
}
|
|
8214
|
+
.template-action-dialog .dialog-collision-row[data-rename="false"] .collision-action-select {
|
|
8215
|
+
grid-column: 2 / span 2;
|
|
8216
|
+
}
|
|
8217
|
+
.template-action-dialog .dialog-new-aliases-hint {
|
|
8218
|
+
margin-top: 8px;
|
|
8219
|
+
}
|
|
8220
|
+
.template-action-dialog .dialog-new-aliases-hint code {
|
|
8221
|
+
font-family: var(--sl-font-mono);
|
|
8222
|
+
font-size: 12px;
|
|
8223
|
+
color: var(--fg);
|
|
8224
|
+
margin-right: 4px;
|
|
8225
|
+
}
|
|
8226
|
+
|
|
8173
8227
|
.editor-content {
|
|
8174
8228
|
flex: 1;
|
|
8175
8229
|
overflow-y: auto;
|
|
@@ -9241,7 +9295,10 @@ body.help-mode-active sl-tab {
|
|
|
9241
9295
|
border-right: none;
|
|
9242
9296
|
}
|
|
9243
9297
|
|
|
9244
|
-
/* File column header — sticky left, wider than data cells
|
|
9298
|
+
/* File column header — sticky left, wider than data cells.
|
|
9299
|
+
``position: relative`` so the drag handle (.access-col-file-resizer)
|
|
9300
|
+
can pin to the right edge with position:absolute. ``sticky`` already
|
|
9301
|
+
establishes a containing block for absolute children. */
|
|
9245
9302
|
.access-col-file-header {
|
|
9246
9303
|
position: sticky;
|
|
9247
9304
|
left: 0;
|
|
@@ -9254,6 +9311,37 @@ body.help-mode-active sl-tab {
|
|
|
9254
9311
|
border-right: 2px solid var(--border, #e2e8f0);
|
|
9255
9312
|
}
|
|
9256
9313
|
|
|
9314
|
+
/* Drag handle for resizing the file column. Pinned to the right edge of
|
|
9315
|
+
the header; on pointerdown it captures the pointer and rewrites the
|
|
9316
|
+
shared --fa-file-col-width CSS var on the .run-file-access root, which
|
|
9317
|
+
the grid template + sticky cells both read so they move in lockstep. */
|
|
9318
|
+
.access-col-file-resizer {
|
|
9319
|
+
position: absolute;
|
|
9320
|
+
top: 0;
|
|
9321
|
+
right: -3px;
|
|
9322
|
+
width: 7px;
|
|
9323
|
+
height: 100%;
|
|
9324
|
+
cursor: col-resize;
|
|
9325
|
+
user-select: none;
|
|
9326
|
+
touch-action: none;
|
|
9327
|
+
z-index: 5;
|
|
9328
|
+
background: transparent;
|
|
9329
|
+
}
|
|
9330
|
+
|
|
9331
|
+
.access-col-file-resizer:hover,
|
|
9332
|
+
.access-col-file-resizer--active {
|
|
9333
|
+
background: rgba(59, 130, 246, 0.35);
|
|
9334
|
+
}
|
|
9335
|
+
|
|
9336
|
+
/* Body-level cursor lock during a drag (set via JS) — keeps the cursor as
|
|
9337
|
+
col-resize even when the pointer leaves the handle's 7px hit area, which
|
|
9338
|
+
is otherwise easy to do at faster drag speeds. */
|
|
9339
|
+
.access-col-file-resizing,
|
|
9340
|
+
.access-col-file-resizing * {
|
|
9341
|
+
cursor: col-resize !important;
|
|
9342
|
+
user-select: none !important;
|
|
9343
|
+
}
|
|
9344
|
+
|
|
9257
9345
|
.access-col-header--collapsed {
|
|
9258
9346
|
background: rgba(59, 130, 246, 0.06);
|
|
9259
9347
|
color: #3b82f6;
|
|
@@ -9338,10 +9426,12 @@ body.help-mode-active sl-tab {
|
|
|
9338
9426
|
justify-content: flex-start;
|
|
9339
9427
|
gap: 6px;
|
|
9340
9428
|
padding: 3px 8px;
|
|
9341
|
-
/* One indent step ==
|
|
9342
|
-
|
|
9343
|
-
folder
|
|
9344
|
-
|
|
9429
|
+
/* One indent step == the full chevron + folder-icon prefix on a dir row:
|
|
9430
|
+
chevron 18px + gap 6px + folder-icon 14px + gap 6px = 44px. A child file
|
|
9431
|
+
row (no chevron/folder prefix) lands its name at exactly the x-position
|
|
9432
|
+
of its parent folder's name, and a nested dir row's text aligns with
|
|
9433
|
+
its grandparent's text — what a treegrid is supposed to look like. */
|
|
9434
|
+
padding-left: calc(8px + var(--depth, 0) * 44px);
|
|
9345
9435
|
background: inherit;
|
|
9346
9436
|
border-right: 2px solid var(--border, #e2e8f0);
|
|
9347
9437
|
}
|
package/package.json
CHANGED
|
@@ -361,6 +361,14 @@ function fragmentRecordsToFileAccess(records, repoRoot) {
|
|
|
361
361
|
* Relativise an absolute fragment path against the repo root (a live stand-in
|
|
362
362
|
* for the Python GitPathOracle respelling). Paths already relative, or outside
|
|
363
363
|
* the repo, are returned unchanged; the repo root itself maps to null.
|
|
364
|
+
*
|
|
365
|
+
* Tolerant recovery: when ``repoRoot`` is a worktree under
|
|
366
|
+
* ``<project>/.worktrees/<id>`` and ``rawPath`` is an absolute path pointing
|
|
367
|
+
* at a sibling clone of the same project (e.g. the main checkout), strip the
|
|
368
|
+
* prefix up to and including the project basename so the entry groups under
|
|
369
|
+
* the project tree instead of rendering the full absolute path. Mirrors the
|
|
370
|
+
* Python ``_recover_basename_tail`` in ``path_canon.py`` so live and
|
|
371
|
+
* completion-time views agree.
|
|
364
372
|
*/
|
|
365
373
|
function canonicalizePath(rawPath, repoRoot) {
|
|
366
374
|
if (!rawPath) return null;
|
|
@@ -368,7 +376,31 @@ function canonicalizePath(rawPath, repoRoot) {
|
|
|
368
376
|
return rawPath.slice(repoRoot.length + 1);
|
|
369
377
|
}
|
|
370
378
|
if (rawPath === repoRoot) return null;
|
|
371
|
-
return rawPath;
|
|
379
|
+
return recoverWorktreeBasenameTail(rawPath, repoRoot) ?? rawPath;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Recover repo-relative tail from a raw absolute path that fell outside a
|
|
384
|
+
* worktree root. Returns null when the heuristic doesn't apply so the caller
|
|
385
|
+
* can fall back to the raw path unchanged.
|
|
386
|
+
*/
|
|
387
|
+
function recoverWorktreeBasenameTail(rawPath, repoRoot) {
|
|
388
|
+
if (!rawPath || !repoRoot) return null;
|
|
389
|
+
const rootParts = repoRoot.split('/');
|
|
390
|
+
const wtIdx = rootParts.indexOf('.worktrees');
|
|
391
|
+
if (wtIdx <= 0) return null;
|
|
392
|
+
const projectBasename = rootParts[wtIdx - 1];
|
|
393
|
+
if (!projectBasename) return null;
|
|
394
|
+
const rawParts = rawPath.split('/');
|
|
395
|
+
// Scan from the right — the tail closest to the leaf is the most likely
|
|
396
|
+
// intended target when the basename appears more than once.
|
|
397
|
+
for (let i = rawParts.length - 1; i >= 0; i--) {
|
|
398
|
+
if (rawParts[i] === projectBasename) {
|
|
399
|
+
const tail = rawParts.slice(i + 1).join('/');
|
|
400
|
+
return tail || null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
372
404
|
}
|
|
373
405
|
|
|
374
406
|
function ensureFile(fileData, path) {
|
|
@@ -451,7 +483,47 @@ function buildTree(fileData) {
|
|
|
451
483
|
|
|
452
484
|
// Rollup dir totals and cells bottom-up, then serialise to arrays.
|
|
453
485
|
rollupDir(root);
|
|
454
|
-
return
|
|
486
|
+
return collapseSingleChildDirs(
|
|
487
|
+
[...root.children.values()].map(serializeNode),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Collapse chains of single-child intermediate directories into one synthetic
|
|
493
|
+
* node. Without this, a stray absolute path (e.g. /Volumes/Apps/dev/.../foo.js)
|
|
494
|
+
* renders as a six-deep chain of single-row dirs that buries the filename.
|
|
495
|
+
*
|
|
496
|
+
* Rule: walk bottom-up, then while a dir's only child is also a dir, merge:
|
|
497
|
+
* adopt the child's children/cells/totals (which equal the parent's, since
|
|
498
|
+
* the parent rolled up from the single child) and visually concatenate the
|
|
499
|
+
* names so the collapsed segments stay visible without burning rows.
|
|
500
|
+
*
|
|
501
|
+
* The collapsed node keeps the deeper child's ``path`` so per-file drawer
|
|
502
|
+
* lookups (which key on the file's full path) continue to work.
|
|
503
|
+
*/
|
|
504
|
+
function collapseSingleChildDirs(nodes) {
|
|
505
|
+
return nodes.map(collapseNode);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function collapseNode(node) {
|
|
509
|
+
if (node.type !== 'dir') return node;
|
|
510
|
+
// Recurse first so the rule applies bottom-up.
|
|
511
|
+
let current = { ...node, children: node.children.map(collapseNode) };
|
|
512
|
+
while (current.children.length === 1 && current.children[0].type === 'dir') {
|
|
513
|
+
const child = current.children[0];
|
|
514
|
+
current = {
|
|
515
|
+
...current,
|
|
516
|
+
path: child.path,
|
|
517
|
+
name: `${current.name}/${child.name}`,
|
|
518
|
+
children: child.children,
|
|
519
|
+
// cells/totals of a parent with a single dir child equal the child's
|
|
520
|
+
// (rollup invariant), so adopting the child's keeps the row identical
|
|
521
|
+
// to what it would have rendered before the collapse.
|
|
522
|
+
cells: child.cells,
|
|
523
|
+
totals: child.totals,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return current;
|
|
455
527
|
}
|
|
456
528
|
|
|
457
529
|
function rollupDir(node) {
|
package/server/paths.js
CHANGED
|
@@ -23,6 +23,17 @@ export function worcaHome() {
|
|
|
23
23
|
return join(homedir(), '.worca');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* @returns {string} Absolute path to the user-global settings.json
|
|
28
|
+
* (`~/.worca/settings.json` by default, $WORCA_HOME-aware). The Python
|
|
29
|
+
* runtime reads this layer first and deep-merges project settings on top,
|
|
30
|
+
* so any UI surface that needs to mirror runtime resolution (e.g. model
|
|
31
|
+
* alias dropdowns) must layer this in.
|
|
32
|
+
*/
|
|
33
|
+
export function globalSettingsPath() {
|
|
34
|
+
return join(worcaHome(), 'settings.json');
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
/**
|
|
27
38
|
* @param {string=} override Caller-supplied path that wins over $WORCA_HOME.
|
|
28
39
|
* @returns {string} Absolute path to the fleet-runs directory.
|
package/server/project-routes.js
CHANGED
|
@@ -29,7 +29,7 @@ import { getDefaultBranch } from './git-helpers.js';
|
|
|
29
29
|
import { extractAndStripGlobalKeys } from './global-keys.js';
|
|
30
30
|
import { LaunchLock } from './launch-lock.js';
|
|
31
31
|
import { createModelEnvRouter } from './model-env-routes.js';
|
|
32
|
-
import { preferencesPath } from './paths.js';
|
|
32
|
+
import { globalSettingsPath, preferencesPath } from './paths.js';
|
|
33
33
|
import { readPreferences } from './preferences.js';
|
|
34
34
|
import { ProcessManager } from './process-manager.js';
|
|
35
35
|
import { countRunningPipelinesAcrossProjects } from './process-registry.js';
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import {
|
|
46
46
|
deepMerge,
|
|
47
47
|
localPathFor,
|
|
48
|
+
readEffectiveSettings,
|
|
48
49
|
readLocalSettings,
|
|
49
50
|
readMergedSettings,
|
|
50
51
|
} from './settings-merge.js';
|
|
@@ -488,6 +489,28 @@ export function createProjectScopedRoutes({
|
|
|
488
489
|
}
|
|
489
490
|
});
|
|
490
491
|
|
|
492
|
+
// GET /api/projects/:projectId/effective-settings
|
|
493
|
+
//
|
|
494
|
+
// Returns the fully-layered settings the Python runtime sees: user-global
|
|
495
|
+
// base → user-global .local → project base → project .local. Used by UI
|
|
496
|
+
// surfaces that must mirror runtime alias resolution (e.g. the per-agent
|
|
497
|
+
// model dropdown in the template editor — without this, aliases that live
|
|
498
|
+
// only in user-global ~/.worca/settings.json are invisible to projects).
|
|
499
|
+
router.get('/effective-settings', (req, res) => {
|
|
500
|
+
const { settingsPath } = req.project;
|
|
501
|
+
try {
|
|
502
|
+
const merged = readEffectiveSettings(settingsPath, globalSettingsPath());
|
|
503
|
+
res.json({
|
|
504
|
+
worca: merged.worca || {},
|
|
505
|
+
permissions: merged.permissions || {},
|
|
506
|
+
});
|
|
507
|
+
} catch (err) {
|
|
508
|
+
res
|
|
509
|
+
.status(500)
|
|
510
|
+
.json({ error: { code: 'read_error', message: err.message } });
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
491
514
|
// POST /api/projects/:projectId/settings
|
|
492
515
|
router.post('/settings', async (req, res) => {
|
|
493
516
|
const { settingsPath } = req.project;
|
package/server/settings-merge.js
CHANGED
|
@@ -81,3 +81,48 @@ export function readLocalSettings(settingsPath) {
|
|
|
81
81
|
return {};
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read the full effective settings stack the way the Python runtime resolves
|
|
87
|
+
* 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.
|
|
91
|
+
*
|
|
92
|
+
* `globalSettingsPath` is passed in so callers can substitute it during
|
|
93
|
+
* tests; production callers thread the result of `paths.globalSettingsPath()`.
|
|
94
|
+
*/
|
|
95
|
+
export function readEffectiveSettings(projectSettingsPath, globalSettingsPath) {
|
|
96
|
+
const layers = [];
|
|
97
|
+
if (globalSettingsPath) {
|
|
98
|
+
try {
|
|
99
|
+
layers.push(JSON.parse(readFileSync(globalSettingsPath, 'utf8')));
|
|
100
|
+
} catch {
|
|
101
|
+
/* missing or invalid — treat as empty */
|
|
102
|
+
}
|
|
103
|
+
const globalLocal = localPathFor(globalSettingsPath);
|
|
104
|
+
if (existsSync(globalLocal)) {
|
|
105
|
+
try {
|
|
106
|
+
layers.push(JSON.parse(readFileSync(globalLocal, 'utf8')));
|
|
107
|
+
} catch {
|
|
108
|
+
/* invalid — skip */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (projectSettingsPath) {
|
|
113
|
+
try {
|
|
114
|
+
layers.push(JSON.parse(readFileSync(projectSettingsPath, 'utf8')));
|
|
115
|
+
} catch {
|
|
116
|
+
/* missing or invalid — treat as empty */
|
|
117
|
+
}
|
|
118
|
+
const projectLocal = localPathFor(projectSettingsPath);
|
|
119
|
+
if (existsSync(projectLocal)) {
|
|
120
|
+
try {
|
|
121
|
+
layers.push(JSON.parse(readFileSync(projectLocal, 'utf8')));
|
|
122
|
+
} catch {
|
|
123
|
+
/* invalid — skip */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return layers.reduce((acc, layer) => deepMerge(acc, layer), {});
|
|
128
|
+
}
|
|
@@ -313,6 +313,12 @@ function exportBundle(projectRoot, id, mode = 'standalone') {
|
|
|
313
313
|
const dir = mkdtempSync(join(tmpdir(), 'worca-bundle-'));
|
|
314
314
|
try {
|
|
315
315
|
const bundlePath = join(dir, `${id}-bundle.out`);
|
|
316
|
+
// --include-models / --include-pricing: bundle the project's worca.models
|
|
317
|
+
// and worca.pricing entries that the template references (e.g. a custom
|
|
318
|
+
// "glm-ds" alias). The CLI already filters to *referenced* aliases via
|
|
319
|
+
// collect_referenced_model_aliases, so built-ins the importer already has
|
|
320
|
+
// (opus/sonnet/haiku) aren't shipped redundantly. Without these flags a
|
|
321
|
+
// bundle that names a custom alias is broken on import.
|
|
316
322
|
runWorcaTemplates(projectRoot, [
|
|
317
323
|
'export',
|
|
318
324
|
'--to',
|
|
@@ -321,6 +327,8 @@ function exportBundle(projectRoot, id, mode = 'standalone') {
|
|
|
321
327
|
id,
|
|
322
328
|
'--mode',
|
|
323
329
|
normalizedMode,
|
|
330
|
+
'--include-models',
|
|
331
|
+
'--include-pricing',
|
|
324
332
|
]);
|
|
325
333
|
if (existsSync(bundlePath)) {
|
|
326
334
|
const buf = readFileSync(bundlePath);
|
|
@@ -354,7 +362,15 @@ function exportBundle(projectRoot, id, mode = 'standalone') {
|
|
|
354
362
|
function exportGist(projectRoot, id) {
|
|
355
363
|
const stdout = runWorcaTemplates(
|
|
356
364
|
projectRoot,
|
|
357
|
-
[
|
|
365
|
+
[
|
|
366
|
+
'export',
|
|
367
|
+
'--to',
|
|
368
|
+
'gist',
|
|
369
|
+
'--templates',
|
|
370
|
+
id,
|
|
371
|
+
'--include-models',
|
|
372
|
+
'--include-pricing',
|
|
373
|
+
],
|
|
358
374
|
{ timeout: 30000 },
|
|
359
375
|
);
|
|
360
376
|
// The CLI prints the gist URL (from `gh gist create`) as its final stdout line.
|
|
@@ -362,6 +378,50 @@ function exportGist(projectRoot, id) {
|
|
|
362
378
|
return match ? match[0] : null;
|
|
363
379
|
}
|
|
364
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
|
+
) {
|
|
408
|
+
const args = [
|
|
409
|
+
'import',
|
|
410
|
+
'--from',
|
|
411
|
+
bundlePath,
|
|
412
|
+
'--scope',
|
|
413
|
+
dstTier,
|
|
414
|
+
'--non-interactive',
|
|
415
|
+
];
|
|
416
|
+
if (resolutionsPath) {
|
|
417
|
+
args.push('--resolutions', resolutionsPath);
|
|
418
|
+
}
|
|
419
|
+
if (onModelConflict) {
|
|
420
|
+
args.push('--on-model-conflict', onModelConflict);
|
|
421
|
+
}
|
|
422
|
+
return args;
|
|
423
|
+
}
|
|
424
|
+
|
|
365
425
|
function handleZipImport(req, res) {
|
|
366
426
|
const dstTier = req.query.dst_tier;
|
|
367
427
|
if (!isValidTier(dstTier)) {
|
|
@@ -373,18 +433,18 @@ function handleZipImport(req, res) {
|
|
|
373
433
|
if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
|
|
374
434
|
|
|
375
435
|
const zipBuffer = req.body;
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
);
|
|
436
|
+
const resolutions = _parseResolutionsHeader(req);
|
|
437
|
+
const onModelConflict = req.query.on_model_conflict || null;
|
|
438
|
+
const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
|
|
439
|
+
const tmpPath = join(dir, 'bundle.zip');
|
|
380
440
|
try {
|
|
381
441
|
writeFileSync(tmpPath, zipBuffer);
|
|
442
|
+
const resolutionsPath = _writeResolutionsFile(dir, resolutions);
|
|
382
443
|
const stdout = runWorcaTemplates(
|
|
383
444
|
req.project.projectRoot,
|
|
384
|
-
|
|
445
|
+
_baseImportArgs(tmpPath, dstTier, resolutionsPath, onModelConflict),
|
|
385
446
|
{ timeout: 60000 },
|
|
386
447
|
);
|
|
387
|
-
// Best-effort summary: the CLI may print imported template ids to stdout
|
|
388
448
|
const imported = [];
|
|
389
449
|
for (const line of (stdout || '').split('\n')) {
|
|
390
450
|
const m = line.match(/imported[:\s]+([a-z0-9_-]+)/i);
|
|
@@ -396,16 +456,76 @@ function handleZipImport(req, res) {
|
|
|
396
456
|
.status(statusForCliCode(err.cliCode))
|
|
397
457
|
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
398
458
|
} finally {
|
|
459
|
+
cleanupTemp(dir);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function handleImportPreview(req, res) {
|
|
464
|
+
const dstTier = req.query.dst_tier;
|
|
465
|
+
if (!isValidTier(dstTier)) {
|
|
466
|
+
return res.status(400).json({
|
|
467
|
+
ok: false,
|
|
468
|
+
error: `dst_tier must be one of: ${TIERS.join(', ')}`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
|
|
472
|
+
|
|
473
|
+
const ctype = req.headers['content-type'] || '';
|
|
474
|
+
const dir = mkdtempSync(join(tmpdir(), 'worca-import-preview-'));
|
|
475
|
+
try {
|
|
476
|
+
let bundlePath;
|
|
477
|
+
if (ctype.startsWith('application/zip')) {
|
|
478
|
+
bundlePath = join(dir, 'bundle.zip');
|
|
479
|
+
writeFileSync(bundlePath, req.body);
|
|
480
|
+
} else {
|
|
481
|
+
const { bundle } = req.body || {};
|
|
482
|
+
if (!bundle || typeof bundle !== 'object') {
|
|
483
|
+
return res
|
|
484
|
+
.status(400)
|
|
485
|
+
.json({ ok: false, error: 'bundle must be a JSON object' });
|
|
486
|
+
}
|
|
487
|
+
bundlePath = join(dir, 'bundle.json');
|
|
488
|
+
writeFileSync(bundlePath, JSON.stringify(bundle), 'utf8');
|
|
489
|
+
}
|
|
490
|
+
const stdout = runWorcaTemplates(
|
|
491
|
+
req.project.projectRoot,
|
|
492
|
+
[
|
|
493
|
+
'import',
|
|
494
|
+
'--from',
|
|
495
|
+
bundlePath,
|
|
496
|
+
'--scope',
|
|
497
|
+
dstTier,
|
|
498
|
+
'--non-interactive',
|
|
499
|
+
'--preview',
|
|
500
|
+
],
|
|
501
|
+
{ timeout: 30000 },
|
|
502
|
+
);
|
|
399
503
|
try {
|
|
400
|
-
|
|
504
|
+
const payload = JSON.parse(stdout);
|
|
505
|
+
res.json({ ok: true, ...payload });
|
|
401
506
|
} catch {
|
|
402
|
-
|
|
507
|
+
res.status(500).json({
|
|
508
|
+
ok: false,
|
|
509
|
+
error: 'CLI preview output was not valid JSON',
|
|
510
|
+
raw: stdout,
|
|
511
|
+
});
|
|
403
512
|
}
|
|
513
|
+
} catch (err) {
|
|
514
|
+
res
|
|
515
|
+
.status(statusForCliCode(err.cliCode))
|
|
516
|
+
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
517
|
+
} finally {
|
|
518
|
+
cleanupTemp(dir);
|
|
404
519
|
}
|
|
405
520
|
}
|
|
406
521
|
|
|
407
522
|
function handleJsonImport(req, res) {
|
|
408
|
-
const {
|
|
523
|
+
const {
|
|
524
|
+
bundle,
|
|
525
|
+
dst_tier: dstTier,
|
|
526
|
+
resolutions,
|
|
527
|
+
on_model_conflict: onModelConflict,
|
|
528
|
+
} = req.body || {};
|
|
409
529
|
if (!bundle || typeof bundle !== 'object') {
|
|
410
530
|
return res
|
|
411
531
|
.status(400)
|
|
@@ -419,7 +539,10 @@ function handleJsonImport(req, res) {
|
|
|
419
539
|
}
|
|
420
540
|
if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
|
|
421
541
|
try {
|
|
422
|
-
const result = importBundle(req.project.projectRoot, bundle, dstTier
|
|
542
|
+
const result = importBundle(req.project.projectRoot, bundle, dstTier, {
|
|
543
|
+
resolutions,
|
|
544
|
+
onModelConflict,
|
|
545
|
+
});
|
|
423
546
|
res.json({ ok: true, ...result });
|
|
424
547
|
} catch (err) {
|
|
425
548
|
res
|
|
@@ -428,17 +551,19 @@ function handleJsonImport(req, res) {
|
|
|
428
551
|
}
|
|
429
552
|
}
|
|
430
553
|
|
|
431
|
-
function importBundle(projectRoot, bundle, tier) {
|
|
554
|
+
function importBundle(projectRoot, bundle, tier, opts = {}) {
|
|
432
555
|
if (!bundle || !Array.isArray(bundle.templates)) {
|
|
433
556
|
throw new Error('Bundle must contain a "templates" array');
|
|
434
557
|
}
|
|
558
|
+
const { resolutions, onModelConflict } = opts;
|
|
435
559
|
const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
|
|
436
560
|
const bundlePath = join(dir, 'bundle.json');
|
|
437
561
|
try {
|
|
438
562
|
writeFileSync(bundlePath, JSON.stringify(bundle), 'utf8');
|
|
563
|
+
const resolutionsPath = _writeResolutionsFile(dir, resolutions);
|
|
439
564
|
runWorcaTemplates(
|
|
440
565
|
projectRoot,
|
|
441
|
-
|
|
566
|
+
_baseImportArgs(bundlePath, tier, resolutionsPath, onModelConflict),
|
|
442
567
|
{ timeout: 60000 },
|
|
443
568
|
);
|
|
444
569
|
const targetDir = dirForTier(projectRoot, tier);
|
|
@@ -577,6 +702,12 @@ export function createTemplatesRoutes() {
|
|
|
577
702
|
},
|
|
578
703
|
);
|
|
579
704
|
|
|
705
|
+
router.post(
|
|
706
|
+
'/templates/import/preview',
|
|
707
|
+
expressRaw({ type: 'application/zip', limit: '1mb' }),
|
|
708
|
+
(req, res) => handleImportPreview(req, res),
|
|
709
|
+
);
|
|
710
|
+
|
|
580
711
|
/**
|
|
581
712
|
* POST /api/projects/:projectId/templates/validate
|
|
582
713
|
* Body: { config }
|