@trineui/cli 0.1.2 → 0.3.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/README.md CHANGED
@@ -6,25 +6,42 @@ Canonical public package:
6
6
  npx @trineui/cli@latest add button
7
7
  ```
8
8
 
9
- Current v0 command:
9
+ Registry status note:
10
+
11
+ - live npm `@latest` is currently `0.1.2`
12
+ - repo-local next publish candidate is `0.2.0`
13
+ - the live registry package currently exposes `add button`
14
+ - `init` is implemented in this repo and included in the next publish candidate, but it is not available from the live npm package until publish succeeds
15
+
16
+ Repository-local current command surface:
10
17
 
11
18
  ```bash
19
+ trine init [--target <app-root>] [--yes]
12
20
  trine add button --target <app-root>
13
21
  ```
14
22
 
15
23
  Notes:
16
24
 
25
+ - `init` is now the preferred first step for project readiness; `add button` still works without it for backward compatibility
26
+ - `init` is guided by default: it detects the target, surfaces warnings, previews file changes, and asks for confirmation before mutation
27
+ - `init --yes` keeps a non-interactive fast path for automation and scripting
17
28
  - `button` is the only supported component in this public-style baseline
18
29
  - omitting `--target` uses the current directory when it already matches the supported Angular app shape
19
30
  - when the current directory is not a supported Angular app target, the CLI auto-detects a single Angular app target under the current directory and proceeds automatically
20
- - when multiple Angular app targets are found, the CLI stops and asks for `--target <app-root>`
31
+ - when multiple Angular app targets are found, guided `init` lets the user choose one interactively
32
+ - when multiple Angular app targets are found, `init --yes` and `add button` fail clearly and ask for `--target <app-root>`
33
+ - when multiple plausible stylesheet entries are found, guided `init` lets the user choose one interactively
34
+ - when multiple plausible stylesheet entries are found, `init --yes` fails clearly and asks the user to rerun without `--yes`
21
35
  - external targets can run `trine add button` from the app root or pass an explicit app root such as `--target /absolute/path/to/angular-app`
36
+ - `init` detects `angular` and `ionic-angular` targets conservatively; unsupported targets fail clearly
22
37
  - the canonical public package name is `@trineui/cli`
23
38
  - the CLI command exposed through the package bin is still `trine`
24
39
  - `apps/consumer-fixture` is the first separate-target proof and does not use the demo-only `@trine/ui/*` bridge
25
40
  - `/tmp/trine-button-publish-proof` is the latest truly external packaged-proof repo outside the monorepo
26
41
  - packaged/public-style proof uses a packed local tarball to simulate `npx @trineui/cli@latest add button`
27
42
  - the packaged CLI ships compiled runtime files plus Button templates so it can execute from `node_modules` in a real `npx`-style flow
43
+ - `init` ensures `src/styles/tokens.css`, `src/styles/trine-consumer.css`, `src/app/components/ui/index.ts`, the local `@trine/ui` alias, and local stylesheet wiring
44
+ - rerunning `init` on an already prepared target exits cleanly with a no-op summary instead of prompting again
28
45
  - consumer-owned component destination files cause a clear failure
29
46
  - existing shared baseline files (`tokens.css` and `trine-consumer.css`) are preserved so a second component can be added into the same target repo
30
47
  - the command copies consumer-owned source instead of wiring runtime back to `packages/ui`
@@ -38,5 +55,6 @@ Notes:
38
55
  Local package proof equivalent:
39
56
 
40
57
  ```bash
41
- npx --yes --package /absolute/path/to/trineui-cli-0.1.0.tgz trine add button
58
+ npx --yes --package /absolute/path/to/trineui-cli-<version>.tgz trine init
59
+ npx --yes --package /absolute/path/to/trineui-cli-<version>.tgz trine add button
42
60
  ```
@@ -1,11 +1,6 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import ts from 'typescript';
5
- const STYLE_SOURCE_FILES = ['styles/tokens.css', 'styles/trine-consumer.css'];
6
- const TEMPLATE_ROOT = fileURLToPath(new URL('../templates/', import.meta.url));
7
- const LOCAL_REPO_DEMO_ROOT = path.resolve(TEMPLATE_ROOT, '../../../apps/demo');
8
- const STYLE_IMPORT_LINE = "@import './styles/trine-consumer.css';";
3
+ import { TEMPLATE_ROOT, ensureLinesFile, ensureSharedStyleBaseline, ensureStylesImport, ensureTsconfigAlias, isDemoTarget, readTargetDependencyWarnings, resolveSupportedProjectTarget, toTargetRelativePath, } from "./project.js";
9
4
  const DEMO_BRIDGE_COMMENT = '// Temporary demo verification bridge: delivered local components resolve locally; other components still re-export from the authoring source.';
10
5
  const DEMO_LOCAL_COMPONENTS = [
11
6
  {
@@ -61,9 +56,8 @@ const DEMO_PROXY_EXPORT_LINES = [
61
56
  ];
62
57
  export function addComponent(manifest, options) {
63
58
  const targetRoot = path.resolve(options.cwd, options.target);
64
- const { targetStylesEntry, targetTsconfig } = resolveTargetShape(manifest.componentName, targetRoot);
59
+ const { targetStylesEntry, targetTsconfig } = resolveSupportedProjectTarget(`trine add ${manifest.componentName}`, targetRoot);
65
60
  const componentDestDir = path.join(targetRoot, 'src', 'app', 'components', 'ui', manifest.componentName);
66
- const stylesDestDir = path.join(targetRoot, 'src', 'styles');
67
61
  const componentCopyTargets = manifest.sourceFiles.map((source) => ({
68
62
  source: path.join(TEMPLATE_ROOT, source),
69
63
  destination: path.join(componentDestDir, path.basename(source)),
@@ -78,13 +72,12 @@ export function addComponent(manifest, options) {
78
72
  ].join('\n'));
79
73
  }
80
74
  mkdirSync(componentDestDir, { recursive: true });
81
- mkdirSync(stylesDestDir, { recursive: true });
82
75
  for (const { source, destination } of componentCopyTargets) {
83
76
  copyFileSync(source, destination);
84
77
  }
85
78
  const componentCopiedFiles = componentCopyTargets.map(({ destination }) => toTargetRelativePath(targetRoot, destination));
86
- const sharedStylesResult = ensureSharedStyleBaseline(targetRoot, stylesDestDir, manifest.componentLabel);
87
- const copiedFiles = [...componentCopiedFiles, ...sharedStylesResult.copiedFiles];
79
+ const sharedStylesResult = ensureSharedStyleBaseline(targetRoot, manifest.componentLabel);
80
+ const copiedFiles = [...componentCopiedFiles, ...sharedStylesResult.createdFiles];
88
81
  const updatedFiles = [];
89
82
  const warnings = [...sharedStylesResult.warnings];
90
83
  const componentBarrelPath = path.join(componentDestDir, 'index.ts');
@@ -122,78 +115,6 @@ export function addComponent(manifest, options) {
122
115
  targetRoot,
123
116
  };
124
117
  }
125
- function ensureSharedStyleBaseline(targetRoot, stylesDestDir, componentLabel) {
126
- const copiedFiles = [];
127
- const warnings = [];
128
- for (const sourceFile of STYLE_SOURCE_FILES) {
129
- const templatePath = path.join(TEMPLATE_ROOT, sourceFile);
130
- const destinationPath = path.join(stylesDestDir, path.basename(sourceFile));
131
- const relativeDestination = toTargetRelativePath(targetRoot, destinationPath);
132
- if (!existsSync(destinationPath)) {
133
- copyFileSync(templatePath, destinationPath);
134
- copiedFiles.push(relativeDestination);
135
- continue;
136
- }
137
- if (readFileSync(destinationPath, 'utf8') !== readFileSync(templatePath, 'utf8')) {
138
- warnings.push(`${relativeDestination} already exists and was preserved. Review it manually if the delivered ${componentLabel} expects newer shared styling baseline content.`);
139
- }
140
- }
141
- return {
142
- copiedFiles,
143
- warnings,
144
- };
145
- }
146
- function resolveTargetShape(componentName, targetRoot) {
147
- const targetAppDir = path.join(targetRoot, 'src', 'app');
148
- const targetTsconfig = path.join(targetRoot, 'tsconfig.app.json');
149
- const targetStylesEntry = resolveStylesEntry(targetRoot);
150
- const missing = [];
151
- if (!existsSync(targetRoot)) {
152
- missing.push(targetRoot);
153
- }
154
- if (!existsSync(targetAppDir)) {
155
- missing.push(targetAppDir);
156
- }
157
- if (!targetStylesEntry) {
158
- missing.push(`${path.join(targetRoot, 'src', 'styles.scss')} or ${path.join(targetRoot, 'src', 'styles.css')} or the first resolvable build styles entry in ${path.join(targetRoot, 'angular.json')}`);
159
- }
160
- if (!existsSync(targetTsconfig)) {
161
- missing.push(targetTsconfig);
162
- }
163
- if (missing.length > 0) {
164
- throw new Error([
165
- `trine add ${componentName} requires an Angular app target with src/app, a global stylesheet entry, and tsconfig.app.json.`,
166
- ...missing.map((file) => `- ${file}`),
167
- ].join('\n'));
168
- }
169
- if (!targetStylesEntry) {
170
- throw new Error(`trine add ${componentName} could not resolve a global stylesheet entry for ${targetRoot}.`);
171
- }
172
- return {
173
- targetStylesEntry,
174
- targetTsconfig,
175
- };
176
- }
177
- function ensureLinesFile(filePath, lines) {
178
- const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
179
- const normalizedExisting = existing.trimEnd();
180
- const currentLines = normalizedExisting === '' ? [] : normalizedExisting.split('\n');
181
- let changed = false;
182
- for (const line of lines) {
183
- if (!currentLines.includes(line)) {
184
- currentLines.push(line);
185
- changed = true;
186
- }
187
- }
188
- if (!existsSync(filePath)) {
189
- changed = true;
190
- }
191
- if (changed) {
192
- mkdirSync(path.dirname(filePath), { recursive: true });
193
- writeFileSync(filePath, `${currentLines.join('\n')}\n`);
194
- }
195
- return changed;
196
- }
197
118
  function rewriteDemoUiRootBarrel(filePath) {
198
119
  const uiRootDir = path.dirname(filePath);
199
120
  const localComponentLines = DEMO_LOCAL_COMPONENTS.filter(({ key }) => existsSync(path.join(uiRootDir, key, 'index.ts')));
@@ -212,151 +133,3 @@ function rewriteDemoUiRootBarrel(filePath) {
212
133
  writeFileSync(filePath, nextContent);
213
134
  return true;
214
135
  }
215
- function ensureTsconfigAlias(tsconfigPath, targetRoot, cwd) {
216
- const currentText = readFileSync(tsconfigPath, 'utf8');
217
- const parsed = ts.parseConfigFileTextToJson(tsconfigPath, currentText);
218
- if (parsed.error) {
219
- throw new Error(`Unable to parse ${path.relative(cwd, tsconfigPath)} as JSONC.`);
220
- }
221
- const config = (parsed.config ?? {});
222
- config.compilerOptions ??= {};
223
- config.compilerOptions.paths ??= {};
224
- const aliasTarget = isDemoTarget(targetRoot)
225
- ? toPosixPath(path.relative(process.cwd(), path.join(targetRoot, 'src', 'app', 'components', 'ui', 'index.ts')))
226
- : toConfigRelativePath(path.relative(path.dirname(tsconfigPath), path.join(targetRoot, 'src', 'app', 'components', 'ui', 'index.ts')));
227
- const wildcardTarget = 'packages/ui/*';
228
- const currentAlias = config.compilerOptions.paths['@trine/ui'];
229
- const currentWildcardAlias = config.compilerOptions.paths['@trine/ui/*'];
230
- const aliasIsCurrent = Array.isArray(currentAlias) && currentAlias.length === 1 && currentAlias[0] === aliasTarget;
231
- const wildcardIsCurrent = !isDemoTarget(targetRoot) ||
232
- (Array.isArray(currentWildcardAlias) &&
233
- currentWildcardAlias.length === 1 &&
234
- currentWildcardAlias[0] === wildcardTarget);
235
- if (aliasIsCurrent && wildcardIsCurrent) {
236
- return false;
237
- }
238
- config.compilerOptions.paths['@trine/ui'] = [aliasTarget];
239
- if (isDemoTarget(targetRoot)) {
240
- config.compilerOptions.paths['@trine/ui/*'] = [wildcardTarget];
241
- }
242
- else if ('@trine/ui/*' in config.compilerOptions.paths) {
243
- delete config.compilerOptions.paths['@trine/ui/*'];
244
- }
245
- writeFileSync(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`);
246
- return true;
247
- }
248
- function ensureStylesImport(stylesPath) {
249
- const current = readFileSync(stylesPath, 'utf8');
250
- if (current.includes(STYLE_IMPORT_LINE)) {
251
- return {
252
- updated: false,
253
- authoringImportStillPresent: current.includes("@import '@trine/ui/styles/trine.css';"),
254
- };
255
- }
256
- const lines = current.split('\n');
257
- let insertAt = -1;
258
- for (let index = 0; index < lines.length; index += 1) {
259
- const trimmed = lines[index].trim();
260
- if (trimmed.startsWith('@use') || trimmed.startsWith('@import')) {
261
- insertAt = index;
262
- }
263
- }
264
- if (insertAt === -1) {
265
- lines.unshift(STYLE_IMPORT_LINE, '');
266
- }
267
- else {
268
- lines.splice(insertAt + 1, 0, STYLE_IMPORT_LINE);
269
- }
270
- writeFileSync(stylesPath, lines.join('\n'));
271
- return {
272
- updated: true,
273
- authoringImportStillPresent: current.includes("@import '@trine/ui/styles/trine.css';"),
274
- };
275
- }
276
- function isDemoTarget(targetRoot) {
277
- return (existsSync(LOCAL_REPO_DEMO_ROOT) &&
278
- path.resolve(targetRoot) === path.resolve(LOCAL_REPO_DEMO_ROOT));
279
- }
280
- function toPosixPath(filePath) {
281
- return filePath.split(path.sep).join(path.posix.sep);
282
- }
283
- function toConfigRelativePath(filePath) {
284
- const posixPath = toPosixPath(filePath);
285
- return posixPath.startsWith('.') ? posixPath : `./${posixPath}`;
286
- }
287
- function toTargetRelativePath(targetRoot, filePath) {
288
- return toPosixPath(path.relative(targetRoot, filePath));
289
- }
290
- function readTargetDependencyWarnings(targetRoot, componentLabel) {
291
- const packageJsonPath = path.join(targetRoot, 'package.json');
292
- if (!existsSync(packageJsonPath)) {
293
- return [
294
- 'No package.json was found in the target root, so Tailwind CSS v4 and class-variance-authority prerequisites could not be checked automatically.',
295
- ];
296
- }
297
- try {
298
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
299
- const deps = {
300
- ...(packageJson.dependencies ?? {}),
301
- ...(packageJson.devDependencies ?? {}),
302
- };
303
- const warnings = [];
304
- if (!deps['class-variance-authority']) {
305
- warnings.push(`class-variance-authority is missing from the target repo. Install it before building the delivered ${componentLabel}.`);
306
- }
307
- const tailwindRange = deps['tailwindcss'];
308
- if (!tailwindRange) {
309
- warnings.push(`tailwindcss is missing from the target repo. The current proven ${componentLabel} baseline expects Tailwind CSS v4.`);
310
- }
311
- else if (!looksLikeTailwindV4(tailwindRange)) {
312
- warnings.push(`The target repo declares tailwindcss@${tailwindRange}. The current proven ${componentLabel} baseline expects Tailwind CSS v4.`);
313
- }
314
- return warnings;
315
- }
316
- catch {
317
- return [
318
- `package.json could not be parsed for dependency checks. Verify Tailwind CSS v4 and class-variance-authority manually before building the delivered ${componentLabel}.`,
319
- ];
320
- }
321
- }
322
- function looksLikeTailwindV4(range) {
323
- return /(^|[^\d])4(\D|$)/.test(range);
324
- }
325
- export function looksLikeAngularAppRoot(root) {
326
- return (existsSync(path.join(root, 'src', 'app')) &&
327
- existsSync(path.join(root, 'tsconfig.app.json')) &&
328
- resolveStylesEntry(root) !== undefined);
329
- }
330
- function resolveStylesEntry(targetRoot) {
331
- const conventionalStyles = ['src/styles.scss', 'src/styles.css'];
332
- for (const relativePath of conventionalStyles) {
333
- const absolutePath = path.join(targetRoot, relativePath);
334
- if (existsSync(absolutePath)) {
335
- return absolutePath;
336
- }
337
- }
338
- const angularJsonPath = path.join(targetRoot, 'angular.json');
339
- if (!existsSync(angularJsonPath)) {
340
- return undefined;
341
- }
342
- try {
343
- const angularJson = JSON.parse(readFileSync(angularJsonPath, 'utf8'));
344
- for (const project of Object.values(angularJson.projects ?? {})) {
345
- const styles = project.architect?.build?.options?.styles ?? project.targets?.build?.options?.styles ?? [];
346
- for (const styleEntry of styles) {
347
- const relativePath = typeof styleEntry === 'string' ? styleEntry : (styleEntry.input ?? undefined);
348
- if (!relativePath) {
349
- continue;
350
- }
351
- const absolutePath = path.join(targetRoot, relativePath);
352
- if (existsSync(absolutePath)) {
353
- return absolutePath;
354
- }
355
- }
356
- }
357
- }
358
- catch {
359
- return undefined;
360
- }
361
- return undefined;
362
- }
package/dist/index.js CHANGED
@@ -1,133 +1,299 @@
1
1
  #!/usr/bin/env node
2
- import { readdirSync } from 'node:fs';
3
2
  import path from 'node:path';
4
- import { looksLikeAngularAppRoot } from "./add-component.js";
5
3
  import { addButton } from "./add-button.js";
4
+ import { applyInitPlan, planInitProject } from "./init.js";
5
+ import { chooseFromList, confirmAction } from "./prompt.js";
6
+ import { findAngularAppTargets, inspectProjectTarget, looksLikeAngularAppRoot } from "./project.js";
6
7
  const HELP_TEXT = `Usage:
8
+ npx @trineui/cli@latest init [--target <app-root>] [--yes]
7
9
  npx @trineui/cli@latest add button [--target <app-root>]
10
+ trine init [--target <app-root>] [--yes]
8
11
  trine add button [--target <app-root>]
9
12
 
10
13
  Defaults:
11
- - current directory when it matches the supported Angular app target shape
12
- - otherwise auto-detect a single Angular app target under the current directory
13
- - when multiple Angular app targets are found, re-run with --target <app-root>
14
+ - init is guided by default and asks for confirmation before mutating project files
15
+ - init --yes keeps a non-interactive fast path for automation and scripting
16
+ - current directory is used when it already matches the supported Trine app target shape
17
+ - otherwise init/add auto-detect a single supported Angular app target under the current directory
18
+ - when multiple supported app targets are found, guided init lets you choose and add still asks for --target
14
19
 
15
20
  Notes:
16
- - v0 supports Button only
17
- - external targets can run trine add button from the app root or pass --target /absolute/path/to/angular-app
18
- - the current proven target model is Angular 21 + src/app + tsconfig.app.json + a global stylesheet entry such as src/styles.scss, src/styles.css, or src/global.scss resolved from angular.json
19
- - use a Node LTS line supported by Angular 21 in the target repo
21
+ - v0 supports init plus add button only
22
+ - init owns target detection, framework detection, stylesheet resolution, baseline files, local @trine/ui alias setup, and local stylesheet wiring
23
+ - add button still works without init for backward compatibility, but init is the preferred first step
24
+ - the current proven target model is Angular 21 + src/app + tsconfig.app.json + a global stylesheet entry such as src/styles.scss, src/styles.css, or src/global.scss resolved directly or from angular.json
25
+ - v0 distinguishes angular and ionic-angular targets; unsupported frameworks fail clearly
20
26
  - the current proven styling/runtime baseline requires Tailwind CSS v4 and class-variance-authority in the target repo
21
27
  - consumer-owned component files fail clearly if they already exist
22
28
  - shared styling baseline files are copied when missing and preserved when they already exist
23
29
  - @trine/ui is configured as a consumer-local alias inside the target app
24
30
  - apps/demo keeps a temporary @trine/ui/* bridge for non-localized components during local repo verification`;
25
31
  const SUPPORTED_COMPONENTS = ['button'];
26
- function main(argv) {
27
- const [command, component, ...rest] = argv;
32
+ async function main(argv) {
33
+ const [command, secondArg, ...rest] = argv;
34
+ if (command === 'init') {
35
+ const flags = parseFlags(argv.slice(1), ['--target', '--yes']);
36
+ const selection = flags.yes
37
+ ? resolveNonInteractiveInitSelection(process.cwd(), flags.target)
38
+ : await resolveGuidedInitSelection(process.cwd(), flags.target);
39
+ const plan = planInitProject({
40
+ target: selection.target,
41
+ cwd: process.cwd(),
42
+ targetStylesEntry: selection.targetStylesEntry,
43
+ });
44
+ if (!hasInitMutations(plan)) {
45
+ printInitNoop(plan, selection);
46
+ return;
47
+ }
48
+ if (!flags.yes) {
49
+ printInitPreview(plan, selection);
50
+ const shouldProceed = await confirmAction('Proceed? (Y/n)');
51
+ if (!shouldProceed) {
52
+ console.log('trine init cancelled. No changes were made.');
53
+ return;
54
+ }
55
+ }
56
+ const result = applyInitPlan(plan, {
57
+ cwd: process.cwd(),
58
+ });
59
+ printInitSuccess(result, selection);
60
+ return;
61
+ }
28
62
  if (command !== 'add') {
29
63
  throw new Error(command ? `Unsupported command: ${command}\n\n${HELP_TEXT}` : HELP_TEXT);
30
64
  }
31
- if (!isSupportedComponent(component)) {
32
- throw new Error(component ? `Unsupported component: ${component}\n\n${HELP_TEXT}` : HELP_TEXT);
65
+ if (!isSupportedComponent(secondArg)) {
66
+ throw new Error(secondArg ? `Unsupported component: ${secondArg}\n\n${HELP_TEXT}` : HELP_TEXT);
33
67
  }
34
- const target = readTarget(rest) ?? autoDetectTarget(process.cwd());
68
+ const flags = parseFlags(rest, ['--target']);
69
+ const selection = resolveTargetSelection(process.cwd(), flags.target, `trine add ${secondArg}`);
35
70
  const result = addButton({
36
- target,
71
+ target: selection.target,
37
72
  cwd: process.cwd(),
38
73
  });
39
- printSuccess('button', result);
74
+ printAddSuccess(secondArg, result, selection);
40
75
  }
41
- function readTarget(argv) {
76
+ function resolveNonInteractiveInitSelection(cwd, explicitTarget) {
77
+ const selection = resolveTargetSelection(cwd, explicitTarget, 'trine init');
78
+ const targetRoot = path.resolve(cwd, selection.target);
79
+ const inspection = inspectProjectTarget(targetRoot);
80
+ if (inspection.framework !== 'unsupported' &&
81
+ inspection.targetStylesEntryCandidates.length > 1 &&
82
+ inspection.targetStylesEntryResolution === 'default') {
83
+ const displayTarget = selection.mode === 'cwd' ? '.' : toDisplayTarget(cwd, targetRoot);
84
+ throw new Error([
85
+ `Multiple plausible global stylesheet entries were found for target (${displayTarget}). Re-run without --yes to choose one interactively:`,
86
+ ...inspection.targetStylesEntryCandidates.map((candidate) => `- ${toDisplayFilePath(targetRoot, candidate)}`),
87
+ ].join('\n'));
88
+ }
89
+ return selection;
90
+ }
91
+ function parseFlags(argv, allowedFlags) {
92
+ const flags = {
93
+ yes: false,
94
+ };
42
95
  for (let index = 0; index < argv.length; index += 1) {
43
- if (argv[index] === '--target') {
44
- return argv[index + 1];
96
+ const token = argv[index];
97
+ if (!allowedFlags.includes(token)) {
98
+ throw new Error(`Unsupported flag: ${token}\n\n${HELP_TEXT}`);
99
+ }
100
+ if (token === '--yes') {
101
+ flags.yes = true;
102
+ continue;
103
+ }
104
+ if (token === '--target') {
105
+ const target = argv[index + 1];
106
+ if (!target || target.startsWith('--')) {
107
+ throw new Error(`--target requires a value.\n\n${HELP_TEXT}`);
108
+ }
109
+ flags.target = target;
110
+ index += 1;
45
111
  }
46
112
  }
47
- return undefined;
113
+ return flags;
48
114
  }
49
- function printSuccess(component, result) {
50
- const relativeTarget = path.relative(process.cwd(), result.targetRoot) || '.';
51
- const displayTarget = relativeTarget.startsWith('..') ? result.targetRoot : relativeTarget;
52
- const componentLabel = capitalize(component);
53
- const isRepoDemoVerification = result.warnings.some((warning) => warning.includes('temporary @trine/ui/* bridge'));
115
+ function resolveTargetSelection(cwd, explicitTarget, commandLabel) {
116
+ if (explicitTarget) {
117
+ return {
118
+ target: explicitTarget,
119
+ mode: 'explicit',
120
+ };
121
+ }
122
+ if (looksLikeAngularAppRoot(cwd)) {
123
+ return {
124
+ target: '.',
125
+ mode: 'cwd',
126
+ };
127
+ }
128
+ const matches = findAngularAppTargets(cwd);
129
+ if (matches.length === 1) {
130
+ return {
131
+ target: matches[0],
132
+ mode: 'auto-detected',
133
+ };
134
+ }
135
+ if (matches.length > 1) {
136
+ throw new Error([
137
+ `Multiple supported Angular app targets were found under the current directory. Re-run with ${commandLabel} --target <app-root>:`,
138
+ ...matches.map((match) => `- ${match}`),
139
+ ].join('\n'));
140
+ }
141
+ return {
142
+ target: '.',
143
+ mode: 'cwd',
144
+ };
145
+ }
146
+ async function resolveGuidedInitSelection(cwd, explicitTarget) {
147
+ const targetSelection = explicitTarget
148
+ ? {
149
+ target: explicitTarget,
150
+ mode: 'explicit',
151
+ }
152
+ : await chooseInitTarget(cwd);
153
+ const targetRoot = path.resolve(cwd, targetSelection.target);
154
+ const inspection = inspectProjectTarget(targetRoot);
155
+ if (inspection.framework === 'unsupported') {
156
+ return targetSelection;
157
+ }
158
+ if (inspection.targetStylesEntryCandidates.length > 1 &&
159
+ inspection.targetStylesEntryResolution === 'default') {
160
+ const targetStylesEntry = await chooseFromList('Multiple plausible global stylesheet entries were found:', inspection.targetStylesEntryCandidates, {
161
+ renderItem: (item, index) => `${String(index + 1)}. ${toDisplayFilePath(targetRoot, item)}`,
162
+ });
163
+ return {
164
+ ...targetSelection,
165
+ targetStylesEntry,
166
+ };
167
+ }
168
+ return targetSelection;
169
+ }
170
+ async function chooseInitTarget(cwd) {
171
+ if (looksLikeAngularAppRoot(cwd)) {
172
+ return {
173
+ target: '.',
174
+ mode: 'cwd',
175
+ };
176
+ }
177
+ const matches = findAngularAppTargets(cwd);
178
+ if (matches.length === 1) {
179
+ return {
180
+ target: matches[0],
181
+ mode: 'auto-detected',
182
+ };
183
+ }
184
+ if (matches.length > 1) {
185
+ const selected = await chooseFromList('Multiple supported apps found:', matches);
186
+ return {
187
+ target: selected,
188
+ mode: 'selected',
189
+ };
190
+ }
191
+ return {
192
+ target: '.',
193
+ mode: 'cwd',
194
+ };
195
+ }
196
+ function printInitPreview(plan, selection) {
197
+ const displayTarget = toDisplayTarget(process.cwd(), plan.targetRoot);
198
+ const stylesheetDisplay = toDisplayFilePath(plan.targetRoot, plan.targetStylesEntry);
199
+ const lines = [
200
+ `Detected target: ${displayTarget}`,
201
+ `Detected framework: ${plan.framework}`,
202
+ `Detected stylesheet entry: ${stylesheetDisplay}`,
203
+ ];
204
+ if (selection.mode === 'auto-detected' || selection.mode === 'selected') {
205
+ lines.push(`Selected app: ${displayTarget}`);
206
+ }
207
+ lines.push('', 'Trine will:', ...renderPlannedInitActions(plan));
208
+ if (plan.warnings.length > 0) {
209
+ lines.push('', 'Warnings:', ...plan.warnings.map((warning) => `- ${warning}`));
210
+ }
211
+ console.log(lines.join('\n'));
212
+ }
213
+ function printInitNoop(plan, selection) {
214
+ const displayTarget = toDisplayTarget(process.cwd(), plan.targetRoot);
215
+ const stylesheetDisplay = toDisplayFilePath(plan.targetRoot, plan.targetStylesEntry);
54
216
  const lines = [
55
- `trine add ${component} completed.`,
217
+ 'trine init found the Trine baseline already in place.',
56
218
  `Target: ${displayTarget}`,
57
- '',
58
- 'Copied files:',
59
- ...result.copiedFiles.map((file) => `- ${file}`),
60
- '',
61
- 'Created or updated:',
62
- ...result.updatedFiles.map((file) => `- ${file}`),
219
+ `Framework: ${plan.framework}`,
220
+ `Resolved stylesheet entry: ${stylesheetDisplay}`,
63
221
  ];
222
+ if (selection.mode === 'auto-detected' || selection.mode === 'selected') {
223
+ lines.push(`Selected app: ${displayTarget}`);
224
+ }
225
+ lines.push('', 'Already in place:', ...renderFileGroup(plan.reusedFiles, '- nothing to report'));
226
+ if (plan.warnings.length > 0) {
227
+ lines.push('', 'Warnings:', ...plan.warnings.map((warning) => `- ${warning}`));
228
+ }
229
+ lines.push('', 'No changes were made.');
230
+ console.log(lines.join('\n'));
231
+ }
232
+ function printInitSuccess(result, selection) {
233
+ const displayTarget = toDisplayTarget(process.cwd(), result.targetRoot);
234
+ const stylesheetDisplay = toDisplayFilePath(result.targetRoot, result.targetStylesEntry);
235
+ const lines = [
236
+ 'trine init completed.',
237
+ `Target: ${displayTarget}`,
238
+ `Framework: ${result.framework}`,
239
+ `Resolved stylesheet entry: ${stylesheetDisplay}`,
240
+ ];
241
+ if (selection.mode === 'auto-detected' || selection.mode === 'selected') {
242
+ lines.push(`Selected app: ${displayTarget}`);
243
+ }
244
+ lines.push('', 'Created:', ...renderFileGroup(result.createdFiles, '- nothing created'), '', 'Reused:', ...renderFileGroup(result.reusedFiles, '- nothing reused'), '', 'Updated:', ...renderFileGroup(result.updatedFiles, '- nothing updated'));
245
+ if (result.warnings.length > 0) {
246
+ lines.push('', 'Warnings:', ...result.warnings.map((warning) => `- ${warning}`));
247
+ }
248
+ lines.push('', 'Manual next steps:', '- Review prerequisite warnings and install anything still missing before building the target app.', '- Run trine add button when you want to deliver the first Trine component into this project.', '- Build the target app and confirm @trine/ui resolves through the local consumer-owned source tree.');
249
+ console.log(lines.join('\n'));
250
+ }
251
+ function printAddSuccess(component, result, selection) {
252
+ const displayTarget = toDisplayTarget(process.cwd(), result.targetRoot);
253
+ const componentLabel = capitalize(component);
254
+ const isRepoDemoVerification = result.warnings.some((warning) => warning.includes('temporary @trine/ui/* bridge'));
255
+ const lines = [`trine add ${component} completed.`, `Target: ${displayTarget}`];
256
+ if (selection.mode === 'auto-detected') {
257
+ lines.push(`Selected app: ${displayTarget}`);
258
+ }
259
+ lines.push('', 'Copied files:', ...renderFileGroup(result.copiedFiles, '- nothing copied'), '', 'Created or updated:', ...renderFileGroup(result.updatedFiles, '- nothing created or updated'));
64
260
  if (result.warnings.length > 0) {
65
261
  lines.push('', 'Warnings:', ...result.warnings.map((warning) => `- ${warning}`));
66
262
  }
67
- lines.push('', 'Manual next steps:', `- Ensure the target repo has Tailwind CSS v4 and class-variance-authority installed before building the delivered ${componentLabel}.`, `- Build the target app and confirm ${componentLabel} imports resolve through the local @trine/ui alias.`, `- Review the copied ${component}.skin.ts and tokens.css if you want local consumer customization.`);
263
+ lines.push('', 'Manual next steps:', `- Ensure the target repo has Tailwind CSS v4 and class-variance-authority installed before building the delivered ${componentLabel}.`, `- Build the target app and confirm ${componentLabel} imports resolve through the local @trine/ui alias.`, `- Review the copied ${component}.skin.ts and tokens.css if you want local consumer customization.`, '- For future setup in this repo, prefer running trine init first so project-level Trine baseline files are already in place.');
68
264
  if (isRepoDemoVerification) {
69
265
  lines.push('- Open /validation-shell and review the CLI delivery proof section for the temporary demo verification path.');
70
266
  }
71
267
  console.log(lines.join('\n'));
72
268
  }
269
+ function renderFileGroup(files, emptyMessage) {
270
+ return files.length > 0 ? files.map((file) => `- ${file}`) : [emptyMessage];
271
+ }
272
+ function renderPlannedInitActions(plan) {
273
+ return [
274
+ ...plan.createdFiles.map((file) => `- create ${file}`),
275
+ ...plan.updatedFiles.map((file) => `- update ${file}`),
276
+ ...plan.reusedFiles.map((file) => `- keep ${file}`),
277
+ ];
278
+ }
279
+ function hasInitMutations(plan) {
280
+ return plan.createdFiles.length > 0 || plan.updatedFiles.length > 0;
281
+ }
73
282
  function isSupportedComponent(value) {
74
283
  return SUPPORTED_COMPONENTS.some((component) => component === value);
75
284
  }
76
285
  function capitalize(value) {
77
286
  return value.charAt(0).toUpperCase() + value.slice(1);
78
287
  }
79
- function autoDetectTarget(cwd) {
80
- if (looksLikeAngularAppRoot(cwd)) {
81
- return '.';
82
- }
83
- const matches = findAngularAppTargets(cwd);
84
- if (matches.length === 1) {
85
- return matches[0];
86
- }
87
- if (matches.length > 1) {
88
- throw new Error([
89
- 'Multiple Angular app targets were found under the current directory. Re-run with --target <app-root>:',
90
- ...matches.map((match) => `- ${match}`),
91
- ].join('\n'));
92
- }
93
- return '.';
288
+ function toDisplayTarget(cwd, targetRoot) {
289
+ const relativeTarget = path.relative(cwd, targetRoot) || '.';
290
+ return relativeTarget.startsWith('..') ? targetRoot : relativeTarget;
94
291
  }
95
- function findAngularAppTargets(root) {
96
- const matches = new Set();
97
- walkForAngularApps(root, root, matches);
98
- return [...matches].sort((left, right) => left.localeCompare(right));
292
+ function toDisplayFilePath(targetRoot, filePath) {
293
+ return path.relative(targetRoot, filePath) || '.';
99
294
  }
100
- function walkForAngularApps(root, currentDir, matches) {
101
- for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
102
- if (entry.isDirectory()) {
103
- if (shouldSkipDirectory(entry.name)) {
104
- continue;
105
- }
106
- walkForAngularApps(root, path.join(currentDir, entry.name), matches);
107
- continue;
108
- }
109
- if (!entry.isFile() || entry.name !== 'tsconfig.app.json') {
110
- continue;
111
- }
112
- const candidateRoot = currentDir;
113
- if (!looksLikeAngularAppRoot(candidateRoot)) {
114
- continue;
115
- }
116
- const relativeRoot = path.relative(root, candidateRoot) || '.';
117
- matches.add(toPosixPath(relativeRoot));
118
- }
119
- }
120
- function shouldSkipDirectory(name) {
121
- return ['.angular', '.git', '.playwright-cli', 'dist', 'node_modules', 'output'].includes(name);
122
- }
123
- function toPosixPath(filePath) {
124
- return filePath.split(path.sep).join(path.posix.sep);
125
- }
126
- try {
127
- main(process.argv.slice(2));
128
- }
129
- catch (error) {
295
+ await main(process.argv.slice(2)).catch((error) => {
130
296
  const message = error instanceof Error ? error.message : String(error);
131
297
  console.error(message);
132
298
  process.exitCode = 1;
133
- }
299
+ });
package/dist/init.js ADDED
@@ -0,0 +1,69 @@
1
+ import path from 'node:path';
2
+ import { ensureSharedStyleBaseline, ensureStylesImport, ensureTsconfigAlias, ensureUiRootBarrel, inspectSharedStyleBaseline, inspectStylesImport, inspectTsconfigAlias, inspectUiRootBarrel, isDemoTarget, readTargetDependencyWarnings, resolveSupportedProjectTarget, toTargetRelativePath, } from "./project.js";
3
+ export function planInitProject(options) {
4
+ const targetRoot = path.resolve(options.cwd, options.target);
5
+ const { framework, targetStylesEntry, targetTsconfig } = resolveSupportedProjectTarget('trine init', targetRoot, {
6
+ preferredStylesEntry: options.targetStylesEntry,
7
+ });
8
+ const createdFiles = [];
9
+ const updatedFiles = [];
10
+ const reusedFiles = [];
11
+ const warnings = [];
12
+ const sharedStylesResult = inspectSharedStyleBaseline(targetRoot, 'Trine components');
13
+ createdFiles.push(...sharedStylesResult.createdFiles);
14
+ reusedFiles.push(...sharedStylesResult.reusedFiles);
15
+ warnings.push(...sharedStylesResult.warnings);
16
+ const uiRootResult = inspectUiRootBarrel(targetRoot);
17
+ if (uiRootResult.created) {
18
+ createdFiles.push(uiRootResult.file);
19
+ }
20
+ else {
21
+ reusedFiles.push(uiRootResult.file);
22
+ }
23
+ const tsconfigDisplayPath = toTargetRelativePath(targetRoot, targetTsconfig);
24
+ if (inspectTsconfigAlias(targetTsconfig, targetRoot, options.cwd).needsUpdate) {
25
+ updatedFiles.push(tsconfigDisplayPath);
26
+ }
27
+ else {
28
+ reusedFiles.push(tsconfigDisplayPath);
29
+ }
30
+ const stylesDisplayPath = toTargetRelativePath(targetRoot, targetStylesEntry);
31
+ const stylesResult = inspectStylesImport(targetStylesEntry);
32
+ if (stylesResult.needsUpdate) {
33
+ updatedFiles.push(stylesDisplayPath);
34
+ }
35
+ else {
36
+ reusedFiles.push(stylesDisplayPath);
37
+ }
38
+ if (stylesResult.authoringImportStillPresent) {
39
+ warnings.push(`${stylesDisplayPath} still imports @trine/ui/styles/trine.css for a broader authoring baseline outside the local Trine consumer setup.`);
40
+ }
41
+ if (isDemoTarget(targetRoot)) {
42
+ warnings.push('apps/demo keeps a temporary @trine/ui/* bridge for non-localized components so the internal demo app can still build while local Trine source is verified.');
43
+ }
44
+ else {
45
+ warnings.push(...readTargetDependencyWarnings(targetRoot, 'Trine components'));
46
+ }
47
+ return {
48
+ targetRoot,
49
+ targetTsconfig,
50
+ framework,
51
+ targetStylesEntry,
52
+ createdFiles,
53
+ updatedFiles,
54
+ reusedFiles,
55
+ warnings,
56
+ };
57
+ }
58
+ export function applyInitPlan(plan, options) {
59
+ ensureSharedStyleBaseline(plan.targetRoot, 'Trine components');
60
+ ensureUiRootBarrel(plan.targetRoot);
61
+ ensureTsconfigAlias(plan.targetTsconfig, plan.targetRoot, options.cwd);
62
+ ensureStylesImport(plan.targetStylesEntry);
63
+ return plan;
64
+ }
65
+ export function initProject(options) {
66
+ return applyInitPlan(planInitProject(options), {
67
+ cwd: options.cwd,
68
+ });
69
+ }
@@ -0,0 +1,454 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import ts from 'typescript';
5
+ const STYLE_SOURCE_FILES = ['styles/tokens.css', 'styles/trine-consumer.css'];
6
+ const STYLE_IMPORT_LINE = "@import './styles/trine-consumer.css';";
7
+ const CONVENTIONAL_STYLE_ENTRY_PATHS = ['src/styles.scss', 'src/styles.css', 'src/global.scss'];
8
+ export const TEMPLATE_ROOT = fileURLToPath(new URL('../templates/', import.meta.url));
9
+ const LOCAL_REPO_ROOT = path.resolve(TEMPLATE_ROOT, '../../..');
10
+ export const LOCAL_REPO_DEMO_ROOT = path.resolve(TEMPLATE_ROOT, '../../../apps/demo');
11
+ export function inspectProjectTarget(targetRoot, options = {}) {
12
+ const targetAppDir = path.join(targetRoot, 'src', 'app');
13
+ const targetTsconfig = path.join(targetRoot, 'tsconfig.app.json');
14
+ const targetStylesEntryCandidates = resolveStylesEntryCandidates(targetRoot);
15
+ const { targetStylesEntry, targetStylesEntryResolution } = resolveStylesEntrySelection(targetRoot, targetStylesEntryCandidates, options.preferredStylesEntry);
16
+ const missingPaths = [];
17
+ if (!existsSync(targetRoot)) {
18
+ missingPaths.push(targetRoot);
19
+ }
20
+ if (!existsSync(targetAppDir)) {
21
+ missingPaths.push(targetAppDir);
22
+ }
23
+ if (!targetStylesEntry) {
24
+ missingPaths.push(`${path.join(targetRoot, 'src', 'styles.scss')} or ${path.join(targetRoot, 'src', 'styles.css')} or ${path.join(targetRoot, 'src', 'global.scss')} or the first resolvable build styles entry in ${findNearestAngularJsonDisplayPath(targetRoot)}`);
25
+ }
26
+ if (!existsSync(targetTsconfig)) {
27
+ missingPaths.push(targetTsconfig);
28
+ }
29
+ const framework = missingPaths.length === 0 ? detectProjectFramework(targetRoot) : 'unsupported';
30
+ return {
31
+ targetRoot,
32
+ targetAppDir,
33
+ targetTsconfig,
34
+ targetStylesEntryCandidates,
35
+ targetStylesEntry,
36
+ targetStylesEntryResolution,
37
+ framework,
38
+ missingPaths,
39
+ };
40
+ }
41
+ export function resolveSupportedProjectTarget(commandLabel, targetRoot, options = {}) {
42
+ const inspection = inspectProjectTarget(targetRoot, options);
43
+ if (inspection.framework === 'unsupported' || !inspection.targetStylesEntry) {
44
+ throw new Error([
45
+ `${commandLabel} requires a supported Angular or Ionic Angular app target with src/app, a global stylesheet entry, and tsconfig.app.json.`,
46
+ ...inspection.missingPaths.map((file) => `- ${file}`),
47
+ ].join('\n'));
48
+ }
49
+ return {
50
+ targetRoot: inspection.targetRoot,
51
+ targetAppDir: inspection.targetAppDir,
52
+ targetTsconfig: inspection.targetTsconfig,
53
+ targetStylesEntry: inspection.targetStylesEntry,
54
+ framework: inspection.framework,
55
+ };
56
+ }
57
+ export function findAngularAppTargets(root) {
58
+ const matches = new Set();
59
+ walkForAngularApps(root, root, matches);
60
+ return [...matches].sort((left, right) => left.localeCompare(right));
61
+ }
62
+ export function looksLikeAngularAppRoot(root) {
63
+ return inspectProjectTarget(root).framework !== 'unsupported';
64
+ }
65
+ export function ensureSharedStyleBaseline(targetRoot, componentLabel) {
66
+ const inspection = inspectSharedStyleBaseline(targetRoot, componentLabel);
67
+ const stylesDestDir = path.join(targetRoot, 'src', 'styles');
68
+ mkdirSync(stylesDestDir, { recursive: true });
69
+ for (const sourceFile of STYLE_SOURCE_FILES) {
70
+ const templatePath = path.join(TEMPLATE_ROOT, sourceFile);
71
+ const destinationPath = path.join(stylesDestDir, path.basename(sourceFile));
72
+ if (!inspection.createdFiles.includes(toTargetRelativePath(targetRoot, destinationPath))) {
73
+ continue;
74
+ }
75
+ if (!existsSync(destinationPath)) {
76
+ copyFileSync(templatePath, destinationPath);
77
+ }
78
+ }
79
+ return inspection;
80
+ }
81
+ export function inspectSharedStyleBaseline(targetRoot, componentLabel) {
82
+ const stylesDestDir = path.join(targetRoot, 'src', 'styles');
83
+ const createdFiles = [];
84
+ const reusedFiles = [];
85
+ const warnings = [];
86
+ for (const sourceFile of STYLE_SOURCE_FILES) {
87
+ const templatePath = path.join(TEMPLATE_ROOT, sourceFile);
88
+ const destinationPath = path.join(stylesDestDir, path.basename(sourceFile));
89
+ const relativeDestination = toTargetRelativePath(targetRoot, destinationPath);
90
+ if (!existsSync(destinationPath)) {
91
+ createdFiles.push(relativeDestination);
92
+ continue;
93
+ }
94
+ if (readFileSync(destinationPath, 'utf8') !== readFileSync(templatePath, 'utf8')) {
95
+ warnings.push(`${relativeDestination} already exists and was preserved. Review it manually if the delivered ${componentLabel} expects newer shared styling baseline content.`);
96
+ continue;
97
+ }
98
+ reusedFiles.push(relativeDestination);
99
+ }
100
+ return {
101
+ createdFiles,
102
+ reusedFiles,
103
+ warnings,
104
+ };
105
+ }
106
+ export function ensureUiRootBarrel(targetRoot) {
107
+ const uiRootPath = path.join(targetRoot, 'src', 'app', 'components', 'ui', 'index.ts');
108
+ const inspection = inspectUiRootBarrel(targetRoot);
109
+ if (!inspection.created) {
110
+ return inspection;
111
+ }
112
+ mkdirSync(path.dirname(uiRootPath), { recursive: true });
113
+ writeFileSync(uiRootPath, '\n');
114
+ return inspection;
115
+ }
116
+ export function inspectUiRootBarrel(targetRoot) {
117
+ const uiRootPath = path.join(targetRoot, 'src', 'app', 'components', 'ui', 'index.ts');
118
+ return {
119
+ created: !existsSync(uiRootPath),
120
+ file: toTargetRelativePath(targetRoot, uiRootPath),
121
+ };
122
+ }
123
+ export function ensureLinesFile(filePath, lines) {
124
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
125
+ const normalizedExisting = existing.trimEnd();
126
+ const currentLines = normalizedExisting === '' ? [] : normalizedExisting.split('\n');
127
+ let changed = false;
128
+ for (const line of lines) {
129
+ if (!currentLines.includes(line)) {
130
+ currentLines.push(line);
131
+ changed = true;
132
+ }
133
+ }
134
+ if (!existsSync(filePath)) {
135
+ changed = true;
136
+ }
137
+ if (changed) {
138
+ mkdirSync(path.dirname(filePath), { recursive: true });
139
+ writeFileSync(filePath, `${currentLines.join('\n')}\n`);
140
+ }
141
+ return changed;
142
+ }
143
+ export function ensureTsconfigAlias(tsconfigPath, targetRoot, cwd) {
144
+ const inspection = inspectTsconfigAlias(tsconfigPath, targetRoot, cwd);
145
+ if (!inspection.needsUpdate) {
146
+ return false;
147
+ }
148
+ const currentText = readFileSync(tsconfigPath, 'utf8');
149
+ const parsed = ts.parseConfigFileTextToJson(tsconfigPath, currentText);
150
+ if (parsed.error) {
151
+ throw new Error(`Unable to parse ${path.relative(cwd, tsconfigPath)} as JSONC.`);
152
+ }
153
+ const config = (parsed.config ?? {});
154
+ config.compilerOptions ??= {};
155
+ config.compilerOptions.paths ??= {};
156
+ const { aliasTarget, wildcardTarget } = buildAliasTargets(tsconfigPath, targetRoot);
157
+ config.compilerOptions.paths['@trine/ui'] = [aliasTarget];
158
+ if (isDemoTarget(targetRoot)) {
159
+ config.compilerOptions.paths['@trine/ui/*'] = [wildcardTarget];
160
+ }
161
+ else if ('@trine/ui/*' in config.compilerOptions.paths) {
162
+ delete config.compilerOptions.paths['@trine/ui/*'];
163
+ }
164
+ writeFileSync(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`);
165
+ return true;
166
+ }
167
+ export function inspectTsconfigAlias(tsconfigPath, targetRoot, cwd) {
168
+ const currentText = readFileSync(tsconfigPath, 'utf8');
169
+ const parsed = ts.parseConfigFileTextToJson(tsconfigPath, currentText);
170
+ if (parsed.error) {
171
+ throw new Error(`Unable to parse ${path.relative(cwd, tsconfigPath)} as JSONC.`);
172
+ }
173
+ const config = (parsed.config ?? {});
174
+ config.compilerOptions ??= {};
175
+ config.compilerOptions.paths ??= {};
176
+ const { aliasTarget, wildcardTarget } = buildAliasTargets(tsconfigPath, targetRoot);
177
+ const currentAlias = config.compilerOptions.paths['@trine/ui'];
178
+ const currentWildcardAlias = config.compilerOptions.paths['@trine/ui/*'];
179
+ const aliasIsCurrent = Array.isArray(currentAlias) && currentAlias.length === 1 && currentAlias[0] === aliasTarget;
180
+ const wildcardIsCurrent = !isDemoTarget(targetRoot) ||
181
+ (Array.isArray(currentWildcardAlias) &&
182
+ currentWildcardAlias.length === 1 &&
183
+ currentWildcardAlias[0] === wildcardTarget);
184
+ if (aliasIsCurrent && wildcardIsCurrent) {
185
+ return {
186
+ needsUpdate: false,
187
+ };
188
+ }
189
+ return {
190
+ needsUpdate: true,
191
+ };
192
+ }
193
+ export function ensureStylesImport(stylesPath) {
194
+ const inspection = inspectStylesImport(stylesPath);
195
+ if (!inspection.needsUpdate) {
196
+ return {
197
+ updated: false,
198
+ authoringImportStillPresent: inspection.authoringImportStillPresent,
199
+ };
200
+ }
201
+ const current = readFileSync(stylesPath, 'utf8');
202
+ const lines = current.split('\n');
203
+ let insertAt = -1;
204
+ for (let index = 0; index < lines.length; index += 1) {
205
+ const trimmed = lines[index].trim();
206
+ if (trimmed.startsWith('@use') || trimmed.startsWith('@import')) {
207
+ insertAt = index;
208
+ }
209
+ }
210
+ if (insertAt === -1) {
211
+ lines.unshift(STYLE_IMPORT_LINE, '');
212
+ }
213
+ else {
214
+ lines.splice(insertAt + 1, 0, STYLE_IMPORT_LINE);
215
+ }
216
+ writeFileSync(stylesPath, lines.join('\n'));
217
+ return {
218
+ updated: true,
219
+ authoringImportStillPresent: inspection.authoringImportStillPresent,
220
+ };
221
+ }
222
+ export function inspectStylesImport(stylesPath) {
223
+ const current = readFileSync(stylesPath, 'utf8');
224
+ return {
225
+ needsUpdate: !current.includes(STYLE_IMPORT_LINE),
226
+ authoringImportStillPresent: current.includes("@import '@trine/ui/styles/trine.css';"),
227
+ };
228
+ }
229
+ export function readTargetDependencyWarnings(targetRoot, componentLabel) {
230
+ const packageJsonPath = findNearestFileUpward(targetRoot, 'package.json');
231
+ if (!packageJsonPath) {
232
+ return [
233
+ 'No package.json was found at or above the target root, so Tailwind CSS v4 and class-variance-authority prerequisites could not be checked automatically.',
234
+ ];
235
+ }
236
+ try {
237
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
238
+ const deps = {
239
+ ...(packageJson.dependencies ?? {}),
240
+ ...(packageJson.devDependencies ?? {}),
241
+ };
242
+ const warnings = [];
243
+ if (!deps['class-variance-authority']) {
244
+ warnings.push(`class-variance-authority is missing from the target repo. Install it before building the delivered ${componentLabel}.`);
245
+ }
246
+ const tailwindRange = deps['tailwindcss'];
247
+ if (!tailwindRange) {
248
+ warnings.push(`tailwindcss is missing from the target repo. The current proven ${componentLabel} baseline expects Tailwind CSS v4.`);
249
+ }
250
+ else if (!looksLikeTailwindV4(tailwindRange)) {
251
+ warnings.push(`The target repo declares tailwindcss@${tailwindRange}. The current proven ${componentLabel} baseline expects Tailwind CSS v4.`);
252
+ }
253
+ return warnings;
254
+ }
255
+ catch {
256
+ return [
257
+ `package.json could not be parsed for dependency checks. Verify Tailwind CSS v4 and class-variance-authority manually before building the delivered ${componentLabel}.`,
258
+ ];
259
+ }
260
+ }
261
+ export function resolveStylesEntry(targetRoot) {
262
+ return inspectProjectTarget(targetRoot).targetStylesEntry;
263
+ }
264
+ export function resolveStylesEntryCandidates(targetRoot) {
265
+ const candidates = new Set();
266
+ for (const relativePath of CONVENTIONAL_STYLE_ENTRY_PATHS) {
267
+ const absolutePath = path.join(targetRoot, relativePath);
268
+ if (existsSync(absolutePath)) {
269
+ candidates.add(absolutePath);
270
+ }
271
+ }
272
+ const angularJsonPaths = findFilesUpward(targetRoot, 'angular.json');
273
+ for (const angularJsonPath of angularJsonPaths) {
274
+ for (const resolvedFromWorkspace of resolveStylesEntriesFromAngularWorkspace(angularJsonPath, targetRoot)) {
275
+ candidates.add(resolvedFromWorkspace);
276
+ }
277
+ }
278
+ return [...candidates];
279
+ }
280
+ export function isDemoTarget(targetRoot) {
281
+ return (existsSync(LOCAL_REPO_DEMO_ROOT) &&
282
+ path.resolve(targetRoot) === path.resolve(LOCAL_REPO_DEMO_ROOT));
283
+ }
284
+ export function toPosixPath(filePath) {
285
+ return filePath.split(path.sep).join(path.posix.sep);
286
+ }
287
+ export function toConfigRelativePath(filePath) {
288
+ const posixPath = toPosixPath(filePath);
289
+ return posixPath.startsWith('.') ? posixPath : `./${posixPath}`;
290
+ }
291
+ export function toTargetRelativePath(targetRoot, filePath) {
292
+ return toPosixPath(path.relative(targetRoot, filePath));
293
+ }
294
+ function walkForAngularApps(root, currentDir, matches) {
295
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
296
+ if (entry.isDirectory()) {
297
+ if (shouldSkipDirectory(entry.name)) {
298
+ continue;
299
+ }
300
+ walkForAngularApps(root, path.join(currentDir, entry.name), matches);
301
+ continue;
302
+ }
303
+ if (!entry.isFile() || entry.name !== 'tsconfig.app.json') {
304
+ continue;
305
+ }
306
+ const candidateRoot = currentDir;
307
+ if (!looksLikeAngularAppRoot(candidateRoot)) {
308
+ continue;
309
+ }
310
+ const relativeRoot = path.relative(root, candidateRoot) || '.';
311
+ matches.add(toPosixPath(relativeRoot));
312
+ }
313
+ }
314
+ function shouldSkipDirectory(name) {
315
+ return ['.angular', '.git', '.playwright-cli', 'dist', 'node_modules', 'output'].includes(name);
316
+ }
317
+ function detectProjectFramework(targetRoot) {
318
+ if (findNearestFileUpward(targetRoot, 'ionic.config.json')) {
319
+ return 'ionic-angular';
320
+ }
321
+ const packageJsonPath = findNearestFileUpward(targetRoot, 'package.json');
322
+ if (packageJsonPath && packageJsonUsesIonicAngular(packageJsonPath)) {
323
+ return 'ionic-angular';
324
+ }
325
+ return 'angular';
326
+ }
327
+ function packageJsonUsesIonicAngular(packageJsonPath) {
328
+ try {
329
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
330
+ return Boolean(packageJson.dependencies?.['@ionic/angular'] ??
331
+ packageJson.devDependencies?.['@ionic/angular']);
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
337
+ function resolveStylesEntriesFromAngularWorkspace(angularJsonPath, targetRoot) {
338
+ try {
339
+ const angularJson = JSON.parse(readFileSync(angularJsonPath, 'utf8'));
340
+ const workspaceRoot = path.dirname(angularJsonPath);
341
+ const projects = Object.values(angularJson.projects ?? {});
342
+ const matchingProjects = projects.filter((project) => projectMatchesTargetRoot(project, workspaceRoot, targetRoot));
343
+ const candidateProjects = matchingProjects.length > 0
344
+ ? matchingProjects
345
+ : path.resolve(targetRoot) === path.resolve(workspaceRoot)
346
+ ? projects
347
+ : [];
348
+ const matches = [];
349
+ for (const project of candidateProjects) {
350
+ const styles = project.architect?.build?.options?.styles ?? project.targets?.build?.options?.styles ?? [];
351
+ for (const style of styles) {
352
+ const input = typeof style === 'string' ? style : style.input;
353
+ if (!input) {
354
+ continue;
355
+ }
356
+ const absolutePath = path.resolve(workspaceRoot, input);
357
+ if (existsSync(absolutePath)) {
358
+ matches.push(absolutePath);
359
+ }
360
+ }
361
+ }
362
+ return matches;
363
+ }
364
+ catch {
365
+ return [];
366
+ }
367
+ }
368
+ function projectMatchesTargetRoot(project, workspaceRoot, targetRoot) {
369
+ const resolvedTargetRoot = path.resolve(targetRoot);
370
+ const candidateRoots = new Set([path.resolve(workspaceRoot, project.root ?? '.')]);
371
+ if (project.sourceRoot) {
372
+ candidateRoots.add(path.resolve(workspaceRoot, path.dirname(project.sourceRoot)));
373
+ }
374
+ for (const candidateRoot of candidateRoots) {
375
+ if (path.resolve(candidateRoot) === resolvedTargetRoot) {
376
+ return true;
377
+ }
378
+ }
379
+ return false;
380
+ }
381
+ function findNearestAngularJsonDisplayPath(targetRoot) {
382
+ const nearestAngularJsonPath = findNearestFileUpward(targetRoot, 'angular.json');
383
+ return nearestAngularJsonPath ? nearestAngularJsonPath : path.join(targetRoot, 'angular.json');
384
+ }
385
+ function findFilesUpward(startDir, fileName) {
386
+ const matches = [];
387
+ let currentDir = path.resolve(startDir);
388
+ let reachedRoot = false;
389
+ while (!reachedRoot) {
390
+ const candidatePath = path.join(currentDir, fileName);
391
+ if (existsSync(candidatePath)) {
392
+ matches.push(candidatePath);
393
+ }
394
+ const parentDir = path.dirname(currentDir);
395
+ if (parentDir === currentDir) {
396
+ reachedRoot = true;
397
+ continue;
398
+ }
399
+ currentDir = parentDir;
400
+ }
401
+ return matches;
402
+ }
403
+ function findNearestFileUpward(startDir, fileName) {
404
+ return findFilesUpward(startDir, fileName)[0];
405
+ }
406
+ function looksLikeTailwindV4(range) {
407
+ const versionTokenPattern = /(?:^|[<>=~^|\s:])v?(\d+)(?:(?:\.\d+){0,2})/g;
408
+ return [...range.matchAll(versionTokenPattern)].some((match) => Number(match[1]) === 4);
409
+ }
410
+ function buildAliasTargets(tsconfigPath, targetRoot) {
411
+ const aliasTarget = isDemoTarget(targetRoot)
412
+ ? toPosixPath(path.relative(LOCAL_REPO_ROOT, path.join(targetRoot, 'src', 'app', 'components', 'ui', 'index.ts')))
413
+ : toConfigRelativePath(path.relative(path.dirname(tsconfigPath), path.join(targetRoot, 'src', 'app', 'components', 'ui', 'index.ts')));
414
+ const wildcardTarget = 'packages/ui/*';
415
+ return {
416
+ aliasTarget,
417
+ wildcardTarget,
418
+ };
419
+ }
420
+ function resolveStylesEntrySelection(targetRoot, candidates, preferredStylesEntry) {
421
+ if (preferredStylesEntry) {
422
+ const preferredAbsolutePath = path.resolve(targetRoot, preferredStylesEntry);
423
+ if (candidates.includes(preferredAbsolutePath)) {
424
+ return {
425
+ targetStylesEntry: preferredAbsolutePath,
426
+ targetStylesEntryResolution: 'preferred',
427
+ };
428
+ }
429
+ }
430
+ const trineOwnedCandidates = candidates.filter((candidate) => {
431
+ try {
432
+ return readFileSync(candidate, 'utf8').includes(STYLE_IMPORT_LINE);
433
+ }
434
+ catch {
435
+ return false;
436
+ }
437
+ });
438
+ if (trineOwnedCandidates.length === 1) {
439
+ return {
440
+ targetStylesEntry: trineOwnedCandidates[0],
441
+ targetStylesEntryResolution: 'trine-import',
442
+ };
443
+ }
444
+ if (candidates.length > 0) {
445
+ return {
446
+ targetStylesEntry: candidates[0],
447
+ targetStylesEntryResolution: 'default',
448
+ };
449
+ }
450
+ return {
451
+ targetStylesEntry: undefined,
452
+ targetStylesEntryResolution: 'none',
453
+ };
454
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,59 @@
1
+ import process from 'node:process';
2
+ import { createInterface } from 'node:readline/promises';
3
+ export async function chooseFromList(message, items, options = {}) {
4
+ assertInteractiveTerminal();
5
+ if (items.length === 0) {
6
+ throw new Error('Cannot choose from an empty list.');
7
+ }
8
+ if (items.length === 1) {
9
+ return items[0];
10
+ }
11
+ const renderItem = options.renderItem ?? ((item, index) => `${String(index + 1)}. ${item}`);
12
+ console.log(message);
13
+ for (const [index, item] of items.entries()) {
14
+ console.log(renderItem(item, index));
15
+ }
16
+ const prompt = createPrompt();
17
+ try {
18
+ for (;;) {
19
+ const answer = (await prompt.question(`Choose an option [1-${String(items.length)}]: `)).trim();
20
+ const selectedIndex = Number(answer);
21
+ if (Number.isInteger(selectedIndex) && selectedIndex >= 1 && selectedIndex <= items.length) {
22
+ return items[selectedIndex - 1];
23
+ }
24
+ console.log(`Enter a number between 1 and ${String(items.length)}.`);
25
+ }
26
+ }
27
+ finally {
28
+ prompt.close();
29
+ }
30
+ }
31
+ export async function confirmAction(message) {
32
+ assertInteractiveTerminal();
33
+ const prompt = createPrompt();
34
+ try {
35
+ const answer = (await prompt.question(`${message} `)).trim().toLowerCase();
36
+ if (answer === '' || answer === 'y' || answer === 'yes') {
37
+ return true;
38
+ }
39
+ if (answer === 'n' || answer === 'no') {
40
+ return false;
41
+ }
42
+ console.log('Enter Y, yes, N, or no.');
43
+ return await confirmAction(message);
44
+ }
45
+ finally {
46
+ prompt.close();
47
+ }
48
+ }
49
+ export function assertInteractiveTerminal() {
50
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
51
+ throw new Error('trine init default mode is guided and needs an interactive terminal. Re-run with --yes for non-interactive mode.');
52
+ }
53
+ }
54
+ function createPrompt() {
55
+ return createInterface({
56
+ input: process.stdin,
57
+ output: process.stdout,
58
+ });
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trineui/cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Copy-paste ownership CLI for Trine UI components.",
6
6
  "main": "./dist/index.js",