@jhizzard/termdeck 0.7.3 → 0.9.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/docs/orchestrator-guide.md +335 -0
- package/package.json +3 -1
- package/packages/cli/src/auto-orchestrate.js +28 -22
- package/packages/cli/src/index.js +55 -11
- package/packages/cli/src/init-project.js +213 -0
- package/packages/cli/src/init-rumen.js +30 -33
- package/packages/cli/src/mcp-config.js +174 -0
- package/packages/cli/src/stack.js +61 -11
- package/packages/cli/src/templates.js +84 -0
- package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
- package/packages/cli/templates/.gitignore.tmpl +28 -0
- package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
- package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
- package/packages/cli/templates/README.md.tmpl +15 -0
- package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
- package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
- package/packages/cli/templates/project_facts.md.tmpl +39 -0
- package/packages/client/public/app.js +895 -2
- package/packages/client/public/index.html +144 -0
- package/packages/client/public/style.css +931 -52
- package/packages/server/src/config.js +96 -0
- package/packages/server/src/index.js +198 -10
- package/packages/server/src/orchestration-preview.js +256 -0
- package/packages/server/src/rag.js +43 -0
- package/packages/server/src/sprint-inject.js +156 -0
- package/packages/server/src/sprint-routes.js +503 -0
|
@@ -298,11 +298,107 @@ function addProject({ name, path: projectPath, defaultTheme, defaultCommand }) {
|
|
|
298
298
|
return parsed.projects;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
// Apply a structural patch to ~/.termdeck/config.yaml. Sprint 36 introduces
|
|
302
|
+
// this for the dashboard RAG toggle (PATCH /api/config) but the helper is
|
|
303
|
+
// generic — pass a deep partial of the config tree, every leaf in `patch` that
|
|
304
|
+
// matches the whitelist gets written through. Returns the parsed-from-disk
|
|
305
|
+
// post-write tree (NOT post-substitution; we only persist user-authored values
|
|
306
|
+
// here, never substituted secrets).
|
|
307
|
+
//
|
|
308
|
+
// Whitelist deliberately tight. Only fields a UI can safely flip live belong
|
|
309
|
+
// here. Adding a new field is an explicit one-line edit (vs. a freeform writer
|
|
310
|
+
// that would let a buggy/malicious client change `port`, `shell`, or projects).
|
|
311
|
+
//
|
|
312
|
+
// Comments and formatting in config.yaml are NOT preserved — same trade-off
|
|
313
|
+
// as `addProject`. The yaml package's parseDocument API can preserve comments
|
|
314
|
+
// but we'd need to migrate addProject too for consistency; that's a follow-up.
|
|
315
|
+
const UPDATABLE_PATHS = new Set([
|
|
316
|
+
'rag.enabled'
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
function flattenPatch(obj, prefix = '') {
|
|
320
|
+
const out = [];
|
|
321
|
+
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return out;
|
|
322
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
323
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
324
|
+
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
|
|
325
|
+
out.push(...flattenPatch(v, key));
|
|
326
|
+
} else {
|
|
327
|
+
out.push([key, v]);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function setPath(obj, segs, value) {
|
|
334
|
+
let cur = obj;
|
|
335
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
336
|
+
const s = segs[i];
|
|
337
|
+
if (cur[s] == null || typeof cur[s] !== 'object') cur[s] = {};
|
|
338
|
+
cur = cur[s];
|
|
339
|
+
}
|
|
340
|
+
cur[segs[segs.length - 1]] = value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function updateConfig(patch, configPath = CONFIG_PATH) {
|
|
344
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
345
|
+
throw new Error('updateConfig: patch must be a plain object');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const flat = flattenPatch(patch);
|
|
349
|
+
if (flat.length === 0) {
|
|
350
|
+
throw new Error('updateConfig: patch is empty');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const [key, val] of flat) {
|
|
354
|
+
if (!UPDATABLE_PATHS.has(key)) {
|
|
355
|
+
throw new Error(`updateConfig: ${key} is not in the updatable whitelist`);
|
|
356
|
+
}
|
|
357
|
+
if (key === 'rag.enabled' && typeof val !== 'boolean') {
|
|
358
|
+
throw new Error('updateConfig: rag.enabled must be a boolean');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const yaml = require('yaml');
|
|
363
|
+
let parsed = {};
|
|
364
|
+
if (fs.existsSync(configPath)) {
|
|
365
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
366
|
+
try {
|
|
367
|
+
parsed = yaml.parse(raw) || {};
|
|
368
|
+
} catch (err) {
|
|
369
|
+
throw new Error(`config.yaml is not valid YAML — refusing to overwrite: ${err.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const [key, val] of flat) {
|
|
374
|
+
setPath(parsed, key.split('.'), val);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (fs.existsSync(configPath)) {
|
|
378
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
379
|
+
const bak = `${configPath}.${ts}.bak`;
|
|
380
|
+
try {
|
|
381
|
+
fs.copyFileSync(configPath, bak);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.warn('[config] Could not write backup before updateConfig:', err.message);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const out = yaml.stringify(parsed);
|
|
388
|
+
fs.writeFileSync(configPath, out, 'utf-8');
|
|
389
|
+
console.log(`[config] updateConfig wrote ${flat.map(([k]) => k).join(', ')}`);
|
|
390
|
+
|
|
391
|
+
return parsed;
|
|
392
|
+
}
|
|
393
|
+
|
|
301
394
|
module.exports = {
|
|
302
395
|
loadConfig,
|
|
303
396
|
addProject,
|
|
397
|
+
updateConfig,
|
|
304
398
|
// exported for tests / introspection
|
|
305
399
|
_parseDotenv: parseDotenv,
|
|
306
400
|
_substituteEnv: substituteEnv,
|
|
401
|
+
_flattenPatch: flattenPatch,
|
|
402
|
+
_UPDATABLE_PATHS: UPDATABLE_PATHS,
|
|
307
403
|
_paths: { CONFIG_DIR, CONFIG_PATH, SECRETS_PATH }
|
|
308
404
|
};
|
|
@@ -61,8 +61,55 @@ const { TranscriptWriter } = require('./transcripts');
|
|
|
61
61
|
const { createHealthHandler, runPreflight } = require('./preflight');
|
|
62
62
|
const { getFullHealth } = require('./health');
|
|
63
63
|
const { themes, statusColors } = require('./themes');
|
|
64
|
-
const { loadConfig, addProject } = require('./config');
|
|
64
|
+
const { loadConfig, addProject, updateConfig } = require('./config');
|
|
65
65
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
66
|
+
const { createSprintRoutes } = require('./sprint-routes');
|
|
67
|
+
const orchestrationPreview = require('./orchestration-preview');
|
|
68
|
+
|
|
69
|
+
// Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
|
|
70
|
+
// helper is decoupled from T2's templates.js / init-project.js; we resolve
|
|
71
|
+
// them here and pass them into the helper. If a module is missing (e.g.
|
|
72
|
+
// install hasn't been completed yet), the route surfaces a 503 with a clear
|
|
73
|
+
// error rather than crashing the server.
|
|
74
|
+
let _t2Templates = null;
|
|
75
|
+
let _t2TemplatesResolved = false;
|
|
76
|
+
function _getT2Templates() {
|
|
77
|
+
if (_t2TemplatesResolved) return _t2Templates;
|
|
78
|
+
_t2TemplatesResolved = true;
|
|
79
|
+
try { _t2Templates = require('../../cli/src/templates'); }
|
|
80
|
+
catch (_e) { _t2Templates = null; }
|
|
81
|
+
return _t2Templates;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let _t2InitProject = null;
|
|
85
|
+
let _t2InitProjectResolved = false;
|
|
86
|
+
function _getT2InitProject() {
|
|
87
|
+
if (_t2InitProjectResolved) return _t2InitProject;
|
|
88
|
+
_t2InitProjectResolved = true;
|
|
89
|
+
try {
|
|
90
|
+
const mod = require('../../cli/src/init-project');
|
|
91
|
+
_t2InitProject = (mod && typeof mod.initProject === 'function')
|
|
92
|
+
? mod.initProject
|
|
93
|
+
: (typeof mod === 'function' ? mod : null);
|
|
94
|
+
} catch (_e) {
|
|
95
|
+
_t2InitProject = null;
|
|
96
|
+
}
|
|
97
|
+
return _t2InitProject;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _getT2DestFor() {
|
|
101
|
+
try {
|
|
102
|
+
const mod = require('../../cli/src/init-project');
|
|
103
|
+
return (mod && typeof mod._destFor === 'function') ? mod._destFor : undefined;
|
|
104
|
+
} catch (_e) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _termdeckVersion() {
|
|
110
|
+
try { return require('../../../package.json').version; }
|
|
111
|
+
catch { return '0.0.0'; }
|
|
112
|
+
}
|
|
66
113
|
|
|
67
114
|
function createServer(config) {
|
|
68
115
|
const app = express();
|
|
@@ -89,6 +136,14 @@ function createServer(config) {
|
|
|
89
136
|
const clientDir = path.join(__dirname, '..', '..', 'client', 'public');
|
|
90
137
|
app.use(express.static(clientDir));
|
|
91
138
|
|
|
139
|
+
// Serve repo-rooted /docs as static markdown so the dashboard right-rail Guide
|
|
140
|
+
// panel can fetch docs/orchestrator-guide.md and render it client-side.
|
|
141
|
+
// Sprint 37 T1.
|
|
142
|
+
const docsDir = path.join(__dirname, '..', '..', '..', 'docs');
|
|
143
|
+
if (fs.existsSync(docsDir)) {
|
|
144
|
+
app.use('/docs', express.static(docsDir));
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
// Initialize database
|
|
93
148
|
let db = null;
|
|
94
149
|
if (Database) {
|
|
@@ -662,10 +717,12 @@ function createServer(config) {
|
|
|
662
717
|
res.json(sessions.getAll());
|
|
663
718
|
});
|
|
664
719
|
|
|
665
|
-
// POST /api/sessions
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
720
|
+
// Reusable PTY spawn + wire helper. Used by POST /api/sessions and the
|
|
721
|
+
// in-dashboard 4+1 sprint runner (Sprint 37 T4) so multi-panel spawns reuse
|
|
722
|
+
// the same wiring (transcripts, RAG, Mnestra flashback) without copy-paste.
|
|
723
|
+
// Returns the Session object regardless of PTY success — status will be
|
|
724
|
+
// 'errored' if pty.spawn threw.
|
|
725
|
+
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason }) {
|
|
669
726
|
const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
|
|
670
727
|
const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
|
|
671
728
|
|
|
@@ -679,7 +736,6 @@ function createServer(config) {
|
|
|
679
736
|
reason: reason || 'launched via API'
|
|
680
737
|
});
|
|
681
738
|
|
|
682
|
-
// Spawn PTY
|
|
683
739
|
if (pty) {
|
|
684
740
|
// Three launch shapes:
|
|
685
741
|
// (1) no command → spawn the default shell interactively
|
|
@@ -827,9 +883,25 @@ function createServer(config) {
|
|
|
827
883
|
session.meta.statusDetail = 'node-pty not available';
|
|
828
884
|
}
|
|
829
885
|
|
|
886
|
+
return session;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// POST /api/sessions - create a new terminal session
|
|
890
|
+
app.post('/api/sessions', (req, res) => {
|
|
891
|
+
const { command, cwd, project, label, type, theme, reason } = req.body || {};
|
|
892
|
+
const session = spawnTerminalSession({ command, cwd, project, label, type, theme, reason });
|
|
830
893
|
res.status(201).json(session.toJSON());
|
|
831
894
|
});
|
|
832
895
|
|
|
896
|
+
// Sprint runner endpoints (Sprint 37 T4) — in-dashboard 4+1 sprint runner.
|
|
897
|
+
// Wraps spawnTerminalSession with two-stage submit + verify-and-poke.
|
|
898
|
+
createSprintRoutes({
|
|
899
|
+
app,
|
|
900
|
+
config,
|
|
901
|
+
spawnTerminalSession,
|
|
902
|
+
getSession: (id) => sessions.get(id),
|
|
903
|
+
});
|
|
904
|
+
|
|
833
905
|
// GET /api/sessions/:id - get session details
|
|
834
906
|
app.get('/api/sessions/:id', (req, res) => {
|
|
835
907
|
const session = sessions.get(req.params.id);
|
|
@@ -1069,16 +1141,64 @@ function createServer(config) {
|
|
|
1069
1141
|
res.json(t);
|
|
1070
1142
|
});
|
|
1071
1143
|
|
|
1072
|
-
// GET
|
|
1073
|
-
|
|
1074
|
-
|
|
1144
|
+
// Public-shape helper so GET and PATCH return the same envelope.
|
|
1145
|
+
function publicConfigPayload() {
|
|
1146
|
+
return {
|
|
1075
1147
|
projects: config.projects || {},
|
|
1076
1148
|
defaultTheme: config.defaultTheme,
|
|
1149
|
+
// ragEnabled is the EFFECTIVE state (after credential eligibility).
|
|
1150
|
+
// ragConfigEnabled is the user's intent from config.yaml. The dashboard
|
|
1151
|
+
// toggle reads ragConfigEnabled (intent) but renders a warning when it
|
|
1152
|
+
// diverges from ragEnabled (e.g. enabled in config but Supabase creds
|
|
1153
|
+
// missing → effective state stays off).
|
|
1077
1154
|
ragEnabled: rag.enabled,
|
|
1155
|
+
ragConfigEnabled: !!(config.rag && config.rag.enabled),
|
|
1156
|
+
ragSupabaseConfigured: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey),
|
|
1078
1157
|
aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
|
|
1079
1158
|
statusColors,
|
|
1080
1159
|
firstRun
|
|
1081
|
-
}
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// GET /api/config - current config (sanitized)
|
|
1164
|
+
app.get('/api/config', (req, res) => {
|
|
1165
|
+
res.json(publicConfigPayload());
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
// PATCH /api/config - update writable config fields. Sprint 36 T3 Deliverable A.
|
|
1169
|
+
// Body: { rag: { enabled: boolean } } — the only currently writable path.
|
|
1170
|
+
// Persists to ~/.termdeck/config.yaml, live-updates the in-memory integration,
|
|
1171
|
+
// and broadcasts a `config_changed` WS event so all open dashboards re-render
|
|
1172
|
+
// their RAG indicator without a refresh.
|
|
1173
|
+
app.patch('/api/config', (req, res) => {
|
|
1174
|
+
const body = req.body;
|
|
1175
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
1176
|
+
return res.status(400).json({ error: 'body must be a JSON object' });
|
|
1177
|
+
}
|
|
1178
|
+
try {
|
|
1179
|
+
updateConfig(body);
|
|
1180
|
+
} catch (err) {
|
|
1181
|
+
return res.status(400).json({ error: err.message });
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (body.rag && typeof body.rag.enabled === 'boolean') {
|
|
1185
|
+
rag.setEnabled(body.rag.enabled);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const payload = publicConfigPayload();
|
|
1189
|
+
|
|
1190
|
+
try {
|
|
1191
|
+
const wsPayload = JSON.stringify({ type: 'config_changed', config: payload });
|
|
1192
|
+
wss.clients.forEach((client) => {
|
|
1193
|
+
if (client.readyState === 1) {
|
|
1194
|
+
try { client.send(wsPayload); } catch (err) { console.error('[ws] config_changed send failed:', err); }
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
console.error('[ws] config_changed broadcast failed:', err);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
res.json(payload);
|
|
1082
1202
|
});
|
|
1083
1203
|
|
|
1084
1204
|
// POST /api/projects - add a new project on the fly, persist to config.yaml
|
|
@@ -1097,6 +1217,66 @@ function createServer(config) {
|
|
|
1097
1217
|
}
|
|
1098
1218
|
});
|
|
1099
1219
|
|
|
1220
|
+
// GET /api/projects/:name/orchestration-preview — Sprint 37 T3.
|
|
1221
|
+
// Renders T2's scaffolding templates without writing to disk so the
|
|
1222
|
+
// dashboard can show "if you ran `termdeck init --project <name>`, this
|
|
1223
|
+
// is what would be created." Read-only.
|
|
1224
|
+
app.get('/api/projects/:name/orchestration-preview', (req, res) => {
|
|
1225
|
+
const templates = _getT2Templates();
|
|
1226
|
+
if (!templates) {
|
|
1227
|
+
return res.status(503).json({
|
|
1228
|
+
error: 'Orchestration scaffolding unavailable: packages/cli/src/templates.js not loaded'
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
const preview = orchestrationPreview.buildPreview({
|
|
1233
|
+
name: req.params.name,
|
|
1234
|
+
projects: config.projects || {},
|
|
1235
|
+
cwd: process.cwd(),
|
|
1236
|
+
templates,
|
|
1237
|
+
destFor: _getT2DestFor(),
|
|
1238
|
+
version: _termdeckVersion()
|
|
1239
|
+
});
|
|
1240
|
+
res.json(preview);
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
const status = err.statusCode || 500;
|
|
1243
|
+
if (status >= 500) console.error('[orchestration-preview] GET failed:', err.message);
|
|
1244
|
+
res.status(status).json({ error: err.message });
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
// POST /api/projects/:name/orchestration-preview/generate — Sprint 37 T3.
|
|
1249
|
+
// Calls T2's initProject() to actually write the scaffolding. Body:
|
|
1250
|
+
// { force?: boolean }. Returns the same envelope as the GET preview but
|
|
1251
|
+
// with `created` instead of `wouldCreate`.
|
|
1252
|
+
app.post('/api/projects/:name/orchestration-preview/generate', async (req, res) => {
|
|
1253
|
+
const templates = _getT2Templates();
|
|
1254
|
+
const initProject = _getT2InitProject();
|
|
1255
|
+
if (!templates || !initProject) {
|
|
1256
|
+
return res.status(503).json({
|
|
1257
|
+
error: 'Orchestration scaffolding unavailable: T2 CLI modules not loaded'
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
const force = !!(req.body && req.body.force);
|
|
1261
|
+
try {
|
|
1262
|
+
const result = await orchestrationPreview.generateScaffolding({
|
|
1263
|
+
name: req.params.name,
|
|
1264
|
+
projects: config.projects || {},
|
|
1265
|
+
cwd: process.cwd(),
|
|
1266
|
+
force,
|
|
1267
|
+
initProject,
|
|
1268
|
+
templates,
|
|
1269
|
+
destFor: _getT2DestFor(),
|
|
1270
|
+
version: _termdeckVersion()
|
|
1271
|
+
});
|
|
1272
|
+
res.json(result);
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
const status = err.statusCode || 500;
|
|
1275
|
+
if (status >= 500) console.error('[orchestration-preview] generate failed:', err.message);
|
|
1276
|
+
res.status(status).json({ error: err.message });
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1100
1280
|
// GET /api/status - global status (control room data)
|
|
1101
1281
|
app.get('/api/status', (req, res) => {
|
|
1102
1282
|
const allSessions = sessions.getAll();
|
|
@@ -1466,6 +1646,14 @@ function createServer(config) {
|
|
|
1466
1646
|
|
|
1467
1647
|
ws.on('close', () => {
|
|
1468
1648
|
console.log(`[ws] Client disconnected from session ${sessionId}`);
|
|
1649
|
+
// Intentional: PTYs survive WS close. The session stays in the manager,
|
|
1650
|
+
// the PTY keeps running, and reconnecting (?session=<id>) re-binds.
|
|
1651
|
+
// PTY teardown happens only via DELETE /api/sessions/:id (user-initiated)
|
|
1652
|
+
// or the PTY's own exit event. Hard-refresh is therefore non-destructive.
|
|
1653
|
+
// Sprint 36 T3 Deliverable C audit (2026-04-27): the briefing predicted
|
|
1654
|
+
// this handler would call pty.kill() — it does not. Joshua's original
|
|
1655
|
+
// hard-refresh-loses-PTYs symptom was the reclaimStalePort SIGKILL chain
|
|
1656
|
+
// (orchestrator hotfix #2, 15:25 ET), not a WS-close cascade.
|
|
1469
1657
|
if (session.ws === ws) {
|
|
1470
1658
|
session.ws = null;
|
|
1471
1659
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Sprint 37 T3 — Orchestration preview helper.
|
|
2
|
+
//
|
|
3
|
+
// Renders the per-project scaffolding (CLAUDE.md, CONTRADICTIONS.md, etc.)
|
|
4
|
+
// without writing to disk so the dashboard can show "if you ran
|
|
5
|
+
// `termdeck init --project <name>` here, this is what would be created."
|
|
6
|
+
//
|
|
7
|
+
// All filesystem writes go through T2's initProject(); this file only
|
|
8
|
+
// renders previews. Templates and the writer are injected by the caller so
|
|
9
|
+
// the helper is unit-testable without depending on T2's CLI modules
|
|
10
|
+
// directly — server route wires the production deps; tests pass stubs.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// Same constraint T2's CLI validator should enforce. Keep these in sync if
|
|
19
|
+
// T2's regex differs at sprint close.
|
|
20
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
21
|
+
const PREVIEW_LINES = 30;
|
|
22
|
+
|
|
23
|
+
function validateName(name) {
|
|
24
|
+
if (typeof name !== 'string' || !NAME_RE.test(name) || name.includes('..')) {
|
|
25
|
+
const err = new Error(
|
|
26
|
+
'Invalid project name: must be lowercase, start with a letter or digit, ' +
|
|
27
|
+
'and contain only letters, digits, dots, underscores, or hyphens.'
|
|
28
|
+
);
|
|
29
|
+
err.statusCode = 400;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expandHome(p) {
|
|
35
|
+
if (typeof p !== 'string') return p;
|
|
36
|
+
if (p === '~' || p.startsWith('~/')) return path.join(os.homedir(), p.slice(1));
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Decide where the scaffolding would land for a given project name:
|
|
41
|
+
// 1. If `projects[name]` exists in config, use its `.path` (tilde-expanded,
|
|
42
|
+
// resolved). Preview shows what would be added/skipped against that dir.
|
|
43
|
+
// 2. Otherwise, target = path.resolve(cwd, name). Preview shows a fresh
|
|
44
|
+
// project being created from scratch.
|
|
45
|
+
function resolveTargetPath({ name, projects, cwd }) {
|
|
46
|
+
if (projects && Object.prototype.hasOwnProperty.call(projects, name)) {
|
|
47
|
+
const proj = projects[name];
|
|
48
|
+
if (proj && typeof proj.path === 'string' && proj.path) {
|
|
49
|
+
return path.resolve(expandHome(proj.path));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return path.resolve(cwd, name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function previewContent(rendered) {
|
|
56
|
+
const lines = rendered.split('\n');
|
|
57
|
+
const totalLines = lines.length;
|
|
58
|
+
const truncated = totalLines > PREVIEW_LINES;
|
|
59
|
+
const contentPreview = truncated
|
|
60
|
+
? lines.slice(0, PREVIEW_LINES).join('\n')
|
|
61
|
+
: rendered;
|
|
62
|
+
return { contentPreview, totalLines, truncated };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Normalize templates.listTemplates() output. T2's templates.js returns a
|
|
66
|
+
// flat array of filenames (e.g. 'CLAUDE.md.tmpl'); legacy/test stubs may
|
|
67
|
+
// return objects with explicit { name, targetPath }. Either is accepted.
|
|
68
|
+
//
|
|
69
|
+
// `destFor` maps a filename → absolute path inside the project tree. T2
|
|
70
|
+
// exports this from init-project.js as `_destFor`. The preview computes the
|
|
71
|
+
// path relative to `projectRoot` so the response payload uses repo-relative
|
|
72
|
+
// paths the UI can render directly.
|
|
73
|
+
function normalizeTemplateItems(rawItems, destFor, projectRoot) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const item of rawItems) {
|
|
76
|
+
if (typeof item === 'string') {
|
|
77
|
+
if (typeof destFor !== 'function') {
|
|
78
|
+
// No mapper available — best-effort: strip a trailing '.tmpl' and
|
|
79
|
+
// use the bare name as the target path. Fine for tests and a
|
|
80
|
+
// graceful fallback when init-project.js isn't on disk yet.
|
|
81
|
+
const fallback = item.endsWith('.tmpl') ? item.slice(0, -'.tmpl'.length) : item;
|
|
82
|
+
out.push({ name: item, targetPath: fallback });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let abs;
|
|
86
|
+
try {
|
|
87
|
+
abs = destFor(item, projectRoot);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Unknown template — skip rather than fail the whole preview.
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
out.push({ name: item, targetPath: path.relative(projectRoot, abs) });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (item && typeof item === 'object' && item.name && item.targetPath) {
|
|
96
|
+
out.push({ name: item.name, targetPath: item.targetPath });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildPreview({ name, projects, cwd, templates, destFor, version, now }) {
|
|
103
|
+
validateName(name);
|
|
104
|
+
if (!templates || typeof templates.listTemplates !== 'function' ||
|
|
105
|
+
typeof templates.renderTemplate !== 'function') {
|
|
106
|
+
const err = new Error('templates module is missing listTemplates / renderTemplate');
|
|
107
|
+
err.statusCode = 503;
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const targetPath = resolveTargetPath({ name, projects, cwd });
|
|
112
|
+
const exists = fs.existsSync(targetPath);
|
|
113
|
+
const renderedAt = (now && typeof now === 'function' ? now() : new Date()).toISOString();
|
|
114
|
+
|
|
115
|
+
const vars = {
|
|
116
|
+
project_name: name,
|
|
117
|
+
project_path: targetPath,
|
|
118
|
+
generated_at: renderedAt,
|
|
119
|
+
termdeck_version: version || '0.0.0'
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const items = normalizeTemplateItems(templates.listTemplates() || [], destFor, targetPath);
|
|
123
|
+
const wouldCreate = [];
|
|
124
|
+
const wouldSkip = [];
|
|
125
|
+
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
if (!item || !item.name || !item.targetPath) continue;
|
|
128
|
+
const filePath = path.join(targetPath, item.targetPath);
|
|
129
|
+
let rendered;
|
|
130
|
+
try {
|
|
131
|
+
rendered = templates.renderTemplate(item.name, vars);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Surface render errors as a wouldSkip entry rather than failing the
|
|
134
|
+
// whole preview — gives the user actionable info without breaking the
|
|
135
|
+
// pane. (Most likely cause: template references a placeholder we don't
|
|
136
|
+
// populate yet — coordination point with T2.)
|
|
137
|
+
wouldSkip.push({
|
|
138
|
+
path: item.targetPath,
|
|
139
|
+
contentPreview: '',
|
|
140
|
+
totalLines: 0,
|
|
141
|
+
renderedAt,
|
|
142
|
+
reason: `render failed: ${err.message}`
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const { contentPreview, totalLines } = previewContent(rendered);
|
|
147
|
+
const entry = {
|
|
148
|
+
path: item.targetPath,
|
|
149
|
+
contentPreview,
|
|
150
|
+
totalLines,
|
|
151
|
+
renderedAt
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// If targetPath dir doesn't exist yet, every file is a wouldCreate.
|
|
155
|
+
// If it exists, individual files that already exist are wouldSkip.
|
|
156
|
+
const fileExists = exists && fs.existsSync(filePath);
|
|
157
|
+
if (fileExists) {
|
|
158
|
+
wouldSkip.push({ ...entry, reason: 'file already exists' });
|
|
159
|
+
} else {
|
|
160
|
+
wouldCreate.push(entry);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
projectName: name,
|
|
166
|
+
targetPath,
|
|
167
|
+
exists,
|
|
168
|
+
wouldCreate,
|
|
169
|
+
wouldSkip
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// POST handler logic. Calls T2's initProject({ dryRun: false, force, cwd, name }),
|
|
174
|
+
// then returns preview-shape with `created` populated. T2's initProject is
|
|
175
|
+
// async and returns { exitCode, files } — we await it and translate a
|
|
176
|
+
// non-zero exitCode into an HTTP-mappable error.
|
|
177
|
+
async function generateScaffolding({ name, projects, cwd, force, initProject, templates, destFor, version, now }) {
|
|
178
|
+
validateName(name);
|
|
179
|
+
if (typeof initProject !== 'function') {
|
|
180
|
+
const err = new Error('initProject is not available — T2 init-project module not loaded');
|
|
181
|
+
err.statusCode = 503;
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const targetPath = resolveTargetPath({ name, projects, cwd });
|
|
186
|
+
|
|
187
|
+
// Refuse on existing non-empty dir without force, mirroring T2's CLI semantics.
|
|
188
|
+
if (fs.existsSync(targetPath)) {
|
|
189
|
+
const entries = (() => {
|
|
190
|
+
try { return fs.readdirSync(targetPath); } catch { return []; }
|
|
191
|
+
})();
|
|
192
|
+
const nonEmpty = entries.length > 0;
|
|
193
|
+
if (nonEmpty && !force) {
|
|
194
|
+
const err = new Error(
|
|
195
|
+
`Target ${targetPath} exists and is non-empty. Pass { force: true } to overwrite.`
|
|
196
|
+
);
|
|
197
|
+
err.statusCode = 409;
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// For fresh-name case, derive the cwd we hand to initProject from targetPath's
|
|
203
|
+
// parent so initProject({ name, cwd }) lands at the same targetPath.
|
|
204
|
+
const initCwd = path.dirname(targetPath);
|
|
205
|
+
const result = await initProject({ name, dryRun: false, force: !!force, cwd: initCwd });
|
|
206
|
+
|
|
207
|
+
if (result && typeof result === 'object' && typeof result.exitCode === 'number' && result.exitCode !== 0) {
|
|
208
|
+
const err = new Error(`initProject returned exit code ${result.exitCode}`);
|
|
209
|
+
err.statusCode = 500;
|
|
210
|
+
err.initProjectResult = result;
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Re-render to return preview-shape with `created` populated. We re-read
|
|
215
|
+
// each file from disk so the response reflects exactly what landed.
|
|
216
|
+
const renderedAt = (now && typeof now === 'function' ? now() : new Date()).toISOString();
|
|
217
|
+
const created = [];
|
|
218
|
+
if (templates && typeof templates.listTemplates === 'function') {
|
|
219
|
+
const items = normalizeTemplateItems(templates.listTemplates() || [], destFor, targetPath);
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
if (!item || !item.name || !item.targetPath) continue;
|
|
222
|
+
const filePath = path.join(targetPath, item.targetPath);
|
|
223
|
+
if (!fs.existsSync(filePath)) continue;
|
|
224
|
+
try {
|
|
225
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
226
|
+
const { contentPreview, totalLines } = previewContent(raw);
|
|
227
|
+
created.push({
|
|
228
|
+
path: item.targetPath,
|
|
229
|
+
contentPreview,
|
|
230
|
+
totalLines,
|
|
231
|
+
renderedAt
|
|
232
|
+
});
|
|
233
|
+
} catch (err) {
|
|
234
|
+
// Skip unreadable files — caller can re-fetch the preview if needed.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
projectName: name,
|
|
241
|
+
targetPath,
|
|
242
|
+
exists: true,
|
|
243
|
+
created,
|
|
244
|
+
initProjectResult: result || null
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
buildPreview,
|
|
250
|
+
generateScaffolding,
|
|
251
|
+
resolveTargetPath,
|
|
252
|
+
validateName,
|
|
253
|
+
expandHome,
|
|
254
|
+
_PREVIEW_LINES: PREVIEW_LINES,
|
|
255
|
+
_NAME_RE: NAME_RE
|
|
256
|
+
};
|