@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.
@@ -0,0 +1,226 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { dirname, join, resolve, relative } from 'node:path';
5
+ export const DEFAULT_CONFIG = {
6
+ componentsDir: 'src/ui',
7
+ templatesDir: 'src/screens',
8
+ alias: '@textui/core',
9
+ theme: 'dark',
10
+ shell: 'plain',
11
+ typescript: true,
12
+ };
13
+ export const CONFIG_FILES = ['textui.config.json', 'textui.config.ts'];
14
+ export const RECEIPT_FILE = '.textui/components.json';
15
+ export function hashContent(content) {
16
+ return createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 16);
17
+ }
18
+ export async function findProjectRoot(from = process.cwd()) {
19
+ let dir = resolve(from);
20
+ for (;;) {
21
+ for (const name of CONFIG_FILES) {
22
+ if (existsSync(join(dir, name)))
23
+ return dir;
24
+ }
25
+ const parent = dirname(dir);
26
+ if (parent === dir)
27
+ return null;
28
+ dir = parent;
29
+ }
30
+ }
31
+ export async function readConfig(root) {
32
+ const jsonPath = join(root, 'textui.config.json');
33
+ if (existsSync(jsonPath)) {
34
+ const raw = await readFile(jsonPath, 'utf8');
35
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
36
+ }
37
+ // A `.ts` config is read for its literal object rather than executed: the
38
+ // CLI must work before the project's dependencies are installed.
39
+ const tsPath = join(root, 'textui.config.ts');
40
+ if (existsSync(tsPath)) {
41
+ const raw = await readFile(tsPath, 'utf8');
42
+ const match = /defineConfig\(\s*({[\s\S]*?})\s*\)/.exec(raw);
43
+ if (match) {
44
+ try {
45
+ return { ...DEFAULT_CONFIG, ...JSON.parse(toJson(match[1])) };
46
+ }
47
+ catch {
48
+ // Fall through to defaults; a config we cannot read is not fatal.
49
+ }
50
+ }
51
+ }
52
+ return { ...DEFAULT_CONFIG };
53
+ }
54
+ /** A permissive JS-object-literal to JSON conversion. Good enough for a config. */
55
+ function toJson(source) {
56
+ return source
57
+ .replace(/\/\/.*$/gm, '')
58
+ .replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":')
59
+ .replace(/'/g, '"')
60
+ .replace(/,(\s*[}\]])/g, '$1');
61
+ }
62
+ export async function writeConfig(root, config) {
63
+ const path = join(root, 'textui.config.json');
64
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
65
+ return path;
66
+ }
67
+ export async function readReceipt(root) {
68
+ const path = join(root, RECEIPT_FILE);
69
+ if (!existsSync(path))
70
+ return {};
71
+ try {
72
+ return JSON.parse(await readFile(path, 'utf8'));
73
+ }
74
+ catch {
75
+ return {};
76
+ }
77
+ }
78
+ export async function writeReceipt(root, receipt) {
79
+ const path = join(root, RECEIPT_FILE);
80
+ await mkdir(dirname(path), { recursive: true });
81
+ await writeFile(path, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
82
+ }
83
+ /** Where the shipped registry lives, relative to this package. */
84
+ export function builtinRegistryDir() {
85
+ // dist/registry.js -> packages/cli/dist -> repo root/components
86
+ const here = dirname(new URL(import.meta.url).pathname);
87
+ const candidates = [
88
+ resolve(here, '../../../components'),
89
+ resolve(here, '../../components'),
90
+ resolve(process.cwd(), 'components'),
91
+ ];
92
+ return candidates.find((dir) => existsSync(join(dir, 'registry.json'))) ?? candidates[0];
93
+ }
94
+ export async function loadRegistry(source) {
95
+ const dir = source ?? builtinRegistryDir();
96
+ const path = join(dir, 'registry.json');
97
+ if (!existsSync(path)) {
98
+ throw new Error(`no registry.json at ${dir}`);
99
+ }
100
+ const manifest = JSON.parse(await readFile(path, 'utf8'));
101
+ return { manifest, dir };
102
+ }
103
+ export function findComponent(manifest, name) {
104
+ return manifest.components.find((c) => c.name === name);
105
+ }
106
+ /** A component plus everything it depends on, dependencies first. */
107
+ export function resolveDependencies(manifest, names) {
108
+ const out = [];
109
+ const seen = new Set();
110
+ const visit = (name, trail) => {
111
+ if (seen.has(name))
112
+ return;
113
+ if (trail.includes(name)) {
114
+ throw new Error(`circular dependency: ${[...trail, name].join(' -> ')}`);
115
+ }
116
+ const component = findComponent(manifest, name);
117
+ if (!component) {
118
+ const known = manifest.components.map((c) => c.name).join(', ');
119
+ throw new Error(`unknown component "${name}". Available: ${known}`);
120
+ }
121
+ for (const dep of component.dependencies ?? [])
122
+ visit(dep, [...trail, name]);
123
+ seen.add(name);
124
+ out.push(component);
125
+ };
126
+ for (const name of names)
127
+ visit(name, []);
128
+ return out;
129
+ }
130
+ export async function copyComponent(component, options) {
131
+ const written = [];
132
+ const skipped = [];
133
+ const recorded = [];
134
+ const previous = options.receipt[component.name];
135
+ for (const file of component.files) {
136
+ const sourcePath = join(options.registryDir, file.path);
137
+ const targetRelative = file.target ?? join(options.config.componentsDir, basename(file.path));
138
+ const targetPath = join(options.root, targetRelative);
139
+ const source = await readFile(sourcePath, 'utf8');
140
+ const transformed = source.replace(/from '@textui\/core'/g, `from '${options.config.alias}'`);
141
+ const hash = hashContent(transformed);
142
+ if (existsSync(targetPath) && !options.force) {
143
+ const current = await readFile(targetPath, 'utf8');
144
+ const currentHash = hashContent(current);
145
+ const installedHash = previous?.files.find((f) => f.path === targetRelative)?.hash;
146
+ if (currentHash === hash) {
147
+ // Already up to date; nothing to do and nothing to warn about.
148
+ recorded.push({ path: targetRelative, hash });
149
+ continue;
150
+ }
151
+ // Only refuse when the local copy differs from what we installed - an
152
+ // untouched file is safe to update, an edited one is the user's work.
153
+ skipped.push({
154
+ path: targetRelative,
155
+ reason: installedHash && installedHash !== currentHash ? 'modified' : 'exists',
156
+ });
157
+ recorded.push({ path: targetRelative, hash: currentHash });
158
+ continue;
159
+ }
160
+ if (!options.dryRun) {
161
+ await mkdir(dirname(targetPath), { recursive: true });
162
+ await writeFile(targetPath, transformed, 'utf8');
163
+ }
164
+ written.push(targetRelative);
165
+ recorded.push({ path: targetRelative, hash });
166
+ }
167
+ if (!options.dryRun) {
168
+ options.receipt[component.name] = {
169
+ registry: options.registryName ?? 'textui',
170
+ version: component.version,
171
+ files: recorded,
172
+ installedAt: new Date().toISOString(),
173
+ };
174
+ }
175
+ return { component: component.name, written, skipped };
176
+ }
177
+ function basename(path) {
178
+ return path.split('/').pop() ?? path;
179
+ }
180
+ /** What has drifted from upstream, and in which direction. */
181
+ export async function diffInstalled(root, registryDir, manifest, receipt, config) {
182
+ const out = [];
183
+ for (const [name, entry] of Object.entries(receipt)) {
184
+ const component = findComponent(manifest, name);
185
+ if (!component)
186
+ continue;
187
+ for (const file of entry.files) {
188
+ const target = join(root, file.path);
189
+ if (!existsSync(target)) {
190
+ out.push({ component: name, file: file.path, state: 'missing' });
191
+ continue;
192
+ }
193
+ const current = hashContent(await readFile(target, 'utf8'));
194
+ const definition = component.files.find((f) => (f.target ?? join(config.componentsDir, basename(f.path))) === file.path);
195
+ const upstreamPath = definition ? join(registryDir, definition.path) : null;
196
+ const upstream = upstreamPath && existsSync(upstreamPath)
197
+ ? hashContent((await readFile(upstreamPath, 'utf8')).replace(/from '@textui\/core'/g, `from '${config.alias}'`))
198
+ : null;
199
+ if (current !== file.hash) {
200
+ out.push({ component: name, file: file.path, state: 'modified' });
201
+ }
202
+ else if (upstream && upstream !== current) {
203
+ out.push({ component: name, file: file.path, state: 'outdated' });
204
+ }
205
+ else {
206
+ out.push({ component: name, file: file.path, state: 'unchanged' });
207
+ }
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ export async function listSourceFiles(dir) {
213
+ const out = [];
214
+ const walk = async (current) => {
215
+ for (const entry of await readdir(current, { withFileTypes: true })) {
216
+ const full = join(current, entry.name);
217
+ if (entry.isDirectory())
218
+ await walk(full);
219
+ else
220
+ out.push(relative(dir, full));
221
+ }
222
+ };
223
+ if (existsSync(dir) && (await stat(dir)).isDirectory())
224
+ await walk(dir);
225
+ return out;
226
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@textui/cli",
3
+ "version": "0.1.0",
4
+ "description": "TextUI developer CLI - init, add, create, doctor - and primitives for building your own",
5
+ "keywords": [
6
+ "terminal",
7
+ "tui",
8
+ "cli",
9
+ "scaffold",
10
+ "generator",
11
+ "init",
12
+ "component-registry"
13
+ ],
14
+ "homepage": "https://softov.github.io/textui/",
15
+ "bugs": {
16
+ "url": "https://github.com/softov/textui/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/softov/textui.git",
21
+ "directory": "packages/cli"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Softov <softov@brbyte.com>",
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "bin": {
30
+ "textui": "./dist/bin.js"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.ts",
41
+ "import": "./dist/index.js"
42
+ },
43
+ "./app": {
44
+ "types": "./dist/app.d.ts",
45
+ "import": "./dist/app.js"
46
+ }
47
+ },
48
+ "dependencies": {
49
+ "@textui/core": "^0.1.0",
50
+ "@textui/widgets": "^0.1.0",
51
+ "@textui/terminal": "^0.1.0"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "scripts": {
57
+ "build": "tsc -p tsconfig.json",
58
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
59
+ "test": "vitest run"
60
+ }
61
+ }