@shokirovr16/frontend-library 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/cmfrt.js +69 -0
- package/package.json +47 -0
- package/src/auth/README.md +193 -0
- package/src/auth/core/AuthEngine.js +623 -0
- package/src/auth/core/OidcClient.js +79 -0
- package/src/auth/core/OidcDiscovery.js +17 -0
- package/src/auth/core/Pkce.js +18 -0
- package/src/auth/events/AuthEventBus.js +22 -0
- package/src/auth/http/authFetch.js +32 -0
- package/src/auth/http/createAuthHttpClient.js +42 -0
- package/src/auth/index.js +90 -0
- package/src/auth/permissions/ClaimsNormalizer.js +69 -0
- package/src/auth/permissions/permissions.js +26 -0
- package/src/auth/react/AuthProvider.js +34 -0
- package/src/auth/react/guards/RequireAuth.js +35 -0
- package/src/auth/react/guards/RequirePermission.js +16 -0
- package/src/auth/react/guards/withAuthGuard.js +12 -0
- package/src/auth/react/hooks/useRequireAuth.js +24 -0
- package/src/auth/react/index.js +6 -0
- package/src/auth/react/useAuth.js +29 -0
- package/src/auth/silent/silentCallback.js +42 -0
- package/src/auth/singleton.js +22 -0
- package/src/auth/storage/InMemoryTokenStore.js +56 -0
- package/src/auth/storage/TransactionStore.js +51 -0
- package/src/auth/sync/BroadcastChannelSync.js +29 -0
- package/src/auth/tenancy/TenantResolver.js +39 -0
- package/src/auth/types.js +113 -0
- package/src/auth/utils/base64url.js +15 -0
- package/src/auth/utils/jwt.js +26 -0
- package/src/auth/utils/random.js +13 -0
- package/src/auth/utils/url.js +27 -0
- package/src/commands/add.js +80 -0
- package/src/commands/init.js +113 -0
- package/src/commands/list.js +92 -0
- package/src/commands/remove.js +150 -0
- package/src/commands/status.js +96 -0
- package/src/commands/theme.js +47 -0
- package/src/commands/uninstall.js +198 -0
- package/src/commands/update.js +151 -0
- package/src/lib/config.js +55 -0
- package/src/lib/fs.js +13 -0
- package/src/lib/packageManager.js +30 -0
- package/src/lib/paths.js +14 -0
- package/src/lib/registry.js +11 -0
- package/src/lib/styles.js +223 -0
- package/src/lib/targets.js +15 -0
- package/src/lib/theme.js +102 -0
- package/templates/docs/cmfrt-doc.md +82 -0
- package/templates/lib/utils.js +6 -0
- package/templates/registry.json +42 -0
- package/templates/styles/theme.cjs +832 -0
- package/templates/styles/type-utilities.css +136 -0
- package/templates/styles/type-utility-classes.css +138 -0
- package/templates/styles/variables.css +1560 -0
- package/templates/styles/variables.json +6870 -0
- package/templates/ui/button.jsx +117 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} Tenant
|
|
3
|
+
* @property {string} tenantId
|
|
4
|
+
* @property {string} [tenantSlug]
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} TenantResolver
|
|
9
|
+
* @property {()=>Promise<Tenant>|Tenant} resolve
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default resolver (SPA): try `?tenant=...`, then first path segment, then subdomain.
|
|
14
|
+
* This is a heuristic; production apps usually provide a dedicated runtime-config endpoint instead.
|
|
15
|
+
*/
|
|
16
|
+
export function createDefaultTenantResolver({ param = 'tenant', pathIndex = 0 } = {}) {
|
|
17
|
+
return {
|
|
18
|
+
resolve() {
|
|
19
|
+
if (typeof window === 'undefined') return { tenantId: 'default' };
|
|
20
|
+
const url = new URL(window.location.href);
|
|
21
|
+
const fromParam = url.searchParams.get(param);
|
|
22
|
+
if (fromParam) return { tenantId: fromParam, tenantSlug: fromParam };
|
|
23
|
+
|
|
24
|
+
const parts = window.location.pathname.split('/').filter(Boolean);
|
|
25
|
+
const fromPath = parts[pathIndex];
|
|
26
|
+
if (fromPath) return { tenantId: fromPath, tenantSlug: fromPath };
|
|
27
|
+
|
|
28
|
+
const host = window.location.hostname;
|
|
29
|
+
const hostParts = host.split('.').filter(Boolean);
|
|
30
|
+
if (hostParts.length > 2) {
|
|
31
|
+
const sub = hostParts[0];
|
|
32
|
+
if (sub && sub !== 'www') return { tenantId: sub, tenantSlug: sub };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { tenantId: 'default' };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file documents the runtime types for the auth module (JS implementation).
|
|
3
|
+
* It exists to make the public API discoverable without requiring TS build tooling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {'spa-direct'|'bff'} AuthMode
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {'disabled'|'onLoad'|'onDemand'} CheckSsoPolicy
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {'memory'} TokenStoreType
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} TokenPolicy
|
|
20
|
+
* @property {number} [minValiditySec] Default min validity for ensureFreshToken (seconds)
|
|
21
|
+
* @property {number} [refreshLeewaySec] Leeway applied when computing expiry (seconds)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} LogoutPolicy
|
|
26
|
+
* @property {boolean} [frontChannel] When true, redirects to OIDC end_session_endpoint (recommended for Keycloak)
|
|
27
|
+
* @property {boolean} [broadcast] When true, broadcasts logout to same-origin tabs
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} ClaimsPolicy
|
|
32
|
+
* @property {string[]} [roleClaimPaths] Dot/bracket paths to role arrays (e.g. ['realm_access.roles','resource_access[{clientId}].roles'])
|
|
33
|
+
* @property {string[]} [permissionClaimPaths] Dot/bracket paths to permission arrays
|
|
34
|
+
* @property {Record<string,string>} [profileClaimPaths] Map from normalized key -> claim path (e.g. { email: 'email', username: 'preferred_username' })
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {Object} AuthRuntimeConfig
|
|
39
|
+
* @property {string} tenantId
|
|
40
|
+
* @property {string} [tenantSlug]
|
|
41
|
+
* @property {AuthMode} [mode]
|
|
42
|
+
* @property {string} issuer OIDC issuer, for Keycloak usually: https://<host>/realms/<realm>
|
|
43
|
+
* @property {string} clientId
|
|
44
|
+
* @property {string[]} [scopes]
|
|
45
|
+
* @property {string} redirectUri
|
|
46
|
+
* @property {string} [postLogoutRedirectUri]
|
|
47
|
+
* @property {string} [silentCheckSsoRedirectUri] Required for check-sso via hidden iframe (same-origin is recommended)
|
|
48
|
+
* @property {CheckSsoPolicy} [checkSsoPolicy]
|
|
49
|
+
* @property {TokenPolicy} [tokenPolicy]
|
|
50
|
+
* @property {LogoutPolicy} [logoutPolicy]
|
|
51
|
+
* @property {ClaimsPolicy} [claimsPolicy]
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} Tenant
|
|
56
|
+
* @property {string} tenantId
|
|
57
|
+
* @property {string} [tenantSlug]
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {Object} InitializeAuthOptions
|
|
62
|
+
* @property {AuthRuntimeConfig|(()=>Promise<AuthRuntimeConfig>)} runtimeConfig Runtime config object or async loader
|
|
63
|
+
* @property {import('./tenancy/TenantResolver.js').TenantResolver} [tenantResolver]
|
|
64
|
+
* @property {(cfg: AuthRuntimeConfig)=>AuthRuntimeConfig} [validateAndNormalizeConfig]
|
|
65
|
+
* @property {string} [broadcastChannelName]
|
|
66
|
+
* @property {(event: any)=>void} [onEvent]
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {Object} AuthUser
|
|
71
|
+
* @property {string} [sub]
|
|
72
|
+
* @property {string} [name]
|
|
73
|
+
* @property {string} [email]
|
|
74
|
+
* @property {string} [username]
|
|
75
|
+
* @property {string[]} roles
|
|
76
|
+
* @property {string[]} permissions
|
|
77
|
+
* @property {Record<string, any>} claims
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @typedef {Object} AuthSnapshot
|
|
82
|
+
* @property {string} status
|
|
83
|
+
* @property {boolean} isAuthenticated
|
|
84
|
+
* @property {string|null} tenantId
|
|
85
|
+
* @property {AuthUser|null} user
|
|
86
|
+
* @property {string|null} error
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {Object} AuthEngine
|
|
91
|
+
* @property {()=>AuthSnapshot} getSnapshot
|
|
92
|
+
* @property {(listener:(snapshot:AuthSnapshot)=>void)=>()=>void} subscribe
|
|
93
|
+
* @property {(listener:(event:any)=>void)=>()=>void} subscribeAuthEvents
|
|
94
|
+
* @property {(params?:{returnTo?:string,prompt?:string})=>Promise<never>} login
|
|
95
|
+
* @property {(params?:{postLogoutRedirectUri?:string})=>Promise<never|void>} logout
|
|
96
|
+
* @property {(params?:{url?:string})=>Promise<{returnTo?:string}>} handleCallback
|
|
97
|
+
* @property {(event:MessageEvent)=>Promise<void>} handleSilentCallbackMessage
|
|
98
|
+
* @property {(minValiditySec?:number)=>Promise<void>} ensureFreshToken
|
|
99
|
+
* @property {()=>Promise<void>} refresh
|
|
100
|
+
* @property {()=>string|null} getAccessToken
|
|
101
|
+
* @property {()=>string|null} getIdToken
|
|
102
|
+
* @property {()=>boolean} isAuthenticated
|
|
103
|
+
* @property {()=>AuthUser|null} getUser
|
|
104
|
+
* @property {(reason?:string)=>void} clearSession
|
|
105
|
+
* @property {(tenant:Tenant)=>Promise<void>} setTenant
|
|
106
|
+
* @property {(tenant:Tenant, opts?:{checkSso?:boolean})=>Promise<void>} switchTenant
|
|
107
|
+
* @property {(role:string)=>boolean} hasRole
|
|
108
|
+
* @property {(roles:string[])=>boolean} hasAnyRole
|
|
109
|
+
* @property {(roles:string[])=>boolean} hasAllRoles
|
|
110
|
+
* @property {(permission:string)=>boolean} hasPermission
|
|
111
|
+
* @property {(permission:string)=>boolean} can
|
|
112
|
+
*/
|
|
113
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function base64UrlToUint8Array(value) {
|
|
2
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
|
|
3
|
+
const raw = atob(base64);
|
|
4
|
+
const out = new Uint8Array(raw.length);
|
|
5
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
6
|
+
return out;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function uint8ArrayToBase64Url(bytes) {
|
|
10
|
+
let binary = '';
|
|
11
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
12
|
+
const base64 = btoa(binary);
|
|
13
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { base64UrlToUint8Array } from './base64url.js';
|
|
2
|
+
|
|
3
|
+
export function decodeJwt(token) {
|
|
4
|
+
if (!token) return null;
|
|
5
|
+
const parts = token.split('.');
|
|
6
|
+
if (parts.length < 2) return null;
|
|
7
|
+
try {
|
|
8
|
+
const json = new TextDecoder().decode(base64UrlToUint8Array(parts[1]));
|
|
9
|
+
return JSON.parse(json);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getJwtExp(token) {
|
|
16
|
+
const payload = decodeJwt(token);
|
|
17
|
+
const exp = payload?.exp;
|
|
18
|
+
return typeof exp === 'number' ? exp : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isJwtExpired(token, nowSec = Math.floor(Date.now() / 1000), leewaySec = 0) {
|
|
22
|
+
const exp = getJwtExp(token);
|
|
23
|
+
if (!exp) return true;
|
|
24
|
+
return exp <= nowSec + leewaySec;
|
|
25
|
+
}
|
|
26
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function randomString(length = 32) {
|
|
2
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
3
|
+
const bytes = new Uint8Array(length);
|
|
4
|
+
const cryptoObj = globalThis.crypto;
|
|
5
|
+
if (!cryptoObj?.getRandomValues) {
|
|
6
|
+
throw new Error('[cmfrt/auth] crypto.getRandomValues is required (browser secure context).');
|
|
7
|
+
}
|
|
8
|
+
cryptoObj.getRandomValues(bytes);
|
|
9
|
+
let out = '';
|
|
10
|
+
for (let i = 0; i < bytes.length; i++) out += alphabet[bytes[i] % alphabet.length];
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function toAbsoluteUrl(maybeRelativeUrl) {
|
|
2
|
+
if (!maybeRelativeUrl) return null;
|
|
3
|
+
try {
|
|
4
|
+
return new URL(maybeRelativeUrl, globalThis.location?.origin).toString();
|
|
5
|
+
} catch {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getCurrentUrl() {
|
|
11
|
+
if (typeof window === 'undefined') return null;
|
|
12
|
+
return window.location.href;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stripQueryParams(url, keysToStrip) {
|
|
16
|
+
const u = new URL(url);
|
|
17
|
+
for (const key of keysToStrip) u.searchParams.delete(key);
|
|
18
|
+
return u.toString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseUrlParams(url) {
|
|
22
|
+
const u = new URL(url);
|
|
23
|
+
const params = {};
|
|
24
|
+
for (const [k, v] of u.searchParams.entries()) params[k] = v;
|
|
25
|
+
return params;
|
|
26
|
+
}
|
|
27
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { copyFile, ensureDirectory, pathExists } from '../lib/fs.js';
|
|
4
|
+
import { templatePath } from '../lib/paths.js';
|
|
5
|
+
import { readRegistry } from '../lib/registry.js';
|
|
6
|
+
import { loadConfig, writeConfig } from '../lib/config.js';
|
|
7
|
+
import { installDependencies } from '../lib/packageManager.js';
|
|
8
|
+
import { resolveTargetRoot } from '../lib/targets.js';
|
|
9
|
+
import { ensureStylesInstalled } from '../lib/styles.js';
|
|
10
|
+
|
|
11
|
+
async function resolveTargetPath(targetPath) {
|
|
12
|
+
if (!(await pathExists(targetPath))) {
|
|
13
|
+
return targetPath;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { dir, name, ext } = path.parse(targetPath);
|
|
17
|
+
let counter = 0;
|
|
18
|
+
|
|
19
|
+
while (true) {
|
|
20
|
+
const suffix = counter === 0 ? '.new' : `.new${counter}`;
|
|
21
|
+
const candidate = path.join(dir, `${name}${suffix}${ext}`);
|
|
22
|
+
if (!(await pathExists(candidate))) {
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
counter += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { green, yellow, red } = pc;
|
|
30
|
+
|
|
31
|
+
export default async function addCommand(componentName) {
|
|
32
|
+
const config = await loadConfig();
|
|
33
|
+
const registry = await readRegistry();
|
|
34
|
+
const component = registry[componentName];
|
|
35
|
+
|
|
36
|
+
if (!component) {
|
|
37
|
+
const message = `No component named "${componentName}" is registered.`;
|
|
38
|
+
console.error(red(message));
|
|
39
|
+
throw new Error(message);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const componentsRoot = resolveTargetRoot(component, config);
|
|
43
|
+
|
|
44
|
+
await ensureDirectory(componentsRoot);
|
|
45
|
+
|
|
46
|
+
const installedFiles = [];
|
|
47
|
+
|
|
48
|
+
for (const file of component.files ?? []) {
|
|
49
|
+
const source = templatePath(file.source);
|
|
50
|
+
const destination = path.resolve(componentsRoot, file.target);
|
|
51
|
+
await ensureDirectory(path.dirname(destination));
|
|
52
|
+
|
|
53
|
+
const finalTarget = await resolveTargetPath(destination);
|
|
54
|
+
if (finalTarget !== destination) {
|
|
55
|
+
console.log(yellow(`Target already exists; writing to ${path.basename(finalTarget)} instead.`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await copyFile(source, finalTarget, { overwrite: false });
|
|
59
|
+
installedFiles.push(path.relative(componentsRoot, finalTarget));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await installDependencies(component.dependencies ?? []);
|
|
63
|
+
|
|
64
|
+
let updated = {
|
|
65
|
+
...config,
|
|
66
|
+
installed: {
|
|
67
|
+
...config.installed,
|
|
68
|
+
[componentName]: {
|
|
69
|
+
version: component.version ?? '0.0.0',
|
|
70
|
+
files: installedFiles,
|
|
71
|
+
installedAt: new Date().toISOString(),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const stylesResult = await ensureStylesInstalled(registry, updated);
|
|
76
|
+
updated = stylesResult.config;
|
|
77
|
+
await writeConfig(updated);
|
|
78
|
+
|
|
79
|
+
console.log(green(`${componentName} is ready under src/components/ui.`));
|
|
80
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
|
|
5
|
+
import { configExists, createDefaultConfig, writeConfig } from '../lib/config.js';
|
|
6
|
+
import { copyFile, ensureDirectory, pathExists, readJson } from '../lib/fs.js';
|
|
7
|
+
import { componentsDir, libDir, projectRoot, templatePath } from '../lib/paths.js';
|
|
8
|
+
import { applyThemeMode, getAvailableModes, resolveMode } from '../lib/theme.js';
|
|
9
|
+
import { readRegistry } from '../lib/registry.js';
|
|
10
|
+
import { ensureStylesInstalled } from '../lib/styles.js';
|
|
11
|
+
|
|
12
|
+
const TAILWIND_PACKAGE = 'tailwindcss';
|
|
13
|
+
const { green, red, yellow } = pc;
|
|
14
|
+
|
|
15
|
+
const TAILWIND_CONFIG_CANDIDATES = [
|
|
16
|
+
'tailwind.config.js',
|
|
17
|
+
'tailwind.config.cjs',
|
|
18
|
+
'tailwind.config.mjs',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const detectTailwindConfig = async () => {
|
|
22
|
+
for (const filename of TAILWIND_CONFIG_CANDIDATES) {
|
|
23
|
+
const configPath = path.resolve(process.cwd(), filename);
|
|
24
|
+
if (await pathExists(configPath)) {
|
|
25
|
+
return configPath;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const printManualTailwindInstructions = () => {
|
|
32
|
+
console.log(yellow('Tailwind config not found. Please add tailwind.config.js/.cjs/.mjs manually.'));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default async function initCommand() {
|
|
36
|
+
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
|
37
|
+
if (!(await pathExists(packageJsonPath))) {
|
|
38
|
+
throw new Error('package.json not found in the current directory.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const packageJson = await readJson(packageJsonPath);
|
|
42
|
+
const hasTailwind = Boolean(
|
|
43
|
+
packageJson.dependencies?.[TAILWIND_PACKAGE] || packageJson.devDependencies?.[TAILWIND_PACKAGE]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!hasTailwind) {
|
|
47
|
+
const message = 'Tailwind CSS is missing from package.json. Install tailwindcss before running cmfrt init.';
|
|
48
|
+
console.error(red(message));
|
|
49
|
+
throw new Error(message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await ensureDirectory(componentsDir());
|
|
53
|
+
await ensureDirectory(libDir());
|
|
54
|
+
|
|
55
|
+
const utilsTarget = path.resolve(libDir(), 'utils.js');
|
|
56
|
+
if (await pathExists(utilsTarget)) {
|
|
57
|
+
console.log(yellow('src/lib/utils.js already exists, skipping template copy.'));
|
|
58
|
+
} else {
|
|
59
|
+
await copyFile(templatePath('lib', 'utils.js'), utilsTarget);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const docTarget = path.resolve(projectRoot(), 'cmfrt-doc.md');
|
|
63
|
+
if (await pathExists(docTarget)) {
|
|
64
|
+
console.log(yellow('cmfrt-doc.md already exists, skipping doc copy.'));
|
|
65
|
+
} else {
|
|
66
|
+
await copyFile(templatePath('docs', 'cmfrt-doc.md'), docTarget);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (await configExists()) {
|
|
70
|
+
console.log(yellow('cmfrt.json already exists, reusing configuration.'));
|
|
71
|
+
} else {
|
|
72
|
+
await writeConfig(createDefaultConfig());
|
|
73
|
+
console.log(green('Created cmfrt.json and default configuration.'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const registry = await readRegistry();
|
|
78
|
+
const currentConfig = await readJson(path.resolve(projectRoot(), 'cmfrt.json'));
|
|
79
|
+
const result = await ensureStylesInstalled(registry, currentConfig);
|
|
80
|
+
if (result.updated) {
|
|
81
|
+
await writeConfig(result.config);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.log(yellow('Failed to install styles during init.'));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const modes = await getAvailableModes({ stylesPath: 'src/styles' });
|
|
88
|
+
if (modes.length > 0) {
|
|
89
|
+
const response = await prompts({
|
|
90
|
+
type: 'select',
|
|
91
|
+
name: 'mode',
|
|
92
|
+
message: 'Select a theme mode',
|
|
93
|
+
choices: modes.map((mode) => ({ title: mode.name, value: mode.safe })),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const selected = resolveMode(modes, response.mode);
|
|
97
|
+
if (selected) {
|
|
98
|
+
const result = await applyThemeMode(selected.safe);
|
|
99
|
+
if (result.updated) {
|
|
100
|
+
const current = await readJson(path.resolve(projectRoot(), 'cmfrt.json'));
|
|
101
|
+
await writeConfig({ ...current, themeMode: selected.name });
|
|
102
|
+
console.log(green(`Theme mode set: ${selected.name}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tailwindConfigPath = await detectTailwindConfig();
|
|
108
|
+
if (!tailwindConfigPath) {
|
|
109
|
+
printManualTailwindInstructions();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(green('Comfort CLI scaffold created under src/components/ui.'));
|
|
113
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { readRegistry } from '../lib/registry.js';
|
|
3
|
+
|
|
4
|
+
const { cyan, green, dim, red } = pc;
|
|
5
|
+
|
|
6
|
+
function normalizeCategory(category) {
|
|
7
|
+
const raw = String(category ?? '').trim();
|
|
8
|
+
return raw.length > 0 ? raw : 'uncategorized';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeDescription(description) {
|
|
12
|
+
return String(description ?? '').trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function matchesSearch(text, query) {
|
|
16
|
+
if (!query) return true;
|
|
17
|
+
return text.toLowerCase().includes(query.toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function listCommand(options = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const registry = await readRegistry();
|
|
23
|
+
const entries = Object.entries(registry ?? {});
|
|
24
|
+
|
|
25
|
+
let items = entries.map(([name, entry]) => {
|
|
26
|
+
const category = normalizeCategory(entry?.category);
|
|
27
|
+
const description = normalizeDescription(entry?.description);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
version: entry?.version ?? '0.0.0',
|
|
32
|
+
category,
|
|
33
|
+
description,
|
|
34
|
+
filesCount: Array.isArray(entry?.files) ? entry.files.length : 0,
|
|
35
|
+
dependenciesCount: Array.isArray(entry?.dependencies) ? entry.dependencies.length : 0,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (options.category) {
|
|
40
|
+
const categoryFilter = String(options.category).toLowerCase();
|
|
41
|
+
items = items.filter((item) => item.category.toLowerCase() === categoryFilter);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (options.search) {
|
|
45
|
+
const search = String(options.search).toLowerCase();
|
|
46
|
+
items = items.filter(
|
|
47
|
+
(item) =>
|
|
48
|
+
matchesSearch(item.name, search) || matchesSearch(item.description, search)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
items.sort((a, b) => {
|
|
53
|
+
const categoryCompare = a.category.localeCompare(b.category);
|
|
54
|
+
if (categoryCompare !== 0) return categoryCompare;
|
|
55
|
+
return a.name.localeCompare(b.name);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const categories = Array.from(new Set(items.map((item) => item.category)));
|
|
59
|
+
|
|
60
|
+
if (options.json) {
|
|
61
|
+
const payload = {
|
|
62
|
+
count: items.length,
|
|
63
|
+
categories,
|
|
64
|
+
items: items.map(({ name, version, category, description }) => ({
|
|
65
|
+
name,
|
|
66
|
+
version,
|
|
67
|
+
category,
|
|
68
|
+
description,
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let currentCategory = null;
|
|
76
|
+
for (const item of items) {
|
|
77
|
+
if (item.category !== currentCategory) {
|
|
78
|
+
currentCategory = item.category;
|
|
79
|
+
console.log(cyan(currentCategory));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const versionLabel = dim(`(${item.version})`);
|
|
83
|
+
const description = item.description ? ` ${item.description}` : '';
|
|
84
|
+
console.log(` - ${green(item.name)} ${versionLabel}${description}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(`Summary: ${items.length} components across ${categories.length} categories`);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error(red(error?.message ?? 'Failed to load registry.'));
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import { execa } from 'execa';
|
|
5
|
+
import { configExists, loadConfig, writeConfig } from '../lib/config.js';
|
|
6
|
+
import { readRegistry } from '../lib/registry.js';
|
|
7
|
+
import { pathExists, removePath } from '../lib/fs.js';
|
|
8
|
+
import { componentsDir, projectRoot } from '../lib/paths.js';
|
|
9
|
+
import { resolveTargetRoot } from '../lib/targets.js';
|
|
10
|
+
|
|
11
|
+
const { green, yellow, red, cyan } = pc;
|
|
12
|
+
|
|
13
|
+
async function confirmRemoval(name) {
|
|
14
|
+
const response = await prompts({
|
|
15
|
+
type: 'confirm',
|
|
16
|
+
name: 'confirm',
|
|
17
|
+
message: `Are you sure you want to remove ${name}?`,
|
|
18
|
+
initial: false,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return Boolean(response.confirm);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveComponentsRoot(config) {
|
|
25
|
+
return config.componentsPath
|
|
26
|
+
? path.resolve(projectRoot(), config.componentsPath)
|
|
27
|
+
: componentsDir();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function shouldSkipUtils(targetPath, config) {
|
|
31
|
+
if (!config.utilsPath) return false;
|
|
32
|
+
const utilsPath = path.resolve(projectRoot(), config.utilsPath);
|
|
33
|
+
return path.resolve(targetPath) === utilsPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default async function removeCommand(componentName, options = {}) {
|
|
37
|
+
if (!(await configExists())) {
|
|
38
|
+
console.error(red('cmfrt.json is missing. Run cmfrt init first.'));
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!componentName) {
|
|
44
|
+
console.error(red('Please provide a component name.'));
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const config = await loadConfig();
|
|
50
|
+
const installedEntry = config.installed?.[componentName];
|
|
51
|
+
|
|
52
|
+
if (!installedEntry) {
|
|
53
|
+
console.log(yellow(`Component "${componentName}" is not installed.`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!options.yes) {
|
|
58
|
+
const confirmed = await confirmRemoval(componentName);
|
|
59
|
+
if (!confirmed) {
|
|
60
|
+
console.log(yellow('Aborted.'));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let registry = null;
|
|
66
|
+
try {
|
|
67
|
+
registry = await readRegistry();
|
|
68
|
+
} catch {
|
|
69
|
+
registry = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const registryEntry = registry?.[componentName];
|
|
73
|
+
const componentsRoot = registryEntry
|
|
74
|
+
? resolveTargetRoot(registryEntry, config)
|
|
75
|
+
: resolveComponentsRoot(config);
|
|
76
|
+
console.log(`Removing: ${componentName}`);
|
|
77
|
+
|
|
78
|
+
for (const file of installedEntry.files ?? []) {
|
|
79
|
+
const destination = path.resolve(componentsRoot, file);
|
|
80
|
+
|
|
81
|
+
if (shouldSkipUtils(destination, config)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (await pathExists(destination)) {
|
|
86
|
+
await removePath(destination);
|
|
87
|
+
const relativePath = path.relative(projectRoot(), destination) || destination;
|
|
88
|
+
console.log(`Deleted: ${relativePath}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const updatedInstalled = { ...(config.installed ?? {}) };
|
|
93
|
+
delete updatedInstalled[componentName];
|
|
94
|
+
|
|
95
|
+
const updatedConfig = {
|
|
96
|
+
...config,
|
|
97
|
+
installed: updatedInstalled,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
await writeConfig(updatedConfig);
|
|
101
|
+
|
|
102
|
+
if (options.prune) {
|
|
103
|
+
if (!registry) {
|
|
104
|
+
console.error(red('Failed to load registry for pruning dependencies.'));
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
console.log(green('Component removed successfully.'));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const entry = registry?.[componentName];
|
|
111
|
+
if (!entry) {
|
|
112
|
+
console.log(yellow(`Registry entry for "${componentName}" not found. Skipping prune.`));
|
|
113
|
+
} else {
|
|
114
|
+
const dependencies = Array.isArray(entry.dependencies) ? entry.dependencies : [];
|
|
115
|
+
const otherInstalled = Object.keys(updatedInstalled);
|
|
116
|
+
|
|
117
|
+
for (const dependency of dependencies) {
|
|
118
|
+
let usedElsewhere = false;
|
|
119
|
+
|
|
120
|
+
for (const otherName of otherInstalled) {
|
|
121
|
+
const otherEntry = registry?.[otherName];
|
|
122
|
+
if (!otherEntry) {
|
|
123
|
+
usedElsewhere = true;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
const otherDeps = Array.isArray(otherEntry.dependencies) ? otherEntry.dependencies : [];
|
|
127
|
+
if (otherDeps.includes(dependency)) {
|
|
128
|
+
usedElsewhere = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (usedElsewhere) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
console.log(cyan(`Uninstalling: ${dependency}`));
|
|
139
|
+
await execa('npm', ['uninstall', dependency]);
|
|
140
|
+
console.log(`Uninstalled: ${dependency}`);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(red(`Failed to uninstall ${dependency}.`));
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log(green('Component removed successfully.'));
|
|
150
|
+
}
|