termdeck-cli 1.0.2

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/src/config.js ADDED
@@ -0,0 +1,427 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Configuration + first-run setup.
5
+ *
6
+ * The config lives at `~/.termdeck-config.json` (override with the
7
+ * TERMDECK_CONFIG environment variable, which is handy for testing).
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const inquirer = require('inquirer');
14
+
15
+ const CONFIG_VERSION = 1;
16
+
17
+ const STATUSES = ['Experimental', 'Live', 'Working', 'Pending'];
18
+
19
+ /** Color used for each status tag in the TUI. */
20
+ const STATUS_COLORS = {
21
+ Experimental: 'magenta',
22
+ Live: 'green',
23
+ Working: 'yellow',
24
+ Pending: 'cyan',
25
+ };
26
+
27
+ /** Directory names that are never project folders. */
28
+ const IGNORED_DIRS = new Set([
29
+ 'node_modules',
30
+ '$RECYCLE.BIN',
31
+ 'System Volume Information',
32
+ 'AppData',
33
+ 'Program Files',
34
+ 'Program Files (x86)',
35
+ 'ProgramData',
36
+ 'Windows',
37
+ 'Windows.old',
38
+ 'PerfLogs',
39
+ 'MSOCache',
40
+ 'Recovery',
41
+ '$WinREAgent',
42
+ 'OneDrive',
43
+ ]);
44
+
45
+ function getConfigPath() {
46
+ return process.env.TERMDECK_CONFIG || path.join(os.homedir(), '.termdeck-config.json');
47
+ }
48
+
49
+ function configExists() {
50
+ try {
51
+ return fs.statSync(getConfigPath()).isFile();
52
+ } catch (_) {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** Normalise one project entry, filling in safe defaults. */
58
+ function normalizeProject(raw, root) {
59
+ if (!raw) return null;
60
+ const projectPath = raw.path
61
+ ? path.resolve(raw.path)
62
+ : raw.name
63
+ ? path.join(root || '', raw.name)
64
+ : null;
65
+ if (!projectPath) return null;
66
+
67
+ const status = STATUSES.includes(raw.status) ? raw.status : 'Pending';
68
+
69
+ return {
70
+ name: raw.name || path.basename(projectPath),
71
+ path: projectPath,
72
+ status,
73
+ info: typeof raw.info === 'string' ? raw.info : '',
74
+ // Optional per-project overrides.
75
+ ...(raw.port ? { port: Number(raw.port) } : {}),
76
+ ...(raw.devCommand ? { devCommand: raw.devCommand } : {}),
77
+ ...(raw.editorCommand ? { editorCommand: raw.editorCommand } : {}),
78
+ ...(raw.agentCommand ? { agentCommand: raw.agentCommand } : {}),
79
+ };
80
+ }
81
+
82
+
83
+ /**
84
+ * Read the config file.
85
+ * @returns {object|null} config, or null when missing/corrupt (caller should run setup).
86
+ */
87
+ function loadConfig({ onWarn = () => {} } = {}) {
88
+ const file = getConfigPath();
89
+ let raw;
90
+ try {
91
+ raw = fs.readFileSync(file, 'utf8');
92
+ } catch (_) {
93
+ return null;
94
+ }
95
+
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(raw);
99
+ } catch (err) {
100
+ onWarn(`Could not parse ${file} (${err.message}). Starting setup again.`);
101
+ return null;
102
+ }
103
+
104
+ if (!parsed || !Array.isArray(parsed.projects)) {
105
+ onWarn(`${file} does not look like a termdeck config. Starting setup again.`);
106
+ return null;
107
+ }
108
+
109
+ const root = parsed.root ? path.resolve(parsed.root) : '';
110
+ return {
111
+ version: CONFIG_VERSION,
112
+ root,
113
+ createdAt: parsed.createdAt || null,
114
+ updatedAt: parsed.updatedAt || null,
115
+ devCommand: parsed.devCommand || 'npm run dev',
116
+ editorCommand: parsed.editorCommand || 'code .',
117
+ agentCommand: parsed.agentCommand || 'opencode',
118
+ openBrowser: parsed.openBrowser !== false,
119
+ projects: parsed.projects.map((p) => normalizeProject(p, root)).filter(Boolean),
120
+ };
121
+ }
122
+
123
+ const PER_PROJECT_OVERRIDES = ['port', 'devCommand', 'editorCommand', 'agentCommand'];
124
+
125
+ /** Keep any hand-written per-project overrides when a project is rewritten. */
126
+ function pickOverrides(project) {
127
+ const overrides = {};
128
+ if (!project) return overrides;
129
+ PER_PROJECT_OVERRIDES.forEach((key) => {
130
+ if (project[key] !== undefined && project[key] !== null && project[key] !== '') overrides[key] = project[key];
131
+ });
132
+ return overrides;
133
+ }
134
+
135
+ function saveConfig(config) {
136
+ const file = getConfigPath();
137
+ const now = new Date().toISOString();
138
+ const payload = {
139
+ version: CONFIG_VERSION,
140
+ root: config.root,
141
+ devCommand: config.devCommand || 'npm run dev',
142
+ editorCommand: config.editorCommand || 'code .',
143
+ agentCommand: config.agentCommand || 'opencode',
144
+ openBrowser: config.openBrowser !== false,
145
+ createdAt: config.createdAt || now,
146
+ updatedAt: now,
147
+ projects: config.projects.map((p) => ({
148
+ name: p.name,
149
+ path: p.path,
150
+ status: p.status,
151
+ info: p.info,
152
+ ...pickOverrides(p),
153
+ })),
154
+ };
155
+ // A custom TERMDECK_CONFIG may point into a directory that does not exist yet.
156
+ fs.mkdirSync(path.dirname(file), { recursive: true });
157
+ fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
158
+ return payload;
159
+ }
160
+
161
+ /** Candidate "projects live here" folders for the setup prompt. */
162
+ function rootCandidates() {
163
+ const home = os.homedir();
164
+ const candidates = [
165
+ path.join(home, 'Projects'),
166
+ path.join(home, 'projects'),
167
+ path.join(home, 'dev'),
168
+ path.join(home, 'Development'),
169
+ path.join(home, 'code'),
170
+ path.join(home, 'source', 'repos'),
171
+ path.join(home, 'workspace'),
172
+ home,
173
+ ];
174
+
175
+ if (process.platform === 'win32') {
176
+ // Offer every existing drive letter.
177
+ for (let i = 65; i <= 90; i += 1) {
178
+ const drive = `${String.fromCharCode(i)}:\\`;
179
+ try {
180
+ if (fs.statSync(drive).isDirectory()) candidates.push(drive);
181
+ } catch (_) {
182
+ /* drive does not exist */
183
+ }
184
+ }
185
+ } else {
186
+ candidates.push('/');
187
+ }
188
+
189
+ const seen = new Set();
190
+ return candidates.filter((dir) => {
191
+ const key = process.platform === 'win32' ? dir.toLowerCase() : dir;
192
+ if (seen.has(key)) return false;
193
+ seen.add(key);
194
+ try {
195
+ return fs.statSync(dir).isDirectory();
196
+ } catch (_) {
197
+ return false;
198
+ }
199
+ });
200
+ }
201
+
202
+ /** Immediate sub folders of `root` that look like projects. */
203
+ function scanDirectories(root) {
204
+ let entries;
205
+ try {
206
+ entries = fs.readdirSync(root, { withFileTypes: true });
207
+ } catch (err) {
208
+ throw new Error(`Cannot read ${root}: ${err.message}`);
209
+ }
210
+
211
+ return entries
212
+ .filter((entry) => {
213
+ if (entry.name.startsWith('.')) return false;
214
+ if (IGNORED_DIRS.has(entry.name)) return false;
215
+ if (entry.isDirectory()) return true;
216
+ if (entry.isSymbolicLink()) {
217
+ try {
218
+ return fs.statSync(path.join(root, entry.name)).isDirectory();
219
+ } catch (_) {
220
+ return false;
221
+ }
222
+ }
223
+ return false;
224
+ })
225
+ .map((entry) => ({ name: entry.name, path: path.join(root, entry.name) }))
226
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
227
+ }
228
+
229
+ function isDirectory(target) {
230
+ try {
231
+ return fs.statSync(target).isDirectory();
232
+ } catch (_) {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ /** Tilde/relative paths typed by hand in the wizard. */
238
+ function resolveUserPath(input) {
239
+ let value = String(input || '').trim().replace(/^"(.*)"$/, '$1');
240
+ if (!value) return null;
241
+ if (value === '~') value = os.homedir();
242
+ else if (value.startsWith('~/') || value.startsWith('~\\')) value = path.join(os.homedir(), value.slice(2));
243
+ return path.resolve(value);
244
+ }
245
+
246
+ /* ------------------------------------------------------------------ *
247
+ * First run wizard
248
+ * ------------------------------------------------------------------ */
249
+
250
+ async function askRoot(existing) {
251
+ const candidates = rootCandidates();
252
+ const defaultRoot = existing && existing.root;
253
+
254
+ const { root } = await inquirer.prompt([
255
+ {
256
+ type: 'list',
257
+ name: 'root',
258
+ message: 'Where do your projects live?',
259
+ pageSize: 14,
260
+ default: defaultRoot && candidates.includes(defaultRoot) ? defaultRoot : undefined,
261
+ choices: [
262
+ ...candidates.map((dir) => ({ name: dir, value: dir })),
263
+ new inquirer.Separator(),
264
+ { name: 'Enter another path\u2026', value: '__custom__' },
265
+ ],
266
+ },
267
+ ]);
268
+
269
+ if (root !== '__custom__') return root;
270
+
271
+ const { custom } = await inquirer.prompt([
272
+ {
273
+ type: 'input',
274
+ name: 'custom',
275
+ message: 'Path to the directory that holds your projects:',
276
+ validate: (input) => {
277
+ const resolved = resolveUserPath(input);
278
+ if (!resolved) return 'Please enter a path.';
279
+ if (!isDirectory(resolved)) return `Not a directory: ${resolved}`;
280
+ return true;
281
+ },
282
+ },
283
+ ]);
284
+
285
+ return resolveUserPath(custom);
286
+ }
287
+
288
+ async function askProjects(root, existing) {
289
+ const previous = new Map((existing && existing.projects ? existing.projects : []).map((p) => [p.path, p]));
290
+ let folders = scanDirectories(root);
291
+
292
+ while (folders.length === 0) {
293
+ const { action } = await inquirer.prompt([
294
+ {
295
+ type: 'list',
296
+ name: 'action',
297
+ message: `No sub folders found in ${root}.`,
298
+ choices: [
299
+ { name: 'Pick a different root directory', value: 'again' },
300
+ { name: 'Abort setup', value: 'abort' },
301
+ ],
302
+ },
303
+ ]);
304
+
305
+ if (action === 'abort') throw new Error('Setup cancelled: no projects found.');
306
+ root = await askRoot(existing);
307
+ folders = scanDirectories(root);
308
+ }
309
+
310
+ const { selected } = await inquirer.prompt([
311
+ {
312
+ type: 'checkbox',
313
+ name: 'selected',
314
+ message: `Select the projects to show in termdeck (${folders.length} folders found):`,
315
+ pageSize: 16,
316
+ choices: folders.map((folder) => ({
317
+ name: folder.name,
318
+ value: folder.path,
319
+ checked: previous.has(folder.path),
320
+ })),
321
+ validate: (answer) => (answer.length > 0 ? true : 'Select at least one project (space to toggle).'),
322
+ },
323
+ ]);
324
+
325
+ return { root, folders, selected };
326
+ }
327
+
328
+ async function askDetails(selected, existing) {
329
+ const previous = new Map((existing && existing.projects ? existing.projects : []).map((p) => [p.path, p]));
330
+ const projects = [];
331
+
332
+ for (let i = 0; i < selected.length; i += 1) {
333
+ const projectPath = selected[i];
334
+ const name = path.basename(projectPath);
335
+ const prev = previous.get(projectPath) || {};
336
+ const step = `[${i + 1}/${selected.length}]`;
337
+
338
+ const { status } = await inquirer.prompt([
339
+ {
340
+ type: 'list',
341
+ name: 'status',
342
+ message: `${step} ${name} \u2014 what is its status?`,
343
+ choices: STATUSES,
344
+ default: prev.status && STATUSES.includes(prev.status) ? prev.status : 'Working',
345
+ },
346
+ ]);
347
+
348
+ const { info } = await inquirer.prompt([
349
+ {
350
+ type: 'input',
351
+ name: 'info',
352
+ message: `${step} ${name} \u2014 short description:`,
353
+ default: prev.info || '',
354
+ validate: (input) => (String(input).trim() ? true : 'A one line description helps nobody but yourself. Add one.'),
355
+ },
356
+ ]);
357
+
358
+ projects.push({
359
+ name,
360
+ path: projectPath,
361
+ status,
362
+ info: String(info).trim(),
363
+ // Do not lose per-project overrides from a previous config.
364
+ ...pickOverrides(prev),
365
+ });
366
+ }
367
+
368
+ return projects.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
369
+ }
370
+
371
+ /**
372
+ * Interactive first-run setup: root -> folder multi-select -> status + blurb.
373
+ * Persists the result to the config file and returns it.
374
+ */
375
+ async function runSetupWizard({ existing = null, stdout = process.stdout } = {}) {
376
+ stdout.write('\n termdeck \u2014 first run setup\n');
377
+ stdout.write(' Answer a few questions and we will remember them in ~/.termdeck-config.json\n\n');
378
+
379
+ const root = await askRoot(existing);
380
+ // `askProjects` may re-ask for the root when the first one had no folders.
381
+ const { root: finalRoot, selected } = await askProjects(root, existing);
382
+ const projects = await askDetails(selected, existing);
383
+
384
+ const config = {
385
+ version: CONFIG_VERSION,
386
+ root: finalRoot,
387
+ devCommand: (existing && existing.devCommand) || 'npm run dev',
388
+ editorCommand: (existing && existing.editorCommand) || 'code .',
389
+ agentCommand: (existing && existing.agentCommand) || 'opencode',
390
+ openBrowser: existing ? existing.openBrowser !== false : true,
391
+ createdAt: existing && existing.createdAt,
392
+ projects,
393
+ };
394
+
395
+ const { save } = await inquirer.prompt([
396
+ {
397
+ type: 'confirm',
398
+ name: 'save',
399
+ message: `Save ${projects.length} project${projects.length === 1 ? '' : 's'} to ${getConfigPath()}?`,
400
+ default: true,
401
+ },
402
+ ]);
403
+
404
+ if (!save) throw new Error('Setup cancelled: nothing was saved.');
405
+
406
+ saveConfig(config);
407
+ stdout.write(`\n Saved. Run the dashboard any time with: termdeck\n\n`);
408
+ return loadConfig() || config;
409
+ }
410
+
411
+ module.exports = {
412
+ CONFIG_VERSION,
413
+ STATUSES,
414
+ STATUS_COLORS,
415
+ IGNORED_DIRS,
416
+ getConfigPath,
417
+ configExists,
418
+ loadConfig,
419
+ saveConfig,
420
+ pickOverrides,
421
+ normalizeProject,
422
+ rootCandidates,
423
+ scanDirectories,
424
+ isDirectory,
425
+ resolveUserPath,
426
+ runSetupWizard,
427
+ };