@elyri/cli 0.0.0-stage → 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 轻爪科技
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/elyri.mjs ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createInterface } from 'node:readline/promises';
4
+
5
+ import { add, detectPackageManager, ExistingFilesError, parseArgs } from './lib.mjs';
6
+ import { components } from './manifest.mjs';
7
+
8
+ const [, , command, ...args] = process.argv;
9
+
10
+ const printUsage = () => {
11
+ console.error('Usage: elyri add <component...> [--dir <path>] [--overwrite]');
12
+ console.error(' elyri list');
13
+ console.error('Run `elyri list` to see every available component.');
14
+ };
15
+
16
+ /** Ask a yes/no question. Only called when stdin is a TTY. */
17
+ const ask = async (question) => {
18
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
19
+ try {
20
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
21
+ } catch {
22
+ // Stdin closed early (Ctrl+D): treat it as declining rather than dumping a raw error.
23
+ return false;
24
+ } finally {
25
+ rl.close();
26
+ }
27
+ };
28
+
29
+ /** Group components by the package they come from, so usage never needs one long line. */
30
+ const list = () => {
31
+ const byPackage = new Map();
32
+ for (const [slug, { pkg }] of Object.entries(components)) {
33
+ if (!byPackage.has(pkg)) byPackage.set(pkg, []);
34
+ byPackage.get(pkg).push(slug);
35
+ }
36
+
37
+ for (const [pkg, slugs] of byPackage) {
38
+ console.log(`${pkg} (${slugs.length})`);
39
+ for (const slug of slugs) console.log(` ${slug}`);
40
+ }
41
+ };
42
+
43
+ const run = async (options) => {
44
+ const { destination, files } = await add(options);
45
+ for (const file of files) console.log(`Added ${file}`);
46
+ console.log(`Import the components from your ${destination} directory.`);
47
+ };
48
+
49
+ const copy = async (rawArgs) => {
50
+ const { names, dir, overwrite } = parseArgs(rawArgs);
51
+ if (!names.length) {
52
+ printUsage();
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+
57
+ const cwd = process.cwd();
58
+ const options = { cwd, names, registry: components, manager: detectPackageManager(cwd), dir, overwrite };
59
+
60
+ try {
61
+ await run(options);
62
+ } catch (error) {
63
+ if (!(error instanceof ExistingFilesError)) throw error;
64
+
65
+ // Interactive terminals get a confirmation prompt; scripts must pass --overwrite.
66
+ if (process.stdin.isTTY) {
67
+ console.log(error.message);
68
+ if (await ask('Overwrite these files? (y/N) ')) {
69
+ await run({ ...options, overwrite: true });
70
+ return;
71
+ }
72
+ console.error('Aborted; no files were changed.');
73
+ } else {
74
+ console.error(error.message);
75
+ console.error('Pass --overwrite to replace them.');
76
+ }
77
+ process.exitCode = 1;
78
+ }
79
+ };
80
+
81
+ if (command === 'list') {
82
+ list();
83
+ } else if (command === 'add') {
84
+ try {
85
+ await copy(args);
86
+ } catch (error) {
87
+ console.error(error instanceof Error ? error.message : error);
88
+ process.exitCode = 1;
89
+ }
90
+ } else {
91
+ printUsage();
92
+ process.exitCode = 1;
93
+ }
@@ -0,0 +1,299 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { test } from 'node:test';
8
+
9
+ import {
10
+ add,
11
+ detectPackageManager,
12
+ ExistingFilesError,
13
+ parseArgs,
14
+ resolveDestination,
15
+ resolveSelection,
16
+ rewriteSource,
17
+ } from './lib.mjs';
18
+ import { components } from './manifest.mjs';
19
+
20
+ const cli = fileURLToPath(new URL('./elyri.mjs', import.meta.url));
21
+
22
+ // Package name → its root directory inside this workspace
23
+ const PACKAGE_ROOT = {
24
+ '@elyri/motion': fileURLToPath(new URL('../../motion/', import.meta.url)),
25
+ '@elyri/ui': fileURLToPath(new URL('../../ui/', import.meta.url)),
26
+ '@elyri/blocks': fileURLToPath(new URL('../../blocks/', import.meta.url)),
27
+ };
28
+
29
+ /** Every package the registry pulls source from */
30
+ const manifestPackages = [...new Set(Object.values(components).map((entry) => entry.pkg))];
31
+
32
+ const tempDir = () => mkdtemp(join(tmpdir(), 'elyri-cli-'));
33
+
34
+ /** Create a throwaway project and link the given source packages into its node_modules */
35
+ const createProject = async (packages) => {
36
+ const cwd = await tempDir();
37
+ await mkdir(join(cwd, 'src'), { recursive: true });
38
+ for (const pkg of packages) {
39
+ const target = join(cwd, 'node_modules', pkg);
40
+ await mkdir(join(cwd, 'node_modules', pkg, '..'), { recursive: true });
41
+ await symlink(PACKAGE_ROOT[pkg], target, 'dir');
42
+ }
43
+ return cwd;
44
+ };
45
+
46
+ const withProject = async (packages, run) => {
47
+ const cwd = await createProject(packages);
48
+ try {
49
+ await run(cwd);
50
+ } finally {
51
+ await rm(cwd, { recursive: true, force: true });
52
+ }
53
+ };
54
+
55
+ const withTempDir = async (run) => {
56
+ const cwd = await tempDir();
57
+ try {
58
+ await run(cwd);
59
+ } finally {
60
+ await rm(cwd, { recursive: true, force: true });
61
+ }
62
+ };
63
+
64
+ test('parseArgs separates names, dir and overwrite flags', () => {
65
+ assert.deepEqual(parseArgs(['a', 'b']), { names: ['a', 'b'], dir: undefined, overwrite: false });
66
+ assert.deepEqual(parseArgs(['a', '--dir', 'app/ui', '--overwrite']), {
67
+ names: ['a'],
68
+ dir: 'app/ui',
69
+ overwrite: true,
70
+ });
71
+ assert.deepEqual(parseArgs(['a', '--dir=app/ui', '-f']), { names: ['a'], dir: 'app/ui', overwrite: true });
72
+ assert.throws(() => parseArgs(['a', '--dir']), /needs a path/);
73
+ });
74
+
75
+ test('resolveSelection expands deps in order and rejects cycles', () => {
76
+ const registry = {
77
+ a: { pkg: 'p', folder: 'a', name: 'A', deps: ['b'] },
78
+ b: { pkg: 'p', folder: 'b', name: 'B' },
79
+ c: { pkg: 'p', folder: 'c', name: 'C', deps: ['c'] },
80
+ };
81
+
82
+ assert.deepEqual(
83
+ resolveSelection(['a', 'b'], registry).map((entry) => entry.slug),
84
+ ['b', 'a'],
85
+ );
86
+ assert.throws(() => resolveSelection(['c'], registry), /Circular dependency/);
87
+ assert.throws(() => resolveSelection(['nope'], registry), /Unknown component/);
88
+ });
89
+
90
+ test('rewriteSource points the shared runtime at the package', () => {
91
+ const source = "import { cn } from '../../core';\nimport './A.css';\n";
92
+ assert.equal(
93
+ rewriteSource(source, '@elyri/motion', 'A.tsx'),
94
+ "import { cn } from '@elyri/motion/core';\nimport './A.css';\n",
95
+ );
96
+ // A nested file may still reference siblings inside the component folder
97
+ const nested = "import './x.css';\nimport { y } from '../y';\n";
98
+ assert.equal(rewriteSource(nested, '@elyri/motion', 'parts/A.tsx'), nested);
99
+ });
100
+
101
+ test('rewriteSource rejects imports that escape the component folder', () => {
102
+ assert.throws(
103
+ () => rewriteSource("import { useX } from '../../hooks/useX';\n", '@elyri/motion', 'A.tsx'),
104
+ /outside the component folder/,
105
+ );
106
+ });
107
+
108
+ test('detectPackageManager prefers the user agent and falls back to lockfiles', async () => {
109
+ await withTempDir(async (cwd) => {
110
+ assert.equal(detectPackageManager(cwd, ''), 'pnpm');
111
+ await writeFile(join(cwd, 'yarn.lock'), '');
112
+ assert.equal(detectPackageManager(cwd, ''), 'yarn');
113
+ assert.equal(detectPackageManager(cwd, 'npm/11.0.0 node/v20'), 'npm');
114
+ });
115
+ });
116
+
117
+ test('resolveDestination follows tsconfig aliases, src layout and --dir', async () => {
118
+ await withTempDir(async (cwd) => {
119
+ // Neither a src directory nor a config: fall back to components/elyri
120
+ assert.equal(await resolveDestination(cwd), resolve(cwd, 'components/elyri'));
121
+
122
+ await mkdir(join(cwd, 'src'), { recursive: true });
123
+ assert.equal(await resolveDestination(cwd), resolve(cwd, 'src/components/elyri'));
124
+
125
+ // The alias is inherited through extends, so baseUrl resolves against the declaring config
126
+ await writeFile(join(cwd, 'tsconfig.json'), JSON.stringify({ extends: './tsconfig.base' }));
127
+ await writeFile(
128
+ join(cwd, 'tsconfig.base.json'),
129
+ JSON.stringify({ compilerOptions: { paths: { react: ['./node_modules/react'], '@/*': ['./app/*'] } } }),
130
+ );
131
+ assert.equal(await resolveDestination(cwd), resolve(cwd, 'app/components/elyri'));
132
+
133
+ assert.equal(await resolveDestination(cwd, 'lib/elyri'), resolve(cwd, 'lib/elyri'));
134
+ });
135
+ });
136
+
137
+ test('add copies the whole component folder and never overwrites', async () => {
138
+ await withProject(['@elyri/motion'], async (cwd) => {
139
+ const { destination, files } = await add({ cwd, names: ['aurora'], registry: components, manager: 'pnpm' });
140
+ const target = join(cwd, 'src/components/elyri/Aurora');
141
+
142
+ assert.equal(destination, join('src', 'components', 'elyri'));
143
+ for (const file of ['Aurora.tsx', 'Aurora.css', 'index.ts']) {
144
+ assert.ok(
145
+ files.some((path) => path.endsWith(`/Aurora/${file}`)),
146
+ `${file} should be copied`,
147
+ );
148
+ }
149
+
150
+ const component = await readFile(join(target, 'Aurora.tsx'), 'utf8');
151
+ assert.match(component, /from '@elyri\/motion\/core'/);
152
+ assert.doesNotMatch(component, /\.\.\/\.\.\/\.\.\/core/);
153
+ assert.ok((await readFile(join(target, 'Aurora.css'), 'utf8')).length > 0);
154
+ });
155
+ });
156
+
157
+ test('add reports every existing file and writes nothing', async () => {
158
+ await withProject(['@elyri/motion'], async (cwd) => {
159
+ await add({ cwd, names: ['aurora'], registry: components, manager: 'pnpm' });
160
+
161
+ const target = join(cwd, 'src/components/elyri/Aurora/Aurora.tsx');
162
+ await writeFile(target, 'custom content');
163
+
164
+ await assert.rejects(add({ cwd, names: ['aurora'], registry: components, manager: 'pnpm' }), (error) => {
165
+ assert.ok(error instanceof ExistingFilesError);
166
+ assert.ok(error.files.some((file) => file.endsWith('/Aurora/Aurora.tsx')));
167
+ return true;
168
+ });
169
+ assert.equal(await readFile(target, 'utf8'), 'custom content');
170
+ });
171
+ });
172
+
173
+ test('add replaces existing files when overwrite is set', async () => {
174
+ await withProject(['@elyri/motion'], async (cwd) => {
175
+ await add({ cwd, names: ['aurora'], registry: components, manager: 'pnpm' });
176
+
177
+ const target = join(cwd, 'src/components/elyri/Aurora/Aurora.tsx');
178
+ await writeFile(target, 'custom content');
179
+
180
+ await add({ cwd, names: ['aurora'], registry: components, manager: 'pnpm', overwrite: true });
181
+
182
+ const component = await readFile(target, 'utf8');
183
+ assert.match(component, /from '@elyri\/motion\/core'/);
184
+ assert.doesNotMatch(component, /custom content/);
185
+ });
186
+ });
187
+
188
+ test('add honours an explicit dir', async () => {
189
+ await withProject(['@elyri/motion'], async (cwd) => {
190
+ const { destination } = await add({
191
+ cwd,
192
+ names: ['fade-in'],
193
+ registry: components,
194
+ manager: 'pnpm',
195
+ dir: 'app/ui',
196
+ });
197
+
198
+ assert.equal(destination, join('app', 'ui'));
199
+ const component = await readFile(join(cwd, 'app/ui/FadeIn/FadeIn.tsx'), 'utf8');
200
+ assert.match(component, /from '@elyri\/motion\/core'/);
201
+ });
202
+ });
203
+
204
+ test('every registered component can be copied', async () => {
205
+ await withProject(manifestPackages, async (cwd) => {
206
+ await add({ cwd, names: Object.keys(components), registry: components, manager: 'pnpm' });
207
+
208
+ for (const { name } of Object.values(components)) {
209
+ const entry = await readFile(join(cwd, 'src/components/elyri', name, 'index.ts'), 'utf8');
210
+ assert.match(entry, /^export /m);
211
+ }
212
+ });
213
+ });
214
+
215
+ test('add rewrites cross-component imports for declared deps', async () => {
216
+ await withProject(['@elyri/ui'], async (cwd) => {
217
+ await add({ cwd, names: ['data-table'], registry: components, manager: 'pnpm' });
218
+
219
+ const source = await readFile(join(cwd, 'src/components/elyri/DataTable/DataTable.tsx'), 'utf8');
220
+ assert.match(source, /from '\.\.\/Checkbox'/);
221
+ assert.match(source, /from '\.\.\/Pagination'/);
222
+ assert.doesNotMatch(source, /\.\.\/\.\.\/(forms|navigation)\//);
223
+
224
+ // deps are copied as sibling folders that the rewritten imports resolve to
225
+ for (const name of ['Checkbox', 'Pagination']) {
226
+ assert.match(await readFile(join(cwd, 'src/components/elyri', name, 'index.ts'), 'utf8'), /^export /m);
227
+ }
228
+ });
229
+ });
230
+
231
+ test('add asks to install the matching package first', async () => {
232
+ await withTempDir(async (cwd) => {
233
+ await assert.rejects(
234
+ add({ cwd, names: ['fade-in'], registry: components, manager: 'npm' }),
235
+ /npm install @elyri\/motion/,
236
+ );
237
+ });
238
+ });
239
+
240
+ test('add explains when the installed package is too old', async () => {
241
+ await withTempDir(async (cwd) => {
242
+ const packageRoot = join(cwd, 'node_modules/@elyri/motion');
243
+ await mkdir(join(packageRoot, 'src/components'), { recursive: true });
244
+ await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ name: '@elyri/motion', version: '0.0.1' }));
245
+
246
+ await assert.rejects(
247
+ add({ cwd, names: ['aurora'], registry: components, manager: 'yarn' }),
248
+ /@elyri\/motion@0\.0\.1 does not ship backgrounds\/Aurora[\s\S]*yarn add @elyri\/motion@latest/,
249
+ );
250
+ });
251
+ });
252
+
253
+ test('cli lists components and reports usage on misuse', async () => {
254
+ await withProject(['@elyri/motion'], async (cwd) => {
255
+ const listed = execFileSync(process.execPath, [cli, 'list'], { cwd, encoding: 'utf8' });
256
+ assert.match(listed, /@elyri\/motion/);
257
+ assert.match(listed, /fade-in/);
258
+
259
+ execFileSync(process.execPath, [cli, 'add', 'fade-in'], { cwd });
260
+ const component = await readFile(join(cwd, 'src/components/elyri/FadeIn/FadeIn.tsx'), 'utf8');
261
+ assert.match(component, /from '@elyri\/motion\/core'/);
262
+
263
+ execFileSync(process.execPath, [cli, 'add', 'tilt', '--dir=app/ui'], { cwd });
264
+ assert.match(await readFile(join(cwd, 'app/ui/Tilt/Tilt.tsx'), 'utf8'), /from '@elyri\/motion\/core'/);
265
+
266
+ // stdin is a pipe here, so a conflict must fail with a hint instead of prompting
267
+ assert.throws(() => execFileSync(process.execPath, [cli, 'add', 'fade-in'], { cwd }), /Pass --overwrite/);
268
+ execFileSync(process.execPath, [cli, 'add', 'fade-in', '--overwrite'], { cwd });
269
+
270
+ assert.throws(() => execFileSync(process.execPath, [cli, 'add'], { cwd }), /Usage/);
271
+ assert.throws(() => execFileSync(process.execPath, [cli, 'add', 'nope'], { cwd }), /Unknown component/);
272
+ assert.throws(() => execFileSync(process.execPath, [cli, 'add', 'fade-in', '--dir'], { cwd }), /needs a path/);
273
+ });
274
+ });
275
+
276
+ test('manifest registers every component on disk', async () => {
277
+ const onDisk = [];
278
+ for (const [pkg, root] of Object.entries(PACKAGE_ROOT)) {
279
+ for (const category of await readdir(join(root, 'src/components'), { withFileTypes: true })) {
280
+ if (!category.isDirectory()) continue;
281
+ const categoryRoot = join(root, 'src/components', category.name);
282
+ for (const entry of await readdir(categoryRoot, { withFileTypes: true })) {
283
+ if (!entry.isDirectory()) continue;
284
+ // A folder counts as a component once it holds a .tsx file; empty placeholders do not
285
+ const files = await readdir(join(categoryRoot, entry.name));
286
+ if (files.some((file) => file.endsWith('.tsx'))) onDisk.push(`${pkg}:${category.name}/${entry.name}`);
287
+ }
288
+ }
289
+ }
290
+ const registered = Object.values(components).map(({ pkg, folder }) => `${pkg}:${folder}`);
291
+ assert.deepEqual(onDisk.sort(), registered.sort());
292
+ });
293
+
294
+ test('runtime packages publish the source the cli copies', async () => {
295
+ for (const pkg of new Set(Object.values(components).map((entry) => entry.pkg))) {
296
+ const manifest = JSON.parse(await readFile(join(PACKAGE_ROOT[pkg], 'package.json'), 'utf8'));
297
+ assert.ok(manifest.files.includes('src/components'), `${pkg} must publish src/components`);
298
+ }
299
+ });
package/bin/lib.mjs ADDED
@@ -0,0 +1,282 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, posix, relative, resolve, sep } from 'node:path';
4
+
5
+ /** The shared runtime is always imported as `<pkg>/core`, whatever depth the source used. */
6
+ const coreImport = /from\s+(['"])(?:\.\.\/)+core\1/g;
7
+ /** Any other relative import (side-effect imports and re-exports included); used to verify a copy cannot escape its folder. */
8
+ const relativeImport = /(?:from\s+|^\s*import\s+)(['"])(\.\.?\/[^'"]*)\1/gm;
9
+ /** Extensions whose imports are rewritten. */
10
+ const scriptExtensions = ['.ts', '.tsx'];
11
+ const packageManagers = ['pnpm', 'npm', 'yarn', 'bun'];
12
+ const installCommands = { pnpm: 'pnpm add', npm: 'npm install', yarn: 'yarn add', bun: 'bun add' };
13
+
14
+ /** Fallback destination when the project has neither a source alias nor a `src/` directory. */
15
+ const defaultDestination = 'src/components/elyri';
16
+
17
+ /** Thrown when a target file already exists; `files` lists every conflicting path. */
18
+ export class ExistingFilesError extends Error {
19
+ constructor(files) {
20
+ super(`Already exists; no files were changed:\n${files.map((file) => ` ${file}`).join('\n')}`);
21
+ this.name = 'ExistingFilesError';
22
+ this.files = files;
23
+ }
24
+ }
25
+
26
+ const isScript = (file) => scriptExtensions.some((ext) => file.endsWith(ext));
27
+
28
+ const readJson = async (file) => {
29
+ try {
30
+ return JSON.parse(await readFile(file, 'utf8'));
31
+ } catch {
32
+ return null;
33
+ }
34
+ };
35
+
36
+ /** Split component names from `--dir` / `--overwrite`, order independent. */
37
+ export const parseArgs = (rawArgs) => {
38
+ const names = [];
39
+ let dir;
40
+ let overwrite = false;
41
+
42
+ for (let index = 0; index < rawArgs.length; index += 1) {
43
+ const arg = rawArgs[index];
44
+ if (arg === '--dir' || arg === '-d') {
45
+ dir = rawArgs[index + 1];
46
+ if (!dir) throw new Error(`${arg} needs a path`);
47
+ index += 1;
48
+ } else if (arg.startsWith('--dir=')) {
49
+ dir = arg.slice('--dir='.length);
50
+ if (!dir) throw new Error('--dir needs a path');
51
+ } else if (arg === '--overwrite' || arg === '--force' || arg === '-f') {
52
+ overwrite = true;
53
+ } else {
54
+ names.push(arg);
55
+ }
56
+ }
57
+
58
+ return { names, dir, overwrite };
59
+ };
60
+
61
+ /** Detect the package manager from `npm_config_user_agent`, then lockfiles, then default to pnpm. */
62
+ export const detectPackageManager = (cwd, userAgent = process.env.npm_config_user_agent ?? '') => {
63
+ const fromAgent = packageManagers.find((manager) => userAgent.startsWith(manager));
64
+ if (fromAgent) return fromAgent;
65
+
66
+ const lockfiles = [
67
+ ['pnpm', 'pnpm-lock.yaml'],
68
+ ['yarn', 'yarn.lock'],
69
+ ['bun', 'bun.lockb'],
70
+ ['bun', 'bun.lock'],
71
+ ['npm', 'package-lock.json'],
72
+ ];
73
+ return lockfiles.find(([, lockfile]) => existsSync(join(cwd, lockfile)))?.[0] ?? 'pnpm';
74
+ };
75
+
76
+ /** Install command for a package, so errors suggest something that works in the user's setup. */
77
+ const installCommand = (manager, pkg) => `${installCommands[manager] ?? installCommands.pnpm} ${pkg}`;
78
+
79
+ /** Only these alias keys are treated as the project source root, so `react`-style mappings are ignored. */
80
+ const aliasKeys = ['@/*', '~/*', '#/*'];
81
+ const configFiles = ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'];
82
+
83
+ /** Walk `extends` to the first config that declares `paths`, keeping the file that declared them. */
84
+ const findPathsConfig = async (file, seen = new Set()) => {
85
+ const full = resolve(file);
86
+ if (seen.has(full)) return null;
87
+ seen.add(full);
88
+
89
+ const json = await readJson(full);
90
+ if (!json) return null;
91
+ if (json.compilerOptions?.paths) {
92
+ return {
93
+ paths: json.compilerOptions.paths,
94
+ baseUrl: json.compilerOptions.baseUrl ?? '.',
95
+ dir: dirname(full),
96
+ };
97
+ }
98
+ if (typeof json.extends === 'string' && json.extends.startsWith('.')) {
99
+ const next = json.extends.endsWith('.json') ? json.extends : `${json.extends}.json`;
100
+ return findPathsConfig(resolve(dirname(full), next), seen);
101
+ }
102
+ return null;
103
+ };
104
+
105
+ /** Infer the source root from a tsconfig alias, e.g. "@/*": ["./src/*"] → <cwd>/src. */
106
+ const findAliasRoot = async (cwd) => {
107
+ for (const configFile of configFiles) {
108
+ const found = await findPathsConfig(join(cwd, configFile));
109
+ if (!found) continue;
110
+
111
+ for (const key of aliasKeys) {
112
+ const [target] = Array.isArray(found.paths[key]) ? found.paths[key] : [];
113
+ if (typeof target !== 'string') continue;
114
+ const root = target.replace(/\*.*$/, '').replace(/\/+$/, '');
115
+ if (!root) continue;
116
+ return resolve(found.dir, found.baseUrl, root);
117
+ }
118
+ }
119
+ return null;
120
+ };
121
+
122
+ /** Copy target: `--dir` wins, then the tsconfig source alias, then the usual layout with or without `src/`. */
123
+ export const resolveDestination = async (cwd, dir) => {
124
+ if (dir) return resolve(cwd, dir);
125
+
126
+ const aliasRoot = await findAliasRoot(cwd);
127
+ if (aliasRoot) return join(aliasRoot, 'components', 'elyri');
128
+
129
+ return resolve(cwd, existsSync(join(cwd, 'src')) ? defaultDestination : 'components/elyri');
130
+ };
131
+
132
+ /** Expand `deps` recursively, keeping declaration order, de-duplicating and rejecting cycles. */
133
+ export const resolveSelection = (names, registry) => {
134
+ const selection = [];
135
+ const visiting = new Set();
136
+ const resolved = new Set();
137
+
138
+ const visit = (slug) => {
139
+ if (resolved.has(slug)) return;
140
+ const entry = registry[slug];
141
+ if (!entry) throw new Error(`Unknown component: ${slug}`);
142
+ if (visiting.has(slug)) throw new Error(`Circular dependency: ${slug}`);
143
+
144
+ visiting.add(slug);
145
+ for (const dep of entry.deps ?? []) visit(dep);
146
+ visiting.delete(slug);
147
+ resolved.add(slug);
148
+ selection.push({ slug, ...entry });
149
+ };
150
+
151
+ for (const name of new Set(names)) visit(name);
152
+ return selection;
153
+ };
154
+
155
+ /** Every file inside a component folder, as `/`-separated paths relative to it. */
156
+ const collectFiles = async (componentRoot) => {
157
+ const files = [];
158
+
159
+ const walk = async (dir) => {
160
+ for (const item of await readdir(dir, { withFileTypes: true })) {
161
+ if (item.name.startsWith('.')) continue;
162
+ const full = join(dir, item.name);
163
+ if (item.isDirectory()) await walk(full);
164
+ else files.push(relative(componentRoot, full).split(sep).join('/'));
165
+ }
166
+ };
167
+
168
+ await walk(componentRoot);
169
+ return files.sort();
170
+ };
171
+
172
+ /**
173
+ * Rewrite one source file so the shared runtime is imported from the package.
174
+ * A relative import may also point at another registered component: when `resolveComponent`
175
+ * maps that specifier to a sibling folder name, it is rewritten accordingly (deps are copied
176
+ * as siblings). Any other import that would escape the component folder is an error: copying
177
+ * it would produce a file that cannot resolve its own imports.
178
+ */
179
+ export const rewriteSource = (source, pkg, file, resolveComponent) => {
180
+ const depth = file.split('/').length - 1;
181
+
182
+ const escaping = new Set();
183
+ const rewritten = source
184
+ .replace(coreImport, (_match, quote) => `from ${quote}${pkg}/core${quote}`)
185
+ .replace(relativeImport, (match, quote, specifier) => {
186
+ const prefix = /^(?:\.\.\/)+/.exec(specifier)?.[0] ?? '';
187
+ const up = prefix ? prefix.split('../').length - 1 : 0;
188
+ if (up <= depth) return match;
189
+
190
+ const replacement = resolveComponent?.(specifier);
191
+ if (!replacement) {
192
+ escaping.add(specifier);
193
+ return match;
194
+ }
195
+ return match.replace(specifier, replacement);
196
+ });
197
+
198
+ if (escaping.size) {
199
+ throw new Error(`${file} imports outside the component folder: ${[...escaping].join(', ')}`);
200
+ }
201
+
202
+ return rewritten;
203
+ };
204
+
205
+ /**
206
+ * Map a cross-component relative import to the sibling folder a copy will live in.
207
+ * `fromDir` is the importing file's directory relative to `<pkg>/src/components`.
208
+ * Returns the new specifier, or null when the target is not a registered component.
209
+ */
210
+ const rewriteComponentImport = (registry, pkg, fromDir, file, specifier) => {
211
+ const target = posix.normalize(posix.join(fromDir, specifier));
212
+ const dep = Object.values(registry).find((entry) => entry.pkg === pkg && entry.folder === target);
213
+ if (!dep) return null;
214
+
215
+ const depth = posix.dirname(file).split('/').filter((segment) => segment && segment !== '.').length;
216
+ return `${'../'.repeat(depth + 1)}${dep.name}`;
217
+ };
218
+
219
+ /**
220
+ * Copy component source into the user's project. A component is copied as a whole
221
+ * folder (including its index.ts), so relative imports inside it keep working;
222
+ * only the shared runtime is rewritten to `<pkg>/core`.
223
+ */
224
+ export const add = async ({ cwd, names, registry, manager = 'pnpm', dir, overwrite = false }) => {
225
+ const selection = resolveSelection(names, registry);
226
+ const destination = await resolveDestination(cwd, dir);
227
+
228
+ // 1. Read everything into memory first: a failure here must not leave a half-written copy.
229
+ const pending = [];
230
+ for (const entry of selection) {
231
+ const packageRoot = resolve(cwd, 'node_modules', entry.pkg);
232
+ const sourceRoot = join(packageRoot, 'src/components');
233
+ if (!existsSync(sourceRoot)) {
234
+ throw new Error(`Missing ${entry.pkg}. Install it first: ${installCommand(manager, entry.pkg)}`);
235
+ }
236
+
237
+ const componentRoot = join(sourceRoot, entry.folder);
238
+ if (!existsSync(componentRoot)) {
239
+ // The package is installed but does not ship this component: usually a stale version.
240
+ const version = (await readJson(join(packageRoot, 'package.json')))?.version ?? 'unknown';
241
+ throw new Error(
242
+ `${entry.pkg}@${version} does not ship ${entry.folder}. Update it first: ${installCommand(manager, `${entry.pkg}@latest`)}`,
243
+ );
244
+ }
245
+
246
+ const files = await collectFiles(componentRoot);
247
+ if (!files.length) throw new Error(`${entry.pkg} ships no files for ${entry.slug}`);
248
+
249
+ for (const file of files) {
250
+ const from = join(componentRoot, file);
251
+ const source = await readFile(from, 'utf8');
252
+ const fromDir = posix.join(entry.folder, posix.dirname(file));
253
+ pending.push({
254
+ from,
255
+ to: join(destination, entry.name, file),
256
+ content: isScript(file)
257
+ ? rewriteSource(source, entry.pkg, file, (specifier) =>
258
+ rewriteComponentImport(registry, entry.pkg, fromDir, file, specifier),
259
+ )
260
+ : source,
261
+ });
262
+ }
263
+ }
264
+
265
+ // 2. Pre-check conflicts: an existing file, or two components writing the same path.
266
+ const seen = new Set();
267
+ const conflicts = new Set();
268
+ for (const { to } of pending) {
269
+ if (seen.has(to) || existsSync(to)) conflicts.add(to);
270
+ seen.add(to);
271
+ }
272
+ if (conflicts.size && !overwrite) throw new ExistingFilesError([...conflicts]);
273
+
274
+ // 3. Only write once every pre-check passed.
275
+ for (const { to, content } of pending) {
276
+ await mkdir(dirname(to), { recursive: true });
277
+ // `wx` never clobbers a file created between the pre-check and the write.
278
+ await writeFile(to, content, { flag: overwrite ? 'w' : 'wx' });
279
+ }
280
+
281
+ return { destination: relative(cwd, destination) || '.', files: pending.map(({ to }) => to) };
282
+ };
@@ -0,0 +1,76 @@
1
+ // Component registry: slug → source package, source folder and component name.
2
+ // `pkg` decides which package the CLI copies from and how the shared runtime is imported (<pkg>/core).
3
+ // An entry may declare `deps: ['other-slug']`; the CLI copies those first, recursively.
4
+ export const components = {
5
+ 'ascii-image': { pkg: '@elyri/motion', folder: 'media/AsciiImage', name: 'AsciiImage' },
6
+ aurora: { pkg: '@elyri/motion', folder: 'backgrounds/Aurora', name: 'Aurora' },
7
+ caustics: { pkg: '@elyri/motion', folder: 'backgrounds/Caustics', name: 'Caustics' },
8
+ contours: { pkg: '@elyri/motion', folder: 'backgrounds/Contours', name: 'Contours' },
9
+ 'count-up': { pkg: '@elyri/motion', folder: 'animations/CountUp', name: 'CountUp' },
10
+ 'dot-field': { pkg: '@elyri/motion', folder: 'backgrounds/DotField', name: 'DotField' },
11
+ 'dragon-scales': { pkg: '@elyri/motion', folder: 'backgrounds/DragonScales', name: 'DragonScales' },
12
+ 'fade-in': { pkg: '@elyri/motion', folder: 'animations/FadeIn', name: 'FadeIn' },
13
+ filaments: { pkg: '@elyri/motion', folder: 'backgrounds/Filaments', name: 'Filaments' },
14
+ 'flip-tiles': { pkg: '@elyri/motion', folder: 'backgrounds/FlipTiles', name: 'FlipTiles' },
15
+ 'gradient-text': { pkg: '@elyri/motion', folder: 'text/GradientText', name: 'GradientText' },
16
+ halo: { pkg: '@elyri/motion', folder: 'backgrounds/Halo', name: 'Halo' },
17
+ magnetic: { pkg: '@elyri/motion', folder: 'animations/Magnetic', name: 'Magnetic' },
18
+ marquee: { pkg: '@elyri/motion', folder: 'text/Marquee', name: 'Marquee' },
19
+ 'mesh-gradient': { pkg: '@elyri/motion', folder: 'backgrounds/MeshGradient', name: 'MeshGradient' },
20
+ 'particle-text': { pkg: '@elyri/motion', folder: 'text/ParticleText', name: 'ParticleText' },
21
+ 'pixel-vortex': { pkg: '@elyri/motion', folder: 'backgrounds/PixelVortex', name: 'PixelVortex' },
22
+ planet: { pkg: '@elyri/motion', folder: 'backgrounds/Planet', name: 'Planet' },
23
+ 'rotating-text': { pkg: '@elyri/motion', folder: 'text/RotatingText', name: 'RotatingText' },
24
+ 'scale-in': { pkg: '@elyri/motion', folder: 'animations/ScaleIn', name: 'ScaleIn' },
25
+ 'scramble-text': { pkg: '@elyri/motion', folder: 'text/ScrambleText', name: 'ScrambleText' },
26
+ 'scroll-marquee': { pkg: '@elyri/motion', folder: 'text/ScrollMarquee', name: 'ScrollMarquee' },
27
+ 'scroll-reveal': { pkg: '@elyri/motion', folder: 'text/ScrollReveal', name: 'ScrollReveal' },
28
+ 'silk-waves': { pkg: '@elyri/motion', folder: 'backgrounds/SilkWaves', name: 'SilkWaves' },
29
+ 'split-reveal': { pkg: '@elyri/motion', folder: 'text/SplitReveal', name: 'SplitReveal' },
30
+ spotlight: { pkg: '@elyri/motion', folder: 'animations/Spotlight', name: 'Spotlight' },
31
+ tilt: { pkg: '@elyri/motion', folder: 'animations/Tilt', name: 'Tilt' },
32
+ typewriter: { pkg: '@elyri/motion', folder: 'text/Typewriter', name: 'Typewriter' },
33
+ velvet: { pkg: '@elyri/motion', folder: 'backgrounds/Velvet', name: 'Velvet' },
34
+ 'wave-text': { pkg: '@elyri/motion', folder: 'text/WaveText', name: 'WaveText' },
35
+
36
+ // @elyri/ui
37
+ accordion: { pkg: '@elyri/ui', folder: 'navigation/Accordion', name: 'Accordion' },
38
+ alert: { pkg: '@elyri/ui', folder: 'feedback/Alert', name: 'Alert' },
39
+ avatar: { pkg: '@elyri/ui', folder: 'data-display/Avatar', name: 'Avatar' },
40
+ button: { pkg: '@elyri/ui', folder: 'forms/Button', name: 'Button' },
41
+ 'button-group': { pkg: '@elyri/ui', folder: 'forms/ButtonGroup', name: 'ButtonGroup' },
42
+ card: { pkg: '@elyri/ui', folder: 'data-display/Card', name: 'Card' },
43
+ checkbox: { pkg: '@elyri/ui', folder: 'forms/Checkbox', name: 'Checkbox' },
44
+ 'context-menu': { pkg: '@elyri/ui', folder: 'overlays/ContextMenu', name: 'ContextMenu' },
45
+ 'data-table': {
46
+ pkg: '@elyri/ui',
47
+ folder: 'data-display/DataTable',
48
+ name: 'DataTable',
49
+ deps: ['checkbox', 'pagination'],
50
+ },
51
+ dialog: { pkg: '@elyri/ui', folder: 'overlays/Dialog', name: 'Dialog' },
52
+ divider: { pkg: '@elyri/ui', folder: 'data-display/Divider', name: 'Divider' },
53
+ 'dropdown-menu': { pkg: '@elyri/ui', folder: 'overlays/DropdownMenu', name: 'DropdownMenu' },
54
+ empty: { pkg: '@elyri/ui', folder: 'data-display/Empty', name: 'Empty' },
55
+ field: { pkg: '@elyri/ui', folder: 'forms/Field', name: 'Field' },
56
+ input: { pkg: '@elyri/ui', folder: 'forms/Input', name: 'Input' },
57
+ item: { pkg: '@elyri/ui', folder: 'data-display/Item', name: 'Item' },
58
+ 'number-input': { pkg: '@elyri/ui', folder: 'forms/NumberInput', name: 'NumberInput' },
59
+ pagination: { pkg: '@elyri/ui', folder: 'navigation/Pagination', name: 'Pagination' },
60
+ popover: { pkg: '@elyri/ui', folder: 'overlays/Popover', name: 'Popover' },
61
+ progress: { pkg: '@elyri/ui', folder: 'feedback/Progress', name: 'Progress' },
62
+ radio: { pkg: '@elyri/ui', folder: 'forms/Radio', name: 'Radio' },
63
+ select: { pkg: '@elyri/ui', folder: 'forms/Select', name: 'Select' },
64
+ sidebar: { pkg: '@elyri/ui', folder: 'navigation/Sidebar', name: 'Sidebar' },
65
+ skeleton: { pkg: '@elyri/ui', folder: 'feedback/Skeleton', name: 'Skeleton' },
66
+ slider: { pkg: '@elyri/ui', folder: 'forms/Slider', name: 'Slider' },
67
+ switch: { pkg: '@elyri/ui', folder: 'forms/Switch', name: 'Switch' },
68
+ tabs: { pkg: '@elyri/ui', folder: 'navigation/Tabs', name: 'Tabs' },
69
+ tag: { pkg: '@elyri/ui', folder: 'data-display/Tag', name: 'Tag' },
70
+ textarea: { pkg: '@elyri/ui', folder: 'forms/Textarea', name: 'Textarea' },
71
+ toast: { pkg: '@elyri/ui', folder: 'feedback/Toast', name: 'Toast' },
72
+ tooltip: { pkg: '@elyri/ui', folder: 'overlays/Tooltip', name: 'Tooltip' },
73
+
74
+ // @elyri/blocks(页面区块,依赖 @elyri/ui 与 @elyri/motion)
75
+ login: { pkg: '@elyri/blocks', folder: 'auth/Login', name: 'Login' },
76
+ };
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@elyri/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "CLI that copies Elyri component source into your project.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/TechCat-Team/Elyri.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "bin"
14
+ ],
15
+ "bin": {
16
+ "elyri": "bin/elyri.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "keywords": [
22
+ "react",
23
+ "cli",
24
+ "component-library",
25
+ "css-variables"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test bin/*.test.mjs"
32
+ }
6
33
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.