termdeck-cli 1.0.2 → 2.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/README.md +195 -174
- package/package.json +9 -7
- package/sample-config.json +176 -0
- package/src/agentManager.js +217 -0
- package/src/config.js +636 -427
- package/src/dashboard.js +502 -198
- package/src/devServer.js +51 -2
- package/src/index.js +103 -9
- package/src/processMonitor.js +168 -0
- package/src/projectManager.js +159 -0
- package/src/updater.js +172 -0
- package/src/util.js +23 -0
- package/bin/termdeck.js +0 -22
package/src/config.js
CHANGED
|
@@ -1,427 +1,636 @@
|
|
|
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 =
|
|
16
|
-
|
|
17
|
-
const STATUSES = ['Experimental', 'Live', 'Working', 'Pending'];
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
'
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
];
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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 = 2;
|
|
16
|
+
|
|
17
|
+
const STATUSES = ['Experimental', 'Live', 'Working', 'Pending'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Modern short status vocabulary used by the termdeck dashboard UI. The legacy
|
|
21
|
+
* `STATUSES` labels remain accepted on read for backward compatibility; the
|
|
22
|
+
* wizard vocabulary fully migrates to these when the dashboard is rewritten.
|
|
23
|
+
*/
|
|
24
|
+
const MODERN_STATUSES = ['live', 'exp', 'pend', 'scrap'];
|
|
25
|
+
|
|
26
|
+
/** Every accepted status string, legacy labels and modern short names. */
|
|
27
|
+
const ALL_STATUSES = [...STATUSES, ...MODERN_STATUSES];
|
|
28
|
+
|
|
29
|
+
/** Color used for each status tag in the TUI. */
|
|
30
|
+
const STATUS_COLORS = {
|
|
31
|
+
Experimental: 'magenta',
|
|
32
|
+
Live: 'green',
|
|
33
|
+
Working: 'yellow',
|
|
34
|
+
Pending: 'cyan',
|
|
35
|
+
// Modern short labels (dashboard uses these).
|
|
36
|
+
live: 'green',
|
|
37
|
+
exp: 'yellow',
|
|
38
|
+
pend: 'blue',
|
|
39
|
+
scrap: 'gray',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Directory names that are never project folders. */
|
|
43
|
+
const IGNORED_DIRS = new Set([
|
|
44
|
+
'node_modules',
|
|
45
|
+
'$RECYCLE.BIN',
|
|
46
|
+
'System Volume Information',
|
|
47
|
+
'AppData',
|
|
48
|
+
'Program Files',
|
|
49
|
+
'Program Files (x86)',
|
|
50
|
+
'ProgramData',
|
|
51
|
+
'Windows',
|
|
52
|
+
'Windows.old',
|
|
53
|
+
'PerfLogs',
|
|
54
|
+
'MSOCache',
|
|
55
|
+
'Recovery',
|
|
56
|
+
'$WinREAgent',
|
|
57
|
+
'OneDrive',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function getConfigPath() {
|
|
61
|
+
return process.env.TERMDECK_CONFIG || path.join(os.homedir(), '.termdeck-config.json');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function configExists() {
|
|
65
|
+
try {
|
|
66
|
+
return fs.statSync(getConfigPath()).isFile();
|
|
67
|
+
} catch (_) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Expand a leading `~` (the home directory) in a hand-written path. */
|
|
73
|
+
function expandHome(value) {
|
|
74
|
+
let v = String(value || '');
|
|
75
|
+
if (v === '~') return os.homedir();
|
|
76
|
+
if (v.startsWith('~/') || v.startsWith('~\\')) return path.join(os.homedir(), v.slice(2));
|
|
77
|
+
return v;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Normalise one project entry, filling in safe defaults. */
|
|
81
|
+
function normalizeProject(raw, root) {
|
|
82
|
+
if (!raw) return null;
|
|
83
|
+
// Relative paths (e.g. `hyperion-core` in the sample dataset) are resolved
|
|
84
|
+
// against the config root so they become `~/dev/projects/hyperion-core`.
|
|
85
|
+
const projectPath = raw.path
|
|
86
|
+
? path.isAbsolute(raw.path)
|
|
87
|
+
? path.resolve(raw.path)
|
|
88
|
+
: path.resolve(root || '', raw.path)
|
|
89
|
+
: raw.name
|
|
90
|
+
? path.join(root || '', raw.name)
|
|
91
|
+
: null;
|
|
92
|
+
if (!projectPath) return null;
|
|
93
|
+
|
|
94
|
+
const status = ALL_STATUSES.includes(raw.status) ? raw.status : 'Pending';
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
name: raw.name || path.basename(projectPath),
|
|
98
|
+
path: projectPath,
|
|
99
|
+
status,
|
|
100
|
+
info: typeof raw.info === 'string' ? raw.info : '',
|
|
101
|
+
// Optional per-project overrides.
|
|
102
|
+
...(raw.port ? { port: Number(raw.port) } : {}),
|
|
103
|
+
...(raw.devCommand ? { devCommand: raw.devCommand } : {}),
|
|
104
|
+
...(raw.editorCommand ? { editorCommand: raw.editorCommand } : {}),
|
|
105
|
+
...(raw.agentCommand ? { agentCommand: raw.agentCommand } : {}),
|
|
106
|
+
// termdeck v2 additions (all optional).
|
|
107
|
+
...(raw.packageManager ? { packageManager: raw.packageManager } : {}),
|
|
108
|
+
...(typeof raw.stack === 'string' ? { stack: raw.stack } : {}),
|
|
109
|
+
...(raw.branch ? { branch: raw.branch } : {}),
|
|
110
|
+
...(raw.agents && typeof raw.agents === 'object' ? { agents: { ...raw.agents } } : {}),
|
|
111
|
+
...(raw.daemon ? { daemon: raw.daemon } : {}),
|
|
112
|
+
...(raw.lastCommit && typeof raw.lastCommit === 'object' ? { lastCommit: { ...raw.lastCommit } } : {}),
|
|
113
|
+
...(typeof raw.lastActivity === 'string' ? { lastActivity: raw.lastActivity } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read the config file.
|
|
120
|
+
* @returns {object|null} config, or null when missing/corrupt (caller should run setup).
|
|
121
|
+
*/
|
|
122
|
+
function loadConfig({ onWarn = () => {} } = {}) {
|
|
123
|
+
return loadConfigFromPath(getConfigPath(), { onWarn });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read + normalise a config from an explicit file path (used by the CLI's
|
|
128
|
+
* `--demo` mode, which points at the shipped sample dataset).
|
|
129
|
+
*/
|
|
130
|
+
function loadConfigFromPath(file, { onWarn = () => {} } = {}) {
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
134
|
+
} catch (_) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(raw);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
onWarn(`Could not parse ${file} (${err.message}). Starting setup again.`);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!parsed || !Array.isArray(parsed.projects)) {
|
|
147
|
+
onWarn(`${file} does not look like a termdeck config. Starting setup again.`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const root = parsed.root ? path.resolve(expandHome(parsed.root)) : '';
|
|
152
|
+
return {
|
|
153
|
+
version: CONFIG_VERSION,
|
|
154
|
+
root,
|
|
155
|
+
createdAt: parsed.createdAt || null,
|
|
156
|
+
updatedAt: parsed.updatedAt || null,
|
|
157
|
+
devCommand: parsed.devCommand || 'npm run dev',
|
|
158
|
+
editorCommand: parsed.editorCommand || 'code .',
|
|
159
|
+
agentCommand: parsed.agentCommand || 'opencode',
|
|
160
|
+
openBrowser: parsed.openBrowser !== false,
|
|
161
|
+
// termdeck v2 flags (additive; safe defaults when a config predates them).
|
|
162
|
+
autoRestart: parsed.autoRestart !== false,
|
|
163
|
+
demoMode: parsed.demoMode === true,
|
|
164
|
+
projects: parsed.projects.map((p) => normalizeProject(p, root)).filter(Boolean),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Path rendered as `~/dev/projects/<name>` when it lives under home. */
|
|
169
|
+
function homePath(p) {
|
|
170
|
+
if (!p) return p;
|
|
171
|
+
const home = os.homedir();
|
|
172
|
+
if (!home) return p;
|
|
173
|
+
const trimmed = String(home).replace(/[\\/]+$/, '');
|
|
174
|
+
if (p === trimmed) return '~';
|
|
175
|
+
if (p.startsWith(`${trimmed}${path.sep}`)) return path.join('~', p.slice(trimmed.length + 1));
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Compact path for the UI: relative to `root` when possible, `~`-shortened. */
|
|
180
|
+
function displayPath(p, root) {
|
|
181
|
+
if (!p) return p;
|
|
182
|
+
if (root && typeof root === 'string' && p.startsWith(root)) {
|
|
183
|
+
const rest = p.slice(root.length).replace(/^[\\/]+/, '');
|
|
184
|
+
return path.join(homePath(root), rest);
|
|
185
|
+
}
|
|
186
|
+
return homePath(p);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const PER_PROJECT_OVERRIDES = ['port', 'devCommand', 'editorCommand', 'agentCommand', 'packageManager', 'stack', 'branch', 'agents', 'lastCommit', 'lastActivity'];
|
|
190
|
+
|
|
191
|
+
/** Keep any hand-written per-project overrides when a project is rewritten. */
|
|
192
|
+
function pickOverrides(project) {
|
|
193
|
+
const overrides = {};
|
|
194
|
+
if (!project) return overrides;
|
|
195
|
+
PER_PROJECT_OVERRIDES.forEach((key) => {
|
|
196
|
+
if (project[key] !== undefined && project[key] !== null && project[key] !== '') overrides[key] = project[key];
|
|
197
|
+
});
|
|
198
|
+
return overrides;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function saveConfig(config) {
|
|
202
|
+
const file = getConfigPath();
|
|
203
|
+
const now = new Date().toISOString();
|
|
204
|
+
const payload = {
|
|
205
|
+
version: CONFIG_VERSION,
|
|
206
|
+
root: expandHome(config.root || ''),
|
|
207
|
+
devCommand: config.devCommand || 'npm run dev',
|
|
208
|
+
editorCommand: config.editorCommand || 'code .',
|
|
209
|
+
agentCommand: config.agentCommand || 'opencode',
|
|
210
|
+
openBrowser: config.openBrowser !== false,
|
|
211
|
+
...(config.demoMode ? { demoMode: config.demoMode } : {}),
|
|
212
|
+
...(config.autoRestart !== undefined && !config.autoRestart ? { autoRestart: false } : {}),
|
|
213
|
+
createdAt: config.createdAt || now,
|
|
214
|
+
updatedAt: now,
|
|
215
|
+
projects: config.projects.map((p) => ({
|
|
216
|
+
name: p.name,
|
|
217
|
+
path: p.path,
|
|
218
|
+
status: p.status,
|
|
219
|
+
info: p.info,
|
|
220
|
+
...pickOverrides(p),
|
|
221
|
+
})),
|
|
222
|
+
};
|
|
223
|
+
// A custom TERMDECK_CONFIG may point into a directory that does not exist yet.
|
|
224
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
225
|
+
fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
226
|
+
return payload;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Candidate "projects live here" folders for the setup prompt. */
|
|
230
|
+
function rootCandidates() {
|
|
231
|
+
const home = os.homedir();
|
|
232
|
+
const candidates = [
|
|
233
|
+
path.join(home, 'Projects'),
|
|
234
|
+
path.join(home, 'projects'),
|
|
235
|
+
path.join(home, 'dev'),
|
|
236
|
+
path.join(home, 'Development'),
|
|
237
|
+
path.join(home, 'code'),
|
|
238
|
+
path.join(home, 'source', 'repos'),
|
|
239
|
+
path.join(home, 'workspace'),
|
|
240
|
+
home,
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
if (process.platform === 'win32') {
|
|
244
|
+
// Offer every existing drive letter.
|
|
245
|
+
for (let i = 65; i <= 90; i += 1) {
|
|
246
|
+
const drive = `${String.fromCharCode(i)}:\\`;
|
|
247
|
+
try {
|
|
248
|
+
if (fs.statSync(drive).isDirectory()) candidates.push(drive);
|
|
249
|
+
} catch (_) {
|
|
250
|
+
/* drive does not exist */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
candidates.push('/');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const seen = new Set();
|
|
258
|
+
return candidates.filter((dir) => {
|
|
259
|
+
const key = process.platform === 'win32' ? dir.toLowerCase() : dir;
|
|
260
|
+
if (seen.has(key)) return false;
|
|
261
|
+
seen.add(key);
|
|
262
|
+
try {
|
|
263
|
+
return fs.statSync(dir).isDirectory();
|
|
264
|
+
} catch (_) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Immediate sub folders of `root` that look like projects. */
|
|
271
|
+
function scanDirectories(root) {
|
|
272
|
+
let entries;
|
|
273
|
+
try {
|
|
274
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
275
|
+
} catch (err) {
|
|
276
|
+
throw new Error(`Cannot read ${root}: ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return entries
|
|
280
|
+
.filter((entry) => {
|
|
281
|
+
if (entry.name.startsWith('.')) return false;
|
|
282
|
+
if (IGNORED_DIRS.has(entry.name)) return false;
|
|
283
|
+
if (entry.isDirectory()) return true;
|
|
284
|
+
if (entry.isSymbolicLink()) {
|
|
285
|
+
try {
|
|
286
|
+
return fs.statSync(path.join(root, entry.name)).isDirectory();
|
|
287
|
+
} catch (_) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
})
|
|
293
|
+
.map((entry) => ({ name: entry.name, path: path.join(root, entry.name) }))
|
|
294
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** True when `dir` holds a `.git` folder or a `package.json` manifest. */
|
|
298
|
+
function isProjectFolder(dir) {
|
|
299
|
+
try {
|
|
300
|
+
if (fs.statSync(path.join(dir, '.git')).isDirectory()) return true;
|
|
301
|
+
} catch (_) { /* no .git */ }
|
|
302
|
+
try {
|
|
303
|
+
if (fs.statSync(path.join(dir, 'package.json')).isFile()) return true;
|
|
304
|
+
} catch (_) { /* no package.json */ }
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Sub folders of `root` that contain a real project marker (.git / package.json). */
|
|
309
|
+
function scanProjectCandidates(root) {
|
|
310
|
+
return scanDirectories(root).filter((entry) => isProjectFolder(entry.path));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Guess the package manager from the lockfile convention in `dir`:
|
|
315
|
+
* pnpm-lock.yaml -> pnpm, yarn.lock -> yarn, otherwise npm.
|
|
316
|
+
*/
|
|
317
|
+
function detectPackageManager(dir) {
|
|
318
|
+
try {
|
|
319
|
+
if (fs.statSync(path.join(dir, 'pnpm-lock.yaml')).isFile()) return 'pnpm';
|
|
320
|
+
} catch (_) { /* no pnpm lockfile */ }
|
|
321
|
+
try {
|
|
322
|
+
if (fs.statSync(path.join(dir, 'yarn.lock')).isFile()) return 'yarn';
|
|
323
|
+
} catch (_) { /* no yarn lockfile */ }
|
|
324
|
+
return 'npm';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Guess the dev-server port from `<dir>/package.json` scripts:
|
|
329
|
+
* `-p 3001`, `--port 3001`, `PORT=3001`, or a `:5173`-style token.
|
|
330
|
+
* Explicit flag tokens anywhere take precedence over `:<digits>` URL-style
|
|
331
|
+
* tokens; a URL-style token is only used when no explicit flag is detectable.
|
|
332
|
+
* Falls back to 3000 when nothing valid is found.
|
|
333
|
+
*/
|
|
334
|
+
function detectPort(dir) {
|
|
335
|
+
const flags = [];
|
|
336
|
+
const urls = [];
|
|
337
|
+
try {
|
|
338
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
339
|
+
const scripts = Object.values(pkg.scripts || {}).map(String);
|
|
340
|
+
for (const script of scripts) {
|
|
341
|
+
const flag = script.match(/(?:-p|--port)\s+(\d+)/) || script.match(/PORT=(\d+)/);
|
|
342
|
+
if (flag) {
|
|
343
|
+
flags.push(Number(flag[1]));
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const url = script.match(/:(\d{4,5})\b/);
|
|
347
|
+
if (url) urls.push(Number(url[1]));
|
|
348
|
+
}
|
|
349
|
+
} catch (_) {
|
|
350
|
+
/* missing or unreadable package.json */
|
|
351
|
+
}
|
|
352
|
+
const valid = (n) => Number.isInteger(n) && n > 0 && n < 65536;
|
|
353
|
+
const explicit = flags.find(valid);
|
|
354
|
+
if (explicit !== undefined) return explicit;
|
|
355
|
+
const urlPort = urls.find(valid);
|
|
356
|
+
return urlPort || 3000;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Merge the projects the wizard just produced into the existing config list.
|
|
361
|
+
* - Incoming entries replace their existing twin (by path) — port/status/other
|
|
362
|
+
* fields the user just set win — but hand-written per-project overrides such
|
|
363
|
+
* as custom agent commands are kept from the old entry.
|
|
364
|
+
* - Existing projects the wizard did not touch are preserved untouched.
|
|
365
|
+
* - Brand new entries (no existing twin) are appended.
|
|
366
|
+
*/
|
|
367
|
+
function mergeWizardProjects(existing, wizard) {
|
|
368
|
+
const existingList = Array.isArray(existing) ? existing : [];
|
|
369
|
+
const wizardList = Array.isArray(wizard) ? wizard : [];
|
|
370
|
+
const wizardPaths = new Set(wizardList.map((entry) => entry.path));
|
|
371
|
+
const kept = existingList.filter((entry) => !wizardPaths.has(entry.path));
|
|
372
|
+
return [
|
|
373
|
+
...kept,
|
|
374
|
+
...wizardList.map((entry) => {
|
|
375
|
+
const previous = existingList.find((old) => old.path === entry.path);
|
|
376
|
+
if (!previous) return entry;
|
|
377
|
+
// Preserve custom overrides (agent commands, editor, stack, etc.) the
|
|
378
|
+
// wizard did not re-ask about, but never overwrite what the user just
|
|
379
|
+
// entered (status / port / packageManager take the new value).
|
|
380
|
+
const preserved = pickOverrides(previous);
|
|
381
|
+
Object.keys(entry).forEach((key) => {
|
|
382
|
+
if (key in preserved && key in entry) delete preserved[key];
|
|
383
|
+
});
|
|
384
|
+
return { ...entry, ...preserved };
|
|
385
|
+
}),
|
|
386
|
+
];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isDirectory(target) {
|
|
390
|
+
try {
|
|
391
|
+
return fs.statSync(target).isDirectory();
|
|
392
|
+
} catch (_) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Silent auto-discovery: shallow-scan `config.root` for project folders (a
|
|
399
|
+
* `.git` directory or a `package.json` manifest) and return the ones that are
|
|
400
|
+
* not in the config yet, with quiet defaults so they never clutter the Live
|
|
401
|
+
* view (status `pend`, auto-detected port and package manager).
|
|
402
|
+
*
|
|
403
|
+
* Runs on every dashboard launch. Deliberately shallow (no git parsing here;
|
|
404
|
+
* that happens lazily when the user selects the project in the UI) and
|
|
405
|
+
* deliberately non-destructive: existing entries are returned untouched, so a
|
|
406
|
+
* deleted folder never removes anything from the config.
|
|
407
|
+
*
|
|
408
|
+
* Never throws: an unreadable or missing root simply discovers nothing.
|
|
409
|
+
*
|
|
410
|
+
* @param {{root?: string, projects?: object[]}} config
|
|
411
|
+
* @returns {{added: object[], projects: object[]}} the new entries and the
|
|
412
|
+
* merged list (existing projects first, new ones appended in name order).
|
|
413
|
+
*/
|
|
414
|
+
function autoDiscoverProjects(config) {
|
|
415
|
+
const existing = Array.isArray(config && config.projects) ? config.projects : [];
|
|
416
|
+
const known = new Set(existing.map((p) => p.path));
|
|
417
|
+
|
|
418
|
+
let candidates;
|
|
419
|
+
try {
|
|
420
|
+
candidates = scanProjectCandidates(config.root);
|
|
421
|
+
} catch (_) {
|
|
422
|
+
return { added: [], projects: existing };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const added = candidates
|
|
426
|
+
.filter((entry) => !known.has(entry.path))
|
|
427
|
+
.map((entry) => ({
|
|
428
|
+
name: entry.name,
|
|
429
|
+
path: entry.path,
|
|
430
|
+
status: 'pend',
|
|
431
|
+
info: '',
|
|
432
|
+
port: detectPort(entry.path),
|
|
433
|
+
packageManager: detectPackageManager(entry.path),
|
|
434
|
+
}));
|
|
435
|
+
|
|
436
|
+
return { added, projects: added.length ? [...existing, ...added] : existing };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Tilde/relative paths typed by hand in the wizard. */
|
|
440
|
+
function resolveUserPath(input) {
|
|
441
|
+
let value = String(input || '').trim().replace(/^"(.*)"$/, '$1');
|
|
442
|
+
if (!value) return null;
|
|
443
|
+
if (value === '~') value = os.homedir();
|
|
444
|
+
else if (value.startsWith('~/') || value.startsWith('~\\')) value = path.join(os.homedir(), value.slice(2));
|
|
445
|
+
return path.resolve(value);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* ------------------------------------------------------------------ *
|
|
449
|
+
* First run wizard
|
|
450
|
+
* ------------------------------------------------------------------ */
|
|
451
|
+
|
|
452
|
+
async function askRoot(existing) {
|
|
453
|
+
const candidates = rootCandidates();
|
|
454
|
+
const defaultRoot = existing && existing.root;
|
|
455
|
+
|
|
456
|
+
const { root } = await inquirer.prompt([
|
|
457
|
+
{
|
|
458
|
+
type: 'list',
|
|
459
|
+
name: 'root',
|
|
460
|
+
message: 'Where do your projects live?',
|
|
461
|
+
pageSize: 14,
|
|
462
|
+
default: defaultRoot && candidates.includes(defaultRoot) ? defaultRoot : undefined,
|
|
463
|
+
choices: [
|
|
464
|
+
...candidates.map((dir) => ({ name: dir, value: dir })),
|
|
465
|
+
new inquirer.Separator(),
|
|
466
|
+
{ name: 'Enter another path\u2026', value: '__custom__' },
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
]);
|
|
470
|
+
|
|
471
|
+
if (root !== '__custom__') return root;
|
|
472
|
+
|
|
473
|
+
const { custom } = await inquirer.prompt([
|
|
474
|
+
{
|
|
475
|
+
type: 'input',
|
|
476
|
+
name: 'custom',
|
|
477
|
+
message: 'Path to the directory that holds your projects:',
|
|
478
|
+
validate: (input) => {
|
|
479
|
+
const resolved = resolveUserPath(input);
|
|
480
|
+
if (!resolved) return 'Please enter a path.';
|
|
481
|
+
if (!isDirectory(resolved)) return `Not a directory: ${resolved}`;
|
|
482
|
+
return true;
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
]);
|
|
486
|
+
|
|
487
|
+
return resolveUserPath(custom);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function askProjects(root, existing) {
|
|
491
|
+
const previous = new Map((existing && existing.projects ? existing.projects : []).map((p) => [p.path, p]));
|
|
492
|
+
let folders = scanProjectCandidates(root);
|
|
493
|
+
|
|
494
|
+
while (folders.length === 0) {
|
|
495
|
+
const { action } = await inquirer.prompt([
|
|
496
|
+
{
|
|
497
|
+
type: 'list',
|
|
498
|
+
name: 'action',
|
|
499
|
+
message: `No project folders (with .git or package.json) found in ${root}.`,
|
|
500
|
+
choices: [
|
|
501
|
+
{ name: 'Pick a different root directory', value: 'again' },
|
|
502
|
+
{ name: 'Abort setup', value: 'abort' },
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
if (action === 'abort') throw new Error('Setup cancelled: no projects found.');
|
|
508
|
+
root = await askRoot(existing);
|
|
509
|
+
folders = scanProjectCandidates(root);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const { selected } = await inquirer.prompt([
|
|
513
|
+
{
|
|
514
|
+
type: 'checkbox',
|
|
515
|
+
name: 'selected',
|
|
516
|
+
message: `Select the projects to show in termdeck (${folders.length} projects found):`,
|
|
517
|
+
pageSize: 16,
|
|
518
|
+
choices: folders.map((folder) => ({
|
|
519
|
+
name: folder.name,
|
|
520
|
+
value: folder.path,
|
|
521
|
+
checked: previous.has(folder.path),
|
|
522
|
+
})),
|
|
523
|
+
validate: (answer) => (answer.length > 0 ? true : 'Select at least one project (space to toggle).'),
|
|
524
|
+
},
|
|
525
|
+
]);
|
|
526
|
+
|
|
527
|
+
return { root, folders, selected };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function askDetails(selected, existing) {
|
|
531
|
+
const previous = new Map((existing && existing.projects ? existing.projects : []).map((p) => [p.path, p]));
|
|
532
|
+
const projects = [];
|
|
533
|
+
|
|
534
|
+
for (let i = 0; i < selected.length; i += 1) {
|
|
535
|
+
const projectPath = selected[i];
|
|
536
|
+
const name = path.basename(projectPath);
|
|
537
|
+
const prev = previous.get(projectPath) || {};
|
|
538
|
+
const step = `[${i + 1}/${selected.length}]`;
|
|
539
|
+
|
|
540
|
+
const { status } = await inquirer.prompt([
|
|
541
|
+
{
|
|
542
|
+
type: 'list',
|
|
543
|
+
name: 'status',
|
|
544
|
+
message: `${step} ${name} \u2014 what is its status?`,
|
|
545
|
+
choices: MODERN_STATUSES,
|
|
546
|
+
default: MODERN_STATUSES.includes(prev.status) ? prev.status : 'exp',
|
|
547
|
+
},
|
|
548
|
+
]);
|
|
549
|
+
|
|
550
|
+
projects.push({
|
|
551
|
+
name,
|
|
552
|
+
path: projectPath,
|
|
553
|
+
// Do not lose per-project overrides from a previous config.
|
|
554
|
+
...pickOverrides(prev),
|
|
555
|
+
status,
|
|
556
|
+
// Zero-friction: both auto-detected, never asked.
|
|
557
|
+
port: detectPort(projectPath),
|
|
558
|
+
packageManager: detectPackageManager(projectPath),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return projects.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Interactive first-run setup: root -> folder multi-select -> status + blurb.
|
|
567
|
+
* Persists the result to the config file and returns it.
|
|
568
|
+
*/
|
|
569
|
+
async function runSetupWizard({ existing = null, stdout = process.stdout } = {}) {
|
|
570
|
+
stdout.write('\n termdeck \u2014 first run setup\n');
|
|
571
|
+
stdout.write(' Answer a few questions and we will remember them in ~/.termdeck-config.json\n\n');
|
|
572
|
+
|
|
573
|
+
const root = await askRoot(existing);
|
|
574
|
+
// `askProjects` may re-ask for the root when the first one had no folders.
|
|
575
|
+
const { root: finalRoot, selected } = await askProjects(root, existing);
|
|
576
|
+
const projects = await askDetails(selected, existing);
|
|
577
|
+
|
|
578
|
+
const mergedProjects = mergeWizardProjects(existing && existing.projects, projects);
|
|
579
|
+
|
|
580
|
+
const config = {
|
|
581
|
+
version: CONFIG_VERSION,
|
|
582
|
+
root: finalRoot,
|
|
583
|
+
devCommand: (existing && existing.devCommand) || 'npm run dev',
|
|
584
|
+
editorCommand: (existing && existing.editorCommand) || 'code .',
|
|
585
|
+
agentCommand: (existing && existing.agentCommand) || 'opencode',
|
|
586
|
+
openBrowser: existing ? existing.openBrowser !== false : true,
|
|
587
|
+
autoRestart: existing ? existing.autoRestart !== false : true,
|
|
588
|
+
createdAt: existing && existing.createdAt,
|
|
589
|
+
projects: mergedProjects,
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const { save } = await inquirer.prompt([
|
|
593
|
+
{
|
|
594
|
+
type: 'confirm',
|
|
595
|
+
name: 'save',
|
|
596
|
+
message: `Save ${mergedProjects.length} project${mergedProjects.length === 1 ? '' : 's'} to ${getConfigPath()}?`,
|
|
597
|
+
default: true,
|
|
598
|
+
},
|
|
599
|
+
]);
|
|
600
|
+
|
|
601
|
+
if (!save) throw new Error('Setup cancelled: nothing was saved.');
|
|
602
|
+
|
|
603
|
+
saveConfig(config);
|
|
604
|
+
stdout.write(`\n Saved. Run the dashboard any time with: termdeck\n\n`);
|
|
605
|
+
return loadConfig() || config;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
module.exports = {
|
|
609
|
+
CONFIG_VERSION,
|
|
610
|
+
STATUSES,
|
|
611
|
+
MODERN_STATUSES,
|
|
612
|
+
ALL_STATUSES,
|
|
613
|
+
STATUS_COLORS,
|
|
614
|
+
IGNORED_DIRS,
|
|
615
|
+
getConfigPath,
|
|
616
|
+
configExists,
|
|
617
|
+
loadConfig,
|
|
618
|
+
loadConfigFromPath,
|
|
619
|
+
saveConfig,
|
|
620
|
+
pickOverrides,
|
|
621
|
+
normalizeProject,
|
|
622
|
+
rootCandidates,
|
|
623
|
+
scanDirectories,
|
|
624
|
+
scanProjectCandidates,
|
|
625
|
+
detectPackageManager,
|
|
626
|
+
detectPort,
|
|
627
|
+
autoDiscoverProjects,
|
|
628
|
+
mergeWizardProjects,
|
|
629
|
+
isProjectFolder,
|
|
630
|
+
isDirectory,
|
|
631
|
+
resolveUserPath,
|
|
632
|
+
expandHome,
|
|
633
|
+
homePath,
|
|
634
|
+
displayPath,
|
|
635
|
+
runSetupWizard,
|
|
636
|
+
};
|