@worca/ui 0.37.0 → 0.40.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 +4047 -3428
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +1078 -2
- package/package.json +1 -1
- package/server/app.js +17 -0
- package/server/project-routes.js +7 -39
- package/server/templates-routes.js +742 -0
- package/server/version-check.js +11 -2
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API routes for pipeline templates.
|
|
3
|
+
*
|
|
4
|
+
* Thin shim over the `worca templates` CLI. All mutating operations
|
|
5
|
+
* (create / update / delete / duplicate / import / rename) delegate to
|
|
6
|
+
* the Python CLI so `worca/orchestrator/templates.TemplateResolver`
|
|
7
|
+
* stays the single source of truth for validation, naming collisions,
|
|
8
|
+
* and the on-disk layout.
|
|
9
|
+
*
|
|
10
|
+
* Resource model: `(tier, id)` is the primary key. Every route below
|
|
11
|
+
* carries the tier explicitly so the editor / API never has to guess
|
|
12
|
+
* which copy it's operating on. List does NOT dedup — built-ins
|
|
13
|
+
* always render even when a project copy with the same id exists,
|
|
14
|
+
* which lets the UI mark the shadow without hiding the original.
|
|
15
|
+
*
|
|
16
|
+
* Provides:
|
|
17
|
+
* GET /templates — list (flat, each entry tagged with tier)
|
|
18
|
+
* GET /templates/:tier/:id — fetch exact (tier, id)
|
|
19
|
+
* POST /templates/:tier — create — body { id, name?, description?, config?, params?, tags? }
|
|
20
|
+
* PUT /templates/:tier/:id — upsert (rejects tier=builtin with 405)
|
|
21
|
+
* DELETE /templates/:tier/:id — delete (rejects tier=builtin with 405)
|
|
22
|
+
* POST /templates/:tier/:id/duplicate — body { dst_tier, dst_id }
|
|
23
|
+
* POST /templates/:tier/:id/rename — body { dst_tier, dst_id }
|
|
24
|
+
* POST /templates/:tier/:id/validate — body { config }
|
|
25
|
+
* GET /templates/:tier/:id/bundle — export (redacted)
|
|
26
|
+
* POST /templates/import — body { bundle, dst_tier }
|
|
27
|
+
* PUT /default-template — body { tier, id } | { tier: null, id: null } to clear
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFileSync } from 'node:child_process';
|
|
31
|
+
import {
|
|
32
|
+
existsSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
mkdtempSync,
|
|
35
|
+
readdirSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
rmSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
} from 'node:fs';
|
|
40
|
+
import { tmpdir } from 'node:os';
|
|
41
|
+
import { dirname, join } from 'node:path';
|
|
42
|
+
import { Router } from 'express';
|
|
43
|
+
import { atomicWriteSync } from './atomic-write.js';
|
|
44
|
+
import { templatesDir } from './paths.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Match template IDs: lowercase alphanumeric, hyphens, and underscores,
|
|
48
|
+
* 1-64 chars. Underscores are allowed so worca init's auto-migrated
|
|
49
|
+
* `_legacy-settings` template round-trips through the API without
|
|
50
|
+
* 400-ing. Mirrors the Python `TemplateResolver.save()` validator.
|
|
51
|
+
*/
|
|
52
|
+
const TEMPLATE_RE = /^[a-z0-9_-]{1,64}$/;
|
|
53
|
+
const TIERS = ['project', 'user', 'builtin'];
|
|
54
|
+
const MUTABLE_TIERS = ['project', 'user'];
|
|
55
|
+
export { TEMPLATE_RE, TIERS };
|
|
56
|
+
|
|
57
|
+
function isValidTier(tier) {
|
|
58
|
+
return TIERS.includes(tier);
|
|
59
|
+
}
|
|
60
|
+
function isMutableTier(tier) {
|
|
61
|
+
return MUTABLE_TIERS.includes(tier);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readTemplateJson(dirPath) {
|
|
65
|
+
const manifestPath = join(dirPath, 'template.json');
|
|
66
|
+
if (!existsSync(manifestPath)) return null;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Directory for a given tier. Built-in is the runtime copy worca init
|
|
76
|
+
* stages under .claude/worca/templates/.
|
|
77
|
+
*/
|
|
78
|
+
function dirForTier(projectRoot, tier) {
|
|
79
|
+
switch (tier) {
|
|
80
|
+
case 'project':
|
|
81
|
+
return join(projectRoot, '.claude', 'templates');
|
|
82
|
+
case 'user':
|
|
83
|
+
return templatesDir();
|
|
84
|
+
case 'builtin':
|
|
85
|
+
return join(projectRoot, '.claude', 'worca', 'templates');
|
|
86
|
+
default:
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Lists every template across all three tiers — NO dedup. Each entry
|
|
93
|
+
* carries its own `tier` field. Callers (UI) are free to group / cross-
|
|
94
|
+
* reference; the API surfaces the truth on disk.
|
|
95
|
+
*
|
|
96
|
+
* Output sort order matches `TemplateResolver.list` on the Python
|
|
97
|
+
* side: builtins alpha → projects alpha → users newest-first.
|
|
98
|
+
*/
|
|
99
|
+
function listTemplatesFlat(projectRoot) {
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const tier of TIERS) {
|
|
102
|
+
const dir = dirForTier(projectRoot, tier);
|
|
103
|
+
if (!existsSync(dir)) continue;
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (!entry.isDirectory()) continue;
|
|
112
|
+
const manifest = readTemplateJson(join(dir, entry.name));
|
|
113
|
+
if (!manifest) continue;
|
|
114
|
+
const id = manifest.id || entry.name;
|
|
115
|
+
out.push({
|
|
116
|
+
tier,
|
|
117
|
+
id,
|
|
118
|
+
name: manifest.name || id,
|
|
119
|
+
description: manifest.description || '',
|
|
120
|
+
config: manifest.config || {},
|
|
121
|
+
params: manifest.params || {},
|
|
122
|
+
tags: manifest.tags || [],
|
|
123
|
+
created_at: manifest.created_at,
|
|
124
|
+
builtin: manifest.builtin === true || tier === 'builtin',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const tierOrder = { builtin: 0, project: 1, user: 2 };
|
|
130
|
+
out.sort((a, b) => {
|
|
131
|
+
if (a.tier !== b.tier) return tierOrder[a.tier] - tierOrder[b.tier];
|
|
132
|
+
if (a.tier === 'user') {
|
|
133
|
+
return (b.created_at || '').localeCompare(a.created_at || '');
|
|
134
|
+
}
|
|
135
|
+
return a.id.localeCompare(b.id);
|
|
136
|
+
});
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Look up a single (tier, id). Returns the parsed template.json + tier,
|
|
142
|
+
* or null.
|
|
143
|
+
*/
|
|
144
|
+
function fetchTemplate(projectRoot, tier, id) {
|
|
145
|
+
const dir = dirForTier(projectRoot, tier);
|
|
146
|
+
if (!dir) return null;
|
|
147
|
+
const manifest = readTemplateJson(join(dir, id));
|
|
148
|
+
if (!manifest) return null;
|
|
149
|
+
return { tier, template: manifest };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Runs `worca templates …` with the given args.
|
|
154
|
+
*
|
|
155
|
+
* Returns trimmed stdout on success. On non-zero exit, throws an
|
|
156
|
+
* Error whose `cliCode` field carries a normalized code parsed from
|
|
157
|
+
* stderr ('name_collision' | 'not_found' | 'validation_error' |
|
|
158
|
+
* 'unknown'), and whose `cliStderr` field carries the raw stderr.
|
|
159
|
+
*/
|
|
160
|
+
function runWorcaTemplates(projectRoot, args, opts = {}) {
|
|
161
|
+
try {
|
|
162
|
+
const stdout = execFileSync(
|
|
163
|
+
'worca',
|
|
164
|
+
['templates', '--project-root', projectRoot, ...args],
|
|
165
|
+
{
|
|
166
|
+
cwd: projectRoot,
|
|
167
|
+
encoding: 'utf8',
|
|
168
|
+
timeout: opts.timeout ?? 30000,
|
|
169
|
+
input: opts.stdin,
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
return typeof stdout === 'string' ? stdout.trim() : '';
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const stderr = err.stderr?.toString?.() || '';
|
|
175
|
+
const stdout = err.stdout?.toString?.() || '';
|
|
176
|
+
const combined = `${stderr}\n${stdout}`.toLowerCase();
|
|
177
|
+
let code = 'unknown';
|
|
178
|
+
if (
|
|
179
|
+
combined.includes('already exists') ||
|
|
180
|
+
combined.includes('name_collision')
|
|
181
|
+
) {
|
|
182
|
+
code = 'name_collision';
|
|
183
|
+
} else if (
|
|
184
|
+
combined.includes('not found') ||
|
|
185
|
+
combined.includes('not_found')
|
|
186
|
+
) {
|
|
187
|
+
code = 'not_found';
|
|
188
|
+
} else if (
|
|
189
|
+
combined.includes('validation') ||
|
|
190
|
+
combined.includes('invalid')
|
|
191
|
+
) {
|
|
192
|
+
code = 'validation_error';
|
|
193
|
+
}
|
|
194
|
+
const e = new Error(stderr.trim() || err.message || 'worca CLI failed');
|
|
195
|
+
e.cliCode = code;
|
|
196
|
+
e.cliStderr = stderr;
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function statusForCliCode(code) {
|
|
202
|
+
switch (code) {
|
|
203
|
+
case 'name_collision':
|
|
204
|
+
return 409;
|
|
205
|
+
case 'not_found':
|
|
206
|
+
return 404;
|
|
207
|
+
case 'validation_error':
|
|
208
|
+
return 400;
|
|
209
|
+
default:
|
|
210
|
+
return 500;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function validateConfig(projectRoot, config) {
|
|
215
|
+
try {
|
|
216
|
+
const stdout = runWorcaTemplates(
|
|
217
|
+
projectRoot,
|
|
218
|
+
['validate', '--config', JSON.stringify(config || {})],
|
|
219
|
+
{ timeout: 10000 },
|
|
220
|
+
);
|
|
221
|
+
const issues = JSON.parse(stdout || '[]');
|
|
222
|
+
return { issues: Array.isArray(issues) ? issues : [] };
|
|
223
|
+
} catch {
|
|
224
|
+
return { issues: [] };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function writeTempJson(obj) {
|
|
229
|
+
const dir = mkdtempSync(join(tmpdir(), 'worca-tpl-'));
|
|
230
|
+
const path = join(dir, 'template.json');
|
|
231
|
+
writeFileSync(path, JSON.stringify(obj), 'utf8');
|
|
232
|
+
return { path, dir };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function cleanupTemp(tempDir) {
|
|
236
|
+
try {
|
|
237
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
238
|
+
} catch {
|
|
239
|
+
/* ignore */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function saveTemplateViaCli(projectRoot, tier, id, payload) {
|
|
244
|
+
const templateData = {
|
|
245
|
+
id,
|
|
246
|
+
name: payload.name || id,
|
|
247
|
+
description: payload.description || '',
|
|
248
|
+
tags: payload.tags || [],
|
|
249
|
+
params: payload.params || {},
|
|
250
|
+
config: payload.config || {},
|
|
251
|
+
};
|
|
252
|
+
const { path, dir } = writeTempJson(templateData);
|
|
253
|
+
try {
|
|
254
|
+
const args = ['create', '--from-file', path];
|
|
255
|
+
if (tier === 'user') args.push('--global');
|
|
256
|
+
runWorcaTemplates(projectRoot, args);
|
|
257
|
+
} finally {
|
|
258
|
+
cleanupTemp(dir);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function deleteTemplateViaCli(projectRoot, tier, id) {
|
|
263
|
+
const args = ['delete', id];
|
|
264
|
+
if (tier === 'user') args.push('--global');
|
|
265
|
+
runWorcaTemplates(projectRoot, args);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function duplicateTemplateViaCli(projectRoot, srcId, dstId, dstTier) {
|
|
269
|
+
// CLI's `duplicate` reads source from any tier via precedence; for
|
|
270
|
+
// strict (src_tier, src_id) reads we'd need a CLI extension. Today's
|
|
271
|
+
// semantics: src is resolved by id with project > user > builtin
|
|
272
|
+
// precedence — same as the run launcher. Acceptable since the most
|
|
273
|
+
// common shadow-edit flow targets the highest-priority match anyway.
|
|
274
|
+
runWorcaTemplates(projectRoot, [
|
|
275
|
+
'duplicate',
|
|
276
|
+
srcId,
|
|
277
|
+
'--dst',
|
|
278
|
+
dstId,
|
|
279
|
+
'--dst-scope',
|
|
280
|
+
dstTier,
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function exportBundle(projectRoot, id) {
|
|
285
|
+
const dir = mkdtempSync(join(tmpdir(), 'worca-bundle-'));
|
|
286
|
+
const bundlePath = join(dir, `${id}.json`);
|
|
287
|
+
try {
|
|
288
|
+
runWorcaTemplates(projectRoot, [
|
|
289
|
+
'export',
|
|
290
|
+
'--to',
|
|
291
|
+
bundlePath,
|
|
292
|
+
'--templates',
|
|
293
|
+
id,
|
|
294
|
+
]);
|
|
295
|
+
if (existsSync(bundlePath)) {
|
|
296
|
+
return JSON.parse(readFileSync(bundlePath, 'utf8'));
|
|
297
|
+
}
|
|
298
|
+
return { templates: [{ id }] };
|
|
299
|
+
} finally {
|
|
300
|
+
cleanupTemp(dir);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function importBundle(projectRoot, bundle, tier) {
|
|
305
|
+
if (!bundle || !Array.isArray(bundle.templates)) {
|
|
306
|
+
throw new Error('Bundle must contain a "templates" array');
|
|
307
|
+
}
|
|
308
|
+
const dir = mkdtempSync(join(tmpdir(), 'worca-import-'));
|
|
309
|
+
const bundlePath = join(dir, 'bundle.json');
|
|
310
|
+
try {
|
|
311
|
+
writeFileSync(bundlePath, JSON.stringify(bundle), 'utf8');
|
|
312
|
+
runWorcaTemplates(
|
|
313
|
+
projectRoot,
|
|
314
|
+
['import', '--from', bundlePath, '--scope', tier, '--non-interactive'],
|
|
315
|
+
{ timeout: 60000 },
|
|
316
|
+
);
|
|
317
|
+
const targetDir = dirForTier(projectRoot, tier);
|
|
318
|
+
const imported = [];
|
|
319
|
+
for (const tmpl of bundle.templates) {
|
|
320
|
+
const id = tmpl.id;
|
|
321
|
+
const manifest = readTemplateJson(join(targetDir, id));
|
|
322
|
+
if (manifest) {
|
|
323
|
+
imported.push({
|
|
324
|
+
id: manifest.id || id,
|
|
325
|
+
name: manifest.name || id,
|
|
326
|
+
tier,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { imported, count: imported.length };
|
|
331
|
+
} finally {
|
|
332
|
+
cleanupTemp(dir);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Standard tier/id validation guard. Sends a 400 and returns true if
|
|
338
|
+
* something is wrong; returns false when the route handler should
|
|
339
|
+
* continue.
|
|
340
|
+
*/
|
|
341
|
+
function rejectInvalidTierId(res, tier, id, { idRequired = true } = {}) {
|
|
342
|
+
if (!isValidTier(tier)) {
|
|
343
|
+
res.status(400).json({
|
|
344
|
+
ok: false,
|
|
345
|
+
error: `tier must be one of: ${TIERS.join(', ')}`,
|
|
346
|
+
});
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
if (idRequired && (!id || !TEMPLATE_RE.test(id))) {
|
|
350
|
+
res.status(400).json({
|
|
351
|
+
ok: false,
|
|
352
|
+
error: 'id must match ^[a-z0-9_-]{1,64}$',
|
|
353
|
+
});
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Built-in tier is immutable via the API. Returns true (and sends 405)
|
|
361
|
+
* if the request would write to a built-in; false otherwise.
|
|
362
|
+
*/
|
|
363
|
+
function rejectBuiltinWrite(res, tier, op = 'modify') {
|
|
364
|
+
if (tier === 'builtin') {
|
|
365
|
+
res.status(405).json({
|
|
366
|
+
ok: false,
|
|
367
|
+
error: `Built-in templates are immutable — cannot ${op} via the API. Duplicate to project or user scope first.`,
|
|
368
|
+
});
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Creates the templates router. Must be mounted after projectResolver
|
|
376
|
+
* middleware sets `req.project` with { projectRoot, settingsPath, … }.
|
|
377
|
+
*/
|
|
378
|
+
export function createTemplatesRoutes() {
|
|
379
|
+
const router = Router({ mergeParams: true });
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* GET /api/projects/:projectId/templates
|
|
383
|
+
* List every template across all tiers. No dedup, no shadows field —
|
|
384
|
+
* each entry carries its own `tier`. The UI groups client-side.
|
|
385
|
+
*/
|
|
386
|
+
router.get('/templates', (req, res) => {
|
|
387
|
+
try {
|
|
388
|
+
const { projectRoot, settingsPath } = req.project;
|
|
389
|
+
const templates = listTemplatesFlat(projectRoot);
|
|
390
|
+
// Include the project's default_template pointer in the same
|
|
391
|
+
// response so the cards don't render once without the ★ Default
|
|
392
|
+
// badge and then re-render after a second `/settings` round-trip
|
|
393
|
+
// arrives. One request, both bits of state.
|
|
394
|
+
let defaultTemplate = null;
|
|
395
|
+
try {
|
|
396
|
+
if (settingsPath && existsSync(settingsPath)) {
|
|
397
|
+
const parsed = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
398
|
+
defaultTemplate = parsed?.worca?.default_template || null;
|
|
399
|
+
}
|
|
400
|
+
} catch (_err) {
|
|
401
|
+
// Bad settings.json shouldn't break the template list — just
|
|
402
|
+
// omit the default. The Settings tab surfaces the real error.
|
|
403
|
+
}
|
|
404
|
+
res.json({ ok: true, templates, default_template: defaultTemplate });
|
|
405
|
+
} catch (err) {
|
|
406
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* GET /api/projects/:projectId/templates/:tier/:id
|
|
412
|
+
*/
|
|
413
|
+
router.get('/templates/:tier/:id', (req, res) => {
|
|
414
|
+
const { tier, id } = req.params;
|
|
415
|
+
if (rejectInvalidTierId(res, tier, id)) return;
|
|
416
|
+
try {
|
|
417
|
+
const found = fetchTemplate(req.project.projectRoot, tier, id);
|
|
418
|
+
if (!found) {
|
|
419
|
+
return res.status(404).json({
|
|
420
|
+
ok: false,
|
|
421
|
+
error: `Template "${id}" not found in ${tier} scope`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
res.json({ ok: true, template: found.template, tier: found.tier });
|
|
425
|
+
} catch (err) {
|
|
426
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* POST /api/projects/:projectId/templates/import
|
|
432
|
+
*
|
|
433
|
+
* Registered *before* `POST /templates/:tier` so Express's router
|
|
434
|
+
* doesn't match the literal "import" path segment as a tier
|
|
435
|
+
* parameter (which would 400 with "tier must be one of …").
|
|
436
|
+
*
|
|
437
|
+
* Body: { bundle: {templates: [...]}, dst_tier }
|
|
438
|
+
*/
|
|
439
|
+
router.post('/templates/import', (req, res) => {
|
|
440
|
+
const { bundle, dst_tier: dstTier } = req.body || {};
|
|
441
|
+
if (!bundle || typeof bundle !== 'object') {
|
|
442
|
+
return res
|
|
443
|
+
.status(400)
|
|
444
|
+
.json({ ok: false, error: 'bundle must be a JSON object' });
|
|
445
|
+
}
|
|
446
|
+
if (!isValidTier(dstTier)) {
|
|
447
|
+
return res.status(400).json({
|
|
448
|
+
ok: false,
|
|
449
|
+
error: `dst_tier must be one of: ${TIERS.join(', ')}`,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
|
|
453
|
+
try {
|
|
454
|
+
const result = importBundle(req.project.projectRoot, bundle, dstTier);
|
|
455
|
+
res.json({ ok: true, ...result });
|
|
456
|
+
} catch (err) {
|
|
457
|
+
res
|
|
458
|
+
.status(statusForCliCode(err.cliCode))
|
|
459
|
+
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* POST /api/projects/:projectId/templates/validate
|
|
465
|
+
* Body: { config }
|
|
466
|
+
*
|
|
467
|
+
* The validator is generic — it only inspects the posted `config`
|
|
468
|
+
* against the schema; the (tier, id) of any existing template are
|
|
469
|
+
* irrelevant. Keep the path tier-free so the editor can probe
|
|
470
|
+
* arbitrary drafts without inventing a placeholder tier/id pair.
|
|
471
|
+
*
|
|
472
|
+
* Registered before `/templates/:tier` (and any other 1-segment
|
|
473
|
+
* route below) so Express doesn't try to match "validate" as a
|
|
474
|
+
* tier parameter.
|
|
475
|
+
*/
|
|
476
|
+
router.post('/templates/validate', (req, res) => {
|
|
477
|
+
const { config } = req.body || {};
|
|
478
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
479
|
+
return res
|
|
480
|
+
.status(400)
|
|
481
|
+
.json({ ok: false, error: 'config must be a JSON object' });
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const { issues } = validateConfig(req.project.projectRoot, config);
|
|
485
|
+
res.json({ ok: true, issues });
|
|
486
|
+
} catch (err) {
|
|
487
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* POST /api/projects/:projectId/templates/:tier
|
|
493
|
+
* Body: { id, name?, description?, config?, params?, tags? }
|
|
494
|
+
*/
|
|
495
|
+
router.post('/templates/:tier', (req, res) => {
|
|
496
|
+
const { tier } = req.params;
|
|
497
|
+
if (!isValidTier(tier)) {
|
|
498
|
+
return res.status(400).json({
|
|
499
|
+
ok: false,
|
|
500
|
+
error: `tier must be one of: ${TIERS.join(', ')}`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
if (rejectBuiltinWrite(res, tier, 'create in')) return;
|
|
504
|
+
const { id, name, description, config, params, tags } = req.body || {};
|
|
505
|
+
if (!id || typeof id !== 'string' || !TEMPLATE_RE.test(id)) {
|
|
506
|
+
return res.status(400).json({
|
|
507
|
+
ok: false,
|
|
508
|
+
error: 'id is required and must match ^[a-z0-9_-]{1,64}$',
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
const { projectRoot } = req.project;
|
|
512
|
+
if (fetchTemplate(projectRoot, tier, id)) {
|
|
513
|
+
return res.status(409).json({
|
|
514
|
+
ok: false,
|
|
515
|
+
error: `Template "${id}" already exists in ${tier} scope`,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
saveTemplateViaCli(projectRoot, tier, id, {
|
|
520
|
+
name,
|
|
521
|
+
description,
|
|
522
|
+
config,
|
|
523
|
+
params,
|
|
524
|
+
tags,
|
|
525
|
+
});
|
|
526
|
+
res.status(201).json({ ok: true, tier, id });
|
|
527
|
+
} catch (err) {
|
|
528
|
+
res
|
|
529
|
+
.status(statusForCliCode(err.cliCode))
|
|
530
|
+
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* PUT /api/projects/:projectId/templates/:tier/:id
|
|
536
|
+
* Body: { name?, description?, config?, params?, tags? }
|
|
537
|
+
*/
|
|
538
|
+
router.put('/templates/:tier/:id', (req, res) => {
|
|
539
|
+
const { tier, id } = req.params;
|
|
540
|
+
if (rejectInvalidTierId(res, tier, id)) return;
|
|
541
|
+
if (rejectBuiltinWrite(res, tier, 'update')) return;
|
|
542
|
+
try {
|
|
543
|
+
saveTemplateViaCli(req.project.projectRoot, tier, id, req.body || {});
|
|
544
|
+
res.json({ ok: true, tier, id });
|
|
545
|
+
} catch (err) {
|
|
546
|
+
res
|
|
547
|
+
.status(statusForCliCode(err.cliCode))
|
|
548
|
+
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* DELETE /api/projects/:projectId/templates/:tier/:id
|
|
554
|
+
*/
|
|
555
|
+
router.delete('/templates/:tier/:id', (req, res) => {
|
|
556
|
+
const { tier, id } = req.params;
|
|
557
|
+
if (rejectInvalidTierId(res, tier, id)) return;
|
|
558
|
+
if (rejectBuiltinWrite(res, tier, 'delete')) return;
|
|
559
|
+
const { projectRoot } = req.project;
|
|
560
|
+
if (!fetchTemplate(projectRoot, tier, id)) {
|
|
561
|
+
return res.status(404).json({
|
|
562
|
+
ok: false,
|
|
563
|
+
error: `Template "${id}" not found in ${tier} scope`,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
deleteTemplateViaCli(projectRoot, tier, id);
|
|
568
|
+
res.json({ ok: true, deleted: true, tier, id });
|
|
569
|
+
} catch (err) {
|
|
570
|
+
res
|
|
571
|
+
.status(statusForCliCode(err.cliCode))
|
|
572
|
+
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* POST /api/projects/:projectId/templates/:tier/:id/duplicate
|
|
578
|
+
* Body: { dst_tier, dst_id }
|
|
579
|
+
*/
|
|
580
|
+
router.post('/templates/:tier/:id/duplicate', (req, res) => {
|
|
581
|
+
const { tier: srcTier, id: srcId } = req.params;
|
|
582
|
+
if (rejectInvalidTierId(res, srcTier, srcId)) return;
|
|
583
|
+
const { dst_tier: dstTier, dst_id: dstId } = req.body || {};
|
|
584
|
+
if (!isValidTier(dstTier)) {
|
|
585
|
+
return res.status(400).json({
|
|
586
|
+
ok: false,
|
|
587
|
+
error: `dst_tier must be one of: ${TIERS.join(', ')}`,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
if (rejectBuiltinWrite(res, dstTier, 'duplicate to')) return;
|
|
591
|
+
if (!dstId || !TEMPLATE_RE.test(dstId)) {
|
|
592
|
+
return res.status(400).json({ ok: false, error: 'dst_id is required' });
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
duplicateTemplateViaCli(req.project.projectRoot, srcId, dstId, dstTier);
|
|
596
|
+
res.json({
|
|
597
|
+
ok: true,
|
|
598
|
+
src_tier: srcTier,
|
|
599
|
+
src_id: srcId,
|
|
600
|
+
dst_tier: dstTier,
|
|
601
|
+
dst_id: dstId,
|
|
602
|
+
});
|
|
603
|
+
} catch (err) {
|
|
604
|
+
res
|
|
605
|
+
.status(statusForCliCode(err.cliCode))
|
|
606
|
+
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* POST /api/projects/:projectId/templates/:tier/:id/rename
|
|
612
|
+
* Body: { dst_tier, dst_id }
|
|
613
|
+
*
|
|
614
|
+
* Same best-effort composition as before — duplicate then delete.
|
|
615
|
+
* partial_rename (500) when the second leg fails after the first
|
|
616
|
+
* lands on disk.
|
|
617
|
+
*/
|
|
618
|
+
router.post('/templates/:tier/:id/rename', (req, res) => {
|
|
619
|
+
const { tier: srcTier, id: srcId } = req.params;
|
|
620
|
+
if (rejectInvalidTierId(res, srcTier, srcId)) return;
|
|
621
|
+
if (rejectBuiltinWrite(res, srcTier, 'rename')) return;
|
|
622
|
+
const { dst_tier: dstTier, dst_id: dstId } = req.body || {};
|
|
623
|
+
if (!isValidTier(dstTier)) {
|
|
624
|
+
return res.status(400).json({
|
|
625
|
+
ok: false,
|
|
626
|
+
error: `dst_tier must be one of: ${TIERS.join(', ')}`,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
if (rejectBuiltinWrite(res, dstTier, 'rename to')) return;
|
|
630
|
+
if (!dstId || !TEMPLATE_RE.test(dstId)) {
|
|
631
|
+
return res.status(400).json({ ok: false, error: 'dst_id is required' });
|
|
632
|
+
}
|
|
633
|
+
if (srcId === dstId && srcTier === dstTier) {
|
|
634
|
+
return res.status(400).json({
|
|
635
|
+
ok: false,
|
|
636
|
+
error: 'No change requested (same tier and id as the source)',
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const { projectRoot } = req.project;
|
|
641
|
+
if (!fetchTemplate(projectRoot, srcTier, srcId)) {
|
|
642
|
+
return res.status(404).json({
|
|
643
|
+
ok: false,
|
|
644
|
+
error: `Template "${srcId}" not found in ${srcTier} scope`,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
duplicateTemplateViaCli(projectRoot, srcId, dstId, dstTier);
|
|
650
|
+
} catch (err) {
|
|
651
|
+
return res
|
|
652
|
+
.status(statusForCliCode(err.cliCode))
|
|
653
|
+
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
deleteTemplateViaCli(projectRoot, srcTier, srcId);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
return res.status(500).json({
|
|
659
|
+
ok: false,
|
|
660
|
+
code: 'partial_rename',
|
|
661
|
+
error: `Renamed to "${dstId}" (${dstTier}) but failed to remove the source "${srcId}" (${srcTier}): ${err.message}`,
|
|
662
|
+
src_tier: srcTier,
|
|
663
|
+
src_id: srcId,
|
|
664
|
+
dst_tier: dstTier,
|
|
665
|
+
dst_id: dstId,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
res.json({
|
|
669
|
+
ok: true,
|
|
670
|
+
src_tier: srcTier,
|
|
671
|
+
src_id: srcId,
|
|
672
|
+
dst_tier: dstTier,
|
|
673
|
+
dst_id: dstId,
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* GET /api/projects/:projectId/templates/:tier/:id/bundle
|
|
679
|
+
*/
|
|
680
|
+
router.get('/templates/:tier/:id/bundle', (req, res) => {
|
|
681
|
+
const { tier, id } = req.params;
|
|
682
|
+
if (rejectInvalidTierId(res, tier, id)) return;
|
|
683
|
+
try {
|
|
684
|
+
const bundle = exportBundle(req.project.projectRoot, id);
|
|
685
|
+
res.json({ ok: true, bundle });
|
|
686
|
+
} catch (err) {
|
|
687
|
+
res
|
|
688
|
+
.status(statusForCliCode(err.cliCode))
|
|
689
|
+
.json({ ok: false, error: err.message, code: err.cliCode });
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* PUT /api/projects/:projectId/default-template
|
|
695
|
+
*
|
|
696
|
+
* Body: `{ tier, id }` to set, or `{ tier: null, id: null }` (also
|
|
697
|
+
* accepts `{ id: null }`) to clear. Writes to settings.json under
|
|
698
|
+
* `worca.default_template` as the object shape `{tier, id}`; the
|
|
699
|
+
* Python run-launcher accepts both this and the legacy bare-string
|
|
700
|
+
* form for backward compat.
|
|
701
|
+
*/
|
|
702
|
+
router.put('/default-template', (req, res) => {
|
|
703
|
+
const { tier, id } = req.body || {};
|
|
704
|
+
const { settingsPath } = req.project;
|
|
705
|
+
const clearing = !tier && !id;
|
|
706
|
+
if (!clearing) {
|
|
707
|
+
if (!isValidTier(tier)) {
|
|
708
|
+
return res.status(400).json({
|
|
709
|
+
ok: false,
|
|
710
|
+
error: `tier must be one of: ${TIERS.join(', ')}`,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
if (!id || typeof id !== 'string' || !TEMPLATE_RE.test(id)) {
|
|
714
|
+
return res
|
|
715
|
+
.status(400)
|
|
716
|
+
.json({ ok: false, error: 'Invalid template id' });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
let base = {};
|
|
721
|
+
if (existsSync(settingsPath)) {
|
|
722
|
+
base = JSON.parse(readFileSync(settingsPath, 'utf8')) || {};
|
|
723
|
+
}
|
|
724
|
+
if (!base.worca) base.worca = {};
|
|
725
|
+
if (clearing) {
|
|
726
|
+
delete base.worca.default_template;
|
|
727
|
+
} else {
|
|
728
|
+
base.worca.default_template = { tier, id };
|
|
729
|
+
}
|
|
730
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
731
|
+
atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
|
|
732
|
+
res.json({
|
|
733
|
+
ok: true,
|
|
734
|
+
default_template: base.worca.default_template || null,
|
|
735
|
+
});
|
|
736
|
+
} catch (err) {
|
|
737
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
return router;
|
|
742
|
+
}
|