@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/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 == a dir's [chevron 18px + gap 6px], so a child's content
9342
- (file name, or sub-folder chevron) starts exactly under its parent's
9343
- folder icon. */
9344
- padding-left: calc(8px + var(--depth, 0) * 24px);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -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 [...root.children.values()].map(serializeNode);
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.
@@ -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;
@@ -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
- ['export', '--to', 'gist', '--templates', id],
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 tmpPath = join(
377
- tmpdir(),
378
- `worca-import-${Math.random().toString(36).slice(2)}.zip`,
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
- ['import', '--from', tmpPath, '--scope', dstTier, '--non-interactive'],
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
- rmSync(tmpPath, { force: true });
504
+ const payload = JSON.parse(stdout);
505
+ res.json({ ok: true, ...payload });
401
506
  } catch {
402
- /* ignore cleanup errors */
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 { bundle, dst_tier: dstTier } = req.body || {};
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
- ['import', '--from', bundlePath, '--scope', tier, '--non-interactive'],
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 }