@textui/cli 0.1.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/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/app.d.ts +114 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +372 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +16 -0
- package/dist/commands.d.ts +11 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +428 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/registry.d.ts +81 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +226 -0
- package/package.json +61 -0
- package/src/app.ts +486 -0
- package/src/bin.ts +5 -0
- package/src/cli.ts +18 -0
- package/src/commands.ts +502 -0
- package/src/index.ts +4 -0
- package/src/registry.ts +321 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { detectCapabilities, describeEnvironment } from '@textui/terminal';
|
|
5
|
+
import { BUILTIN_THEMES, renderToString } from '@textui/core';
|
|
6
|
+
import { CATALOG } from '@textui/widgets';
|
|
7
|
+
import type { CliCommand } from './app.js';
|
|
8
|
+
import { CliError, promptConfirm } from './app.js';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_CONFIG, copyComponent, diffInstalled, findProjectRoot, loadRegistry,
|
|
11
|
+
readConfig, readReceipt, resolveDependencies, writeConfig, writeReceipt,
|
|
12
|
+
type ProjectConfig,
|
|
13
|
+
} from './registry.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The developer CLI.
|
|
17
|
+
*
|
|
18
|
+
* Every command that writes to a project says what it is going to write before
|
|
19
|
+
* it writes it, and refuses to overwrite a file the user has edited. That
|
|
20
|
+
* refusal is the whole reason the receipt file exists.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
async function requireProject(): Promise<{ root: string; config: ProjectConfig }> {
|
|
24
|
+
const root = await findProjectRoot();
|
|
25
|
+
if (!root) {
|
|
26
|
+
throw new CliError('no textui.config.json found. Run `textui init` first.');
|
|
27
|
+
}
|
|
28
|
+
return { root, config: await readConfig(root) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const initCommand: CliCommand = {
|
|
32
|
+
name: 'init',
|
|
33
|
+
description: 'Set up TextUI in this project',
|
|
34
|
+
options: [
|
|
35
|
+
{ name: 'components', type: 'string', description: 'Where components are copied', default: DEFAULT_CONFIG.componentsDir },
|
|
36
|
+
{ name: 'templates', type: 'string', description: 'Where screens are copied', default: DEFAULT_CONFIG.templatesDir },
|
|
37
|
+
{ name: 'theme', type: 'string', description: 'Default theme', default: 'dark', choices: BUILTIN_THEMES.map((t) => t.id) },
|
|
38
|
+
{ name: 'shell', type: 'string', description: 'Default shell', default: 'plain', choices: ['plain', 'console', 'paper', 'workbench'] },
|
|
39
|
+
{ name: 'yes', short: 'y', type: 'boolean', description: 'Accept the defaults' },
|
|
40
|
+
],
|
|
41
|
+
examples: ['textui init', 'textui init --shell workbench --theme workbench'],
|
|
42
|
+
async run(args, cli) {
|
|
43
|
+
const root = process.cwd();
|
|
44
|
+
const existing = await findProjectRoot(root);
|
|
45
|
+
if (existing === root) {
|
|
46
|
+
throw new CliError('this project is already initialised');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const config: ProjectConfig = {
|
|
50
|
+
...DEFAULT_CONFIG,
|
|
51
|
+
componentsDir: String(args.options.components),
|
|
52
|
+
templatesDir: String(args.options.templates),
|
|
53
|
+
theme: String(args.options.theme),
|
|
54
|
+
shell: String(args.options.shell),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
cli.write('This will create:\n');
|
|
58
|
+
cli.write(` textui.config.json\n`);
|
|
59
|
+
cli.write(` ${config.componentsDir}/\n`);
|
|
60
|
+
cli.write(` ${config.templatesDir}/\n\n`);
|
|
61
|
+
|
|
62
|
+
if (!args.options.yes && !(await promptConfirm('Continue?', { default: true }))) {
|
|
63
|
+
cli.write('Nothing was written.\n');
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const path = await writeConfig(root, config);
|
|
68
|
+
await mkdir(join(root, config.componentsDir), { recursive: true });
|
|
69
|
+
await mkdir(join(root, config.templatesDir), { recursive: true });
|
|
70
|
+
|
|
71
|
+
cli.write(`\nWrote ${path}\n`);
|
|
72
|
+
cli.write('\nNext:\n');
|
|
73
|
+
cli.write(' textui list see what is available\n');
|
|
74
|
+
cli.write(' textui add table copy a component into your project\n');
|
|
75
|
+
cli.write(' textui create dashboard\n');
|
|
76
|
+
return 0;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const listCommand: CliCommand = {
|
|
81
|
+
name: 'list',
|
|
82
|
+
aliases: ['ls'],
|
|
83
|
+
description: 'List registry components, templates and themes',
|
|
84
|
+
options: [
|
|
85
|
+
{ name: 'category', type: 'string', description: 'Filter by category' },
|
|
86
|
+
{ name: 'catalog', type: 'boolean', description: 'List the built-in component catalog instead' },
|
|
87
|
+
{ name: 'json', type: 'boolean', description: 'Machine-readable output' },
|
|
88
|
+
],
|
|
89
|
+
async run(args, cli) {
|
|
90
|
+
if (args.options.catalog) {
|
|
91
|
+
const category = args.options.category as string | undefined;
|
|
92
|
+
const items = CATALOG
|
|
93
|
+
.filter((c) => !category || c.category === category)
|
|
94
|
+
.map((c) => ({ name: c.component, category: c.category ?? '-', description: c.description ?? '' }));
|
|
95
|
+
|
|
96
|
+
if (args.options.json) {
|
|
97
|
+
cli.write(`${JSON.stringify(items, null, 2)}\n`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
cli.write(table(
|
|
101
|
+
['COMPONENT', 'CATEGORY', 'DESCRIPTION'],
|
|
102
|
+
items.map((i) => [i.name, i.category, i.description]),
|
|
103
|
+
));
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { manifest } = await loadRegistry();
|
|
108
|
+
|
|
109
|
+
if (args.options.json) {
|
|
110
|
+
cli.write(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
cli.write(`${manifest.name} ${manifest.version}\n\n`);
|
|
115
|
+
cli.write('Components:\n');
|
|
116
|
+
cli.write(table(
|
|
117
|
+
['NAME', 'CATEGORY', 'DEPENDS ON', 'DESCRIPTION'],
|
|
118
|
+
manifest.components.map((c) => [
|
|
119
|
+
c.name, c.category ?? '-', (c.dependencies ?? []).join(', ') || '-', c.description ?? '',
|
|
120
|
+
]),
|
|
121
|
+
));
|
|
122
|
+
|
|
123
|
+
cli.write('\nTemplates:\n');
|
|
124
|
+
cli.write(table(
|
|
125
|
+
['NAME', 'DESCRIPTION'],
|
|
126
|
+
(manifest.templates ?? []).map((t) => [t.name, t.description ?? '']),
|
|
127
|
+
));
|
|
128
|
+
|
|
129
|
+
cli.write('\nThemes:\n');
|
|
130
|
+
cli.write(table(
|
|
131
|
+
['NAME', 'APPEARANCE'],
|
|
132
|
+
(manifest.themes ?? []).map((t) => [t.name, t.appearance]),
|
|
133
|
+
));
|
|
134
|
+
return 0;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const addCommand: CliCommand = {
|
|
139
|
+
name: 'add',
|
|
140
|
+
description: 'Copy components into your project',
|
|
141
|
+
arguments: [{ name: 'components', required: true, variadic: true, description: 'Component names' }],
|
|
142
|
+
options: [
|
|
143
|
+
{ name: 'force', short: 'f', type: 'boolean', description: 'Overwrite files you have edited' },
|
|
144
|
+
{ name: 'dry-run', type: 'boolean', description: 'Show what would happen' },
|
|
145
|
+
{ name: 'registry', type: 'string', description: 'Registry path or name' },
|
|
146
|
+
{ name: 'yes', short: 'y', type: 'boolean', description: 'Do not ask' },
|
|
147
|
+
],
|
|
148
|
+
examples: ['textui add service-table', 'textui add status-dot metric-card --dry-run'],
|
|
149
|
+
async run(args, cli) {
|
|
150
|
+
const { root, config } = await requireProject();
|
|
151
|
+
const registrySource = resolveRegistrySource(config, args.options.registry as string | undefined);
|
|
152
|
+
const { manifest, dir } = await loadRegistry(registrySource);
|
|
153
|
+
|
|
154
|
+
const requested = args.positionals;
|
|
155
|
+
const components = resolveDependencies(manifest, requested);
|
|
156
|
+
const receipt = await readReceipt(root);
|
|
157
|
+
|
|
158
|
+
const implied = components.filter((c) => !requested.includes(c.name));
|
|
159
|
+
if (implied.length > 0) {
|
|
160
|
+
cli.write(`Also adding, as dependencies: ${implied.map((c) => c.name).join(', ')}\n`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cli.write('\nWill write:\n');
|
|
164
|
+
for (const component of components) {
|
|
165
|
+
for (const file of component.files) {
|
|
166
|
+
const target = file.target ?? join(config.componentsDir, file.path.split('/').pop() as string);
|
|
167
|
+
cli.write(` ${target}${existsSync(join(root, target)) ? ' (exists)' : ''}\n`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
cli.write('\n');
|
|
171
|
+
|
|
172
|
+
if (!args.options['dry-run'] && !args.options.yes) {
|
|
173
|
+
if (!(await promptConfirm('Continue?', { default: true }))) {
|
|
174
|
+
cli.write('Nothing was written.\n');
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let refused = 0;
|
|
180
|
+
for (const component of components) {
|
|
181
|
+
const result = await copyComponent(component, {
|
|
182
|
+
root,
|
|
183
|
+
registryDir: dir,
|
|
184
|
+
registryName: manifest.name,
|
|
185
|
+
config,
|
|
186
|
+
receipt,
|
|
187
|
+
force: Boolean(args.options.force),
|
|
188
|
+
dryRun: Boolean(args.options['dry-run']),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
for (const path of result.written) cli.write(` + ${path}\n`);
|
|
192
|
+
for (const skip of result.skipped) {
|
|
193
|
+
refused++;
|
|
194
|
+
cli.write(
|
|
195
|
+
skip.reason === 'modified'
|
|
196
|
+
? ` ! ${skip.path} - you have edited this; left alone (use --force to replace)\n`
|
|
197
|
+
: ` = ${skip.path} - already present; left alone\n`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!args.options['dry-run']) await writeReceipt(root, receipt);
|
|
203
|
+
if (args.options['dry-run']) cli.write('\nDry run: nothing was written.\n');
|
|
204
|
+
|
|
205
|
+
return refused > 0 ? 1 : 0;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const createCommand: CliCommand = {
|
|
210
|
+
name: 'create',
|
|
211
|
+
description: 'Scaffold a template into your project',
|
|
212
|
+
arguments: [{ name: 'template', required: true, description: 'Template name' }],
|
|
213
|
+
options: [
|
|
214
|
+
{ name: 'name', type: 'string', description: 'File name to write' },
|
|
215
|
+
{ name: 'yes', short: 'y', type: 'boolean', description: 'Do not ask' },
|
|
216
|
+
],
|
|
217
|
+
examples: ['textui create dashboard', 'textui create logs --name log-screen'],
|
|
218
|
+
async run(args, cli) {
|
|
219
|
+
const { root, config } = await requireProject();
|
|
220
|
+
const { manifest, dir } = await loadRegistry();
|
|
221
|
+
|
|
222
|
+
const name = args.positionals[0] as string;
|
|
223
|
+
const template = (manifest.templates ?? []).find((t) => t.name === name);
|
|
224
|
+
if (!template) {
|
|
225
|
+
const known = (manifest.templates ?? []).map((t) => t.name).join(', ');
|
|
226
|
+
throw new CliError(`unknown template "${name}". Available: ${known}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A template is composed from public components, so pull in what it needs.
|
|
230
|
+
if (template.components.length > 0) {
|
|
231
|
+
const components = resolveDependencies(manifest, template.components);
|
|
232
|
+
const receipt = await readReceipt(root);
|
|
233
|
+
for (const component of components) {
|
|
234
|
+
const result = await copyComponent(component, {
|
|
235
|
+
root, registryDir: dir, registryName: manifest.name, config, receipt,
|
|
236
|
+
});
|
|
237
|
+
for (const path of result.written) cli.write(` + ${path}\n`);
|
|
238
|
+
}
|
|
239
|
+
await writeReceipt(root, receipt);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const file of template.files) {
|
|
243
|
+
const source = join(dir, file);
|
|
244
|
+
if (!existsSync(source)) {
|
|
245
|
+
cli.write(` ! ${file} is listed in the registry but missing from it\n`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const base = (args.options.name as string | undefined) ?? (file.split('/').pop() as string).replace(/\.tsx?$/, '');
|
|
249
|
+
const target = join(root, config.templatesDir, `${base}.tsx`);
|
|
250
|
+
|
|
251
|
+
if (existsSync(target) && !args.options.yes) {
|
|
252
|
+
if (!(await promptConfirm(`${config.templatesDir}/${base}.tsx exists. Overwrite?`, { default: false }))) {
|
|
253
|
+
cli.write(` = skipped ${base}.tsx\n`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const content = (await readFile(source, 'utf8'))
|
|
259
|
+
.replace(/from '@textui\/core'/g, `from '${config.alias}'`);
|
|
260
|
+
await mkdir(join(root, config.templatesDir), { recursive: true });
|
|
261
|
+
await writeFile(target, content, 'utf8');
|
|
262
|
+
cli.write(` + ${config.templatesDir}/${base}.tsx\n`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
cli.write(`\nCreated "${name}".\n`);
|
|
266
|
+
return 0;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export const themeCommand: CliCommand = {
|
|
271
|
+
name: 'theme',
|
|
272
|
+
description: 'List themes, or preview one',
|
|
273
|
+
arguments: [{ name: 'theme', description: 'Theme to preview' }],
|
|
274
|
+
options: [
|
|
275
|
+
{ name: 'set', type: 'string', description: 'Set the project default' },
|
|
276
|
+
{ name: 'width', short: 'w', type: 'number', description: 'Preview width', default: 60 },
|
|
277
|
+
],
|
|
278
|
+
examples: ['textui theme', 'textui theme workbench', 'textui theme --set paper'],
|
|
279
|
+
async run(args, cli) {
|
|
280
|
+
if (args.options.set) {
|
|
281
|
+
const { root, config } = await requireProject();
|
|
282
|
+
const id = String(args.options.set);
|
|
283
|
+
if (!BUILTIN_THEMES.some((t) => t.id === id)) {
|
|
284
|
+
throw new CliError(`unknown theme "${id}"`);
|
|
285
|
+
}
|
|
286
|
+
await writeConfig(root, { ...config, theme: id });
|
|
287
|
+
cli.write(`Default theme is now "${id}".\n`);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const name = args.positionals[0];
|
|
292
|
+
if (!name) {
|
|
293
|
+
cli.write(table(
|
|
294
|
+
['THEME', 'APPEARANCE', 'BORDER', 'DENSITY'],
|
|
295
|
+
BUILTIN_THEMES.map((t) => [t.id, t.appearance, t.border ?? '-', t.density ?? 'normal']),
|
|
296
|
+
));
|
|
297
|
+
cli.write('\nPreview one with `textui theme <name>`.\n');
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!BUILTIN_THEMES.some((t) => t.id === name)) {
|
|
302
|
+
throw new CliError(`unknown theme "${name}"`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
cli.write(`${renderToString(themePreview(), {
|
|
306
|
+
theme: name,
|
|
307
|
+
width: Number(args.options.width),
|
|
308
|
+
// The preview is written as data, so its components come by name.
|
|
309
|
+
components: CATALOG,
|
|
310
|
+
})}\n`);
|
|
311
|
+
return 0;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
function themePreview() {
|
|
316
|
+
return {
|
|
317
|
+
component: 'box',
|
|
318
|
+
direction: 'column',
|
|
319
|
+
gap: 1,
|
|
320
|
+
padding: 1,
|
|
321
|
+
children: [
|
|
322
|
+
{ component: 'Heading', content: 'Theme preview' },
|
|
323
|
+
{
|
|
324
|
+
component: 'box',
|
|
325
|
+
direction: 'row',
|
|
326
|
+
gap: 2,
|
|
327
|
+
children: [
|
|
328
|
+
{ component: 'StatusDot', status: 'up', label: 'up' },
|
|
329
|
+
{ component: 'StatusDot', status: 'degraded', label: 'degraded' },
|
|
330
|
+
{ component: 'StatusDot', status: 'down', label: 'down' },
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
{ component: 'Progress', value: 0.62, label: 'progress', barWidth: 24 },
|
|
334
|
+
{ component: 'Sparkline', values: [1, 4, 2, 8, 5, 9, 3, 7], chartWidth: 24 },
|
|
335
|
+
{
|
|
336
|
+
component: 'Panel',
|
|
337
|
+
title: 'Panel',
|
|
338
|
+
children: { component: 'text', content: 'Body text inside a panel.' },
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
component: 'box',
|
|
342
|
+
direction: 'row',
|
|
343
|
+
gap: 1,
|
|
344
|
+
children: [
|
|
345
|
+
{ component: 'Badge', label: 'default' },
|
|
346
|
+
{ component: 'Badge', label: 'success', tone: 'success' },
|
|
347
|
+
{ component: 'Badge', label: 'danger', tone: 'danger' },
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export const registryCommand: CliCommand = {
|
|
355
|
+
name: 'registry',
|
|
356
|
+
description: 'Manage component registries',
|
|
357
|
+
commands: [
|
|
358
|
+
{
|
|
359
|
+
name: 'add',
|
|
360
|
+
description: 'Register another registry by name',
|
|
361
|
+
arguments: [
|
|
362
|
+
{ name: 'name', required: true },
|
|
363
|
+
{ name: 'source', required: true, description: 'Path to a directory containing registry.json' },
|
|
364
|
+
],
|
|
365
|
+
async run(args, cli) {
|
|
366
|
+
const { root, config } = await requireProject();
|
|
367
|
+
const [name, source] = args.positionals as [string, string];
|
|
368
|
+
await loadRegistry(source);
|
|
369
|
+
await writeConfig(root, {
|
|
370
|
+
...config,
|
|
371
|
+
registries: { ...config.registries, [name]: source },
|
|
372
|
+
});
|
|
373
|
+
cli.write(`Registry "${name}" added.\n`);
|
|
374
|
+
return 0;
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
name: 'list',
|
|
379
|
+
description: 'Show registered registries',
|
|
380
|
+
async run(_args, cli) {
|
|
381
|
+
const { config } = await requireProject();
|
|
382
|
+
const entries = Object.entries(config.registries ?? {});
|
|
383
|
+
cli.write(table(
|
|
384
|
+
['NAME', 'SOURCE'],
|
|
385
|
+
[['textui', '(built in)'], ...entries],
|
|
386
|
+
));
|
|
387
|
+
return 0;
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export const diffCommand: CliCommand = {
|
|
394
|
+
name: 'diff',
|
|
395
|
+
description: 'Show which copied components have drifted from upstream',
|
|
396
|
+
async run(_args, cli) {
|
|
397
|
+
const { root, config } = await requireProject();
|
|
398
|
+
const { manifest, dir } = await loadRegistry();
|
|
399
|
+
const receipt = await readReceipt(root);
|
|
400
|
+
const report = await diffInstalled(root, dir, manifest, receipt, config);
|
|
401
|
+
|
|
402
|
+
if (report.length === 0) {
|
|
403
|
+
cli.write('Nothing installed from the registry yet.\n');
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const symbols = { unchanged: '=', modified: 'M', outdated: 'U', missing: '!' } as const;
|
|
408
|
+
cli.write(table(
|
|
409
|
+
['', 'COMPONENT', 'FILE', 'STATE'],
|
|
410
|
+
report.map((r) => [symbols[r.state], r.component, r.file, r.state]),
|
|
411
|
+
));
|
|
412
|
+
cli.write('\nM = you edited it U = upstream changed ! = file is gone\n');
|
|
413
|
+
return report.some((r) => r.state === 'missing') ? 1 : 0;
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
export const doctorCommand: CliCommand = {
|
|
418
|
+
name: 'doctor',
|
|
419
|
+
description: 'Report what this terminal can do, and what the project looks like',
|
|
420
|
+
options: [{ name: 'json', type: 'boolean', description: 'Machine-readable output' }],
|
|
421
|
+
async run(args, cli) {
|
|
422
|
+
const env = describeEnvironment({
|
|
423
|
+
env: process.env,
|
|
424
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
425
|
+
columns: process.stdout.columns,
|
|
426
|
+
rows: process.stdout.rows,
|
|
427
|
+
});
|
|
428
|
+
const caps = detectCapabilities({
|
|
429
|
+
env: process.env,
|
|
430
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
431
|
+
columns: process.stdout.columns,
|
|
432
|
+
rows: process.stdout.rows,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const root = await findProjectRoot();
|
|
436
|
+
const project = root ? await readConfig(root) : null;
|
|
437
|
+
|
|
438
|
+
if (args.options.json) {
|
|
439
|
+
cli.write(`${JSON.stringify({ env, capabilities: caps, project, root }, null, 2)}\n`);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
cli.write('Environment\n');
|
|
444
|
+
cli.write(table(['KEY', 'VALUE'], Object.entries(env)));
|
|
445
|
+
|
|
446
|
+
cli.write('\nCapabilities\n');
|
|
447
|
+
cli.write(table(
|
|
448
|
+
['CAPABILITY', 'VALUE'],
|
|
449
|
+
Object.entries(caps).map(([k, v]) => [k, String(v)]),
|
|
450
|
+
));
|
|
451
|
+
|
|
452
|
+
cli.write('\nProject\n');
|
|
453
|
+
if (!project) {
|
|
454
|
+
cli.write(' no textui.config.json found - run `textui init`\n');
|
|
455
|
+
} else {
|
|
456
|
+
cli.write(table(['KEY', 'VALUE'], [
|
|
457
|
+
['root', root as string],
|
|
458
|
+
['components', project.componentsDir],
|
|
459
|
+
['templates', project.templatesDir],
|
|
460
|
+
['theme', project.theme ?? 'dark'],
|
|
461
|
+
['shell', project.shell ?? 'plain'],
|
|
462
|
+
]));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const warnings: string[] = [];
|
|
466
|
+
if (caps.colorDepth === 0) warnings.push('No colour: output will rely on glyphs alone (this is supported).');
|
|
467
|
+
if (caps.unicode === 'ascii') warnings.push('No Unicode: borders and glyphs degrade to ASCII.');
|
|
468
|
+
if (!process.stdout.isTTY) warnings.push('Not a terminal: interactive apps will not start, static rendering still works.');
|
|
469
|
+
|
|
470
|
+
if (warnings.length > 0) {
|
|
471
|
+
cli.write('\nNotes\n');
|
|
472
|
+
for (const warning of warnings) cli.write(` - ${warning}\n`);
|
|
473
|
+
}
|
|
474
|
+
return 0;
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
function resolveRegistrySource(config: ProjectConfig, requested?: string): string | undefined {
|
|
479
|
+
if (!requested) return undefined;
|
|
480
|
+
return config.registries?.[requested] ?? requested;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** A plain aligned table. The CLI's own output has no dependency on the TUI. */
|
|
484
|
+
function table(headers: string[], rows: string[][]): string {
|
|
485
|
+
const all = [headers, ...rows];
|
|
486
|
+
const widths = headers.map((_, i) => Math.max(...all.map((r) => (r[i] ?? '').length)));
|
|
487
|
+
const line = (cells: string[]): string =>
|
|
488
|
+
` ${cells.map((c, i) => (c ?? '').padEnd(widths[i] as number)).join(' ')}`.trimEnd();
|
|
489
|
+
|
|
490
|
+
return [line(headers), ...rows.map(line)].join('\n') + '\n';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export const COMMANDS: CliCommand[] = [
|
|
494
|
+
initCommand,
|
|
495
|
+
addCommand,
|
|
496
|
+
createCommand,
|
|
497
|
+
listCommand,
|
|
498
|
+
themeCommand,
|
|
499
|
+
registryCommand,
|
|
500
|
+
diffCommand,
|
|
501
|
+
doctorCommand,
|
|
502
|
+
];
|
package/src/index.ts
ADDED