@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.
@@ -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 - create a new terminal session
666
- app.post('/api/sessions', (req, res) => {
667
- const { command, cwd, project, label, type, theme, reason } = req.body;
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 /api/config - current config (sanitized)
1073
- app.get('/api/config', (req, res) => {
1074
- res.json({
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
+ };