diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index abbacb9..c46c129 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,13 @@ import tab from '@bomb.sh/tab/citty' import { add } from '@vuetify/cli-shared/commands/add' +import { diff } from '@vuetify/cli-shared/commands/diff' +import { generate } from '@vuetify/cli-shared/commands/generate' +import { list } from '@vuetify/cli-shared/commands/list' import { mcp } from '@vuetify/cli-shared/commands/mcp' import { createPresetsCommand } from '@vuetify/cli-shared/commands/presets' +import { refresh } from '@vuetify/cli-shared/commands/refresh' +import { registry } from '@vuetify/cli-shared/commands/registry' +import { status } from '@vuetify/cli-shared/commands/status' import { registerProjectArgsCompletion } from '@vuetify/cli-shared/completion' import { i18n } from '@vuetify/cli-shared/i18n' import { createBanner } from '@vuetify/cli-shared/utils/banner' @@ -26,6 +32,12 @@ export const main = defineCommand({ init, presets, add, + generate, + diff, + refresh, + registry, + list, + status, mcp, update, docs, diff --git a/packages/shared/src/commands/add.ts b/packages/shared/src/commands/add.ts index b24ad76..e7b1147 100644 --- a/packages/shared/src/commands/add.ts +++ b/packages/shared/src/commands/add.ts @@ -1,9 +1,17 @@ -import { log, select } from '@clack/prompts' +import { intro, log, outro, select, spinner } from '@clack/prompts' import { defineCommand, runCommand } from 'citty' -import { addEslint } from '../functions' +import { addEslint, addFeature } from '../functions' +import { loadInventory, parseFeatureRef, resolveRegistryUrl } from '../functions/inventory' +import { getIndex } from '../functions/registry' +import { groupedRegistryOptions } from '../functions/registry-options' import { i18n } from '../i18n' import { mcp } from './mcp' +/** + * Integrations wire a tool into the project. Everything else typed after `add` + * is looked up in the registry, so `vuetify add dialog` writes a working, + * styled example instead of leaving the user to copy it out of the docs. + */ const choices = ['eslint'] export const add = defineCommand({ @@ -14,11 +22,35 @@ export const add = defineCommand({ args: { integration: { type: 'positional', + required: false, description: i18n.t('commands.add.integration.description', { choices: choices.join(', ') }), }, + example: { + type: 'string', + description: i18n.t('commands.add.args.example'), + }, + dir: { + type: 'string', + description: i18n.t('commands.add.args.dir'), + }, + registry: { + type: 'string', + description: i18n.t('commands.add.args.registry'), + }, + overwrite: { + type: 'boolean', + default: false, + description: i18n.t('commands.add.args.overwrite'), + }, + yes: { + type: 'boolean', + default: false, + description: i18n.t('commands.add.args.yes'), + }, }, run: async ({ args }) => { let integration = args.integration + const inventory = await loadInventory() if (integration === 'mcp') { log.warning('The "vuetify add mcp" command is deprecated. Redirecting to "vuetify mcp install"...') @@ -26,26 +58,64 @@ export const add = defineCommand({ return } + // With no argument, offer integrations and registry items in one list so + // neither surface is hidden behind knowing its name up front. if (!integration) { + const origin = resolveRegistryUrl(inventory, args.registry) + const loader = spinner() + loader.start(i18n.t('spinners.registry.fetching')) + const index = await getIndex(origin) + loader.stop(i18n.t('spinners.registry.fetched')) + const selected = await select({ - message: i18n.t('prompts.add.integration'), - options: choices.map(c => ({ label: c, value: c })), + message: i18n.t('prompts.add.feature'), + options: [ + { label: '── Integrations', value: '__group:Integrations', disabled: true }, + ...choices.map(choice => ({ label: choice, value: choice, hint: 'integration' })), + ...groupedRegistryOptions(index.items), + ], }) + if (typeof selected === 'symbol') { log.warning(i18n.t('commands.add.integration.available', { choices: choices.join(', ') })) return } + + if (String(selected).startsWith('__group:')) { + log.warning(i18n.t('commands.add.integration.available', { choices: choices.join(', ') })) + return + } + integration = String(selected) } - if (!choices.includes(integration)) { - log.error(i18n.t('commands.add.integration.invalid', { integration, choices: choices.join(', ') })) + + if (choices.includes(integration)) { + switch (integration) { + case 'eslint': { + await addEslint() + break + } + } return } - switch (integration) { - case 'eslint': { - await addEslint() - break - } + + const ref = parseFeatureRef(integration) + const registry = resolveRegistryUrl(inventory, args.registry ?? ref.registry) + + intro(i18n.t('commands.add.intro', { name: ref.name })) + + const written = await addFeature({ + name: ref.name, + example: args.example, + dir: args.dir, + registry, + overwrite: args.overwrite, + yes: args.yes, + }) + + // A failed resolution has already said why — don't follow it with "all done". + if (written.length > 0) { + outro(i18n.t('messages.all_done')) } }, }) diff --git a/packages/shared/src/commands/diff.ts b/packages/shared/src/commands/diff.ts new file mode 100644 index 0000000..131552c --- /dev/null +++ b/packages/shared/src/commands/diff.ts @@ -0,0 +1,49 @@ +import { intro, log, outro } from '@clack/prompts' +import { defineCommand } from 'citty' +import { dim, green, red, yellow } from 'kolorist' +import { diffComponent } from '../functions/diff' +import { i18n } from '../i18n' + +const mark = { + same: () => green('='), + changed: () => yellow('~'), + 'missing-local': () => red('!'), + 'only-remote': () => red('+'), + 'only-local': () => dim('·'), +} as const + +export const diff = defineCommand({ + meta: { + name: 'diff', + description: i18n.t('commands.diff.description'), + }, + args: { + name: { + type: 'positional', + required: true, + description: i18n.t('commands.diff.args.name'), + }, + registry: { + type: 'string', + description: i18n.t('commands.add.args.registry'), + }, + }, + run: async ({ args }) => { + intro(i18n.t('commands.diff.intro', { name: args.name })) + try { + const result = await diffComponent(String(args.name), { registry: args.registry }) + if (result.origin) { + log.message(dim(result.origin)) + } + for (const line of result.lines) { + log.message(`${mark[line.status]()} ${line.file} ${dim(line.status)}`) + } + const dirty = result.lines.some(l => l.status !== 'same' && l.status !== 'only-local') + outro(i18n.t('commands.diff.done')) + if (dirty) process.exitCode = 1 + } catch (error) { + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }, +}) diff --git a/packages/shared/src/commands/generate.ts b/packages/shared/src/commands/generate.ts new file mode 100644 index 0000000..04e7644 --- /dev/null +++ b/packages/shared/src/commands/generate.ts @@ -0,0 +1,44 @@ +import { intro, log, outro } from '@clack/prompts' +import { defineCommand } from 'citty' +import { underline } from 'kolorist' +import { generateComponent } from '../functions/generate' +import { i18n } from '../i18n' + +export const generate = defineCommand({ + meta: { + name: 'generate', + description: i18n.t('commands.generate.description'), + }, + args: { + name: { + type: 'positional', + required: true, + description: i18n.t('commands.generate.args.name'), + }, + dir: { + type: 'string', + description: i18n.t('commands.generate.args.dir'), + }, + overwrite: { + type: 'boolean', + default: false, + description: i18n.t('commands.generate.args.overwrite'), + }, + }, + run: async ({ args }) => { + intro(i18n.t('commands.generate.intro', { name: args.name })) + try { + const result = await generateComponent({ + name: String(args.name), + dir: args.dir, + overwrite: args.overwrite, + }) + log.success(i18n.t('commands.generate.wrote', { path: underline(result.path) })) + log.message(i18n.t('commands.add.inventory', { file: 'vuetify.json' })) + outro(i18n.t('messages.all_done')) + } catch (error) { + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }, +}) diff --git a/packages/shared/src/commands/index.ts b/packages/shared/src/commands/index.ts index 62c2439..475f8d5 100644 --- a/packages/shared/src/commands/index.ts +++ b/packages/shared/src/commands/index.ts @@ -1,7 +1,13 @@ export * from './add' +export * from './diff' export * from './docs' +export * from './generate' +export * from './list' export * from './mcp' export * from './presets' +export * from './refresh' +export * from './registry' export * from './releaseNotes' +export * from './status' export * from './update' export * from './upgrade' diff --git a/packages/shared/src/commands/list.ts b/packages/shared/src/commands/list.ts new file mode 100644 index 0000000..9fe22d9 --- /dev/null +++ b/packages/shared/src/commands/list.ts @@ -0,0 +1,58 @@ +import { intro, log, outro } from '@clack/prompts' +import { defineCommand } from 'citty' +import { dim, green, yellow } from 'kolorist' +import { componentStatus, loadInventory } from '../functions/inventory' +import { i18n } from '../i18n' + +/** + * Print the local component inventory (`vuetify.json`). + * + * Tracking surface for phase 1 of the component-library lifecycle — what was + * seeded via `vuetify add`, and where it lives on disk. + */ +export const list = defineCommand({ + meta: { + name: 'list', + description: i18n.t('commands.list.description'), + }, + args: { + json: { + type: 'boolean', + default: false, + description: i18n.t('commands.list.args.json'), + }, + }, + run: async ({ args }) => { + const inventory = await loadInventory() + const names = Object.keys(inventory.components).toSorted() + + if (args.json) { + console.log(JSON.stringify(inventory, null, 2)) + return + } + + intro(i18n.t('commands.list.intro')) + + if (names.length === 0) { + log.info(i18n.t('commands.list.empty')) + outro(i18n.t('commands.list.hint')) + return + } + + for (const name of names) { + const component = inventory.components[name]! + const { ok, missing } = componentStatus(component) + const origin = component.origin + ? dim(` ← ${component.origin.name}/${component.origin.example}`) + : dim(` ← ${i18n.t('commands.list.local')}`) + const mark = ok ? green('✓') : yellow('!') + const label = component.title || name + log.message(`${mark} ${label} ${dim(component.path)}${origin}`) + if (!ok) { + log.warn(i18n.t('commands.list.missing', { name, files: missing.join(', ') })) + } + } + + outro(i18n.t('commands.list.count', { count: names.length })) + }, +}) diff --git a/packages/shared/src/commands/refresh.ts b/packages/shared/src/commands/refresh.ts new file mode 100644 index 0000000..3f428d3 --- /dev/null +++ b/packages/shared/src/commands/refresh.ts @@ -0,0 +1,45 @@ +import { intro, log, outro } from '@clack/prompts' +import { defineCommand } from 'citty' +import { refreshComponent } from '../functions/refresh' +import { i18n } from '../i18n' + +export const refresh = defineCommand({ + meta: { + name: 'refresh', + description: i18n.t('commands.refresh.description'), + }, + args: { + name: { + type: 'positional', + required: true, + description: i18n.t('commands.refresh.args.name'), + }, + registry: { + type: 'string', + description: i18n.t('commands.add.args.registry'), + }, + yes: { + type: 'boolean', + default: true, + description: i18n.t('commands.add.args.yes'), + }, + }, + run: async ({ args }) => { + intro(i18n.t('commands.refresh.intro', { name: args.name })) + try { + const written = await refreshComponent({ + name: String(args.name), + registry: args.registry, + yes: args.yes, + overwrite: true, + }) + if (written.length === 0) { + log.warn(i18n.t('commands.refresh.noop')) + } + outro(i18n.t('messages.all_done')) + } catch (error) { + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }, +}) diff --git a/packages/shared/src/commands/registry.ts b/packages/shared/src/commands/registry.ts new file mode 100644 index 0000000..8ec438a --- /dev/null +++ b/packages/shared/src/commands/registry.ts @@ -0,0 +1,42 @@ +import { intro, log, outro } from '@clack/prompts' +import { defineCommand } from 'citty' +import { underline } from 'kolorist' +import { buildLocalRegistry } from '../functions/registry-build' +import { i18n } from '../i18n' + +export const registry = defineCommand({ + meta: { + name: 'registry', + description: i18n.t('commands.registry.description'), + }, + subCommands: { + build: defineCommand({ + meta: { + name: 'build', + description: i18n.t('commands.registry.build.description'), + }, + args: { + outDir: { + type: 'string', + default: 'registry', + description: i18n.t('commands.registry.build.args.outDir'), + }, + }, + run: async ({ args }) => { + intro(i18n.t('commands.registry.build.intro')) + try { + const result = await buildLocalRegistry({ outDir: args.outDir }) + log.success(i18n.t('commands.registry.build.done', { + count: result.count, + dir: underline(result.outDir), + })) + log.message(i18n.t('commands.registry.build.hint', { dir: result.outDir })) + outro(i18n.t('messages.all_done')) + } catch (error) { + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }, + }), + }, +}) diff --git a/packages/shared/src/commands/status.ts b/packages/shared/src/commands/status.ts new file mode 100644 index 0000000..864b358 --- /dev/null +++ b/packages/shared/src/commands/status.ts @@ -0,0 +1,79 @@ +import { intro, log, outro } from '@clack/prompts' +import { defineCommand } from 'citty' +import { existsSync } from 'node:fs' +import { dim, green, red, yellow } from 'kolorist' +import { + componentStatus, + inventoryPath, + loadInventory, +} from '../functions/inventory' +import { i18n } from '../i18n' + +/** + * Health check for the local component inventory. + * + * Reports missing files and whether each entry has upstream origin metadata + * (needed later for `diff` / `update`). + */ +export const status = defineCommand({ + meta: { + name: 'status', + description: i18n.t('commands.status.description'), + }, + run: async () => { + intro(i18n.t('commands.status.intro')) + + const path = inventoryPath() + if (!existsSync(path)) { + log.warn(i18n.t('commands.status.noInventory')) + outro(i18n.t('commands.list.hint')) + return + } + + const inventory = await loadInventory() + const names = Object.keys(inventory.components).toSorted() + + if (names.length === 0) { + log.info(i18n.t('commands.list.empty')) + outro(i18n.t('commands.list.hint')) + return + } + + let healthy = 0 + let broken = 0 + let localOnly = 0 + + for (const name of names) { + const component = inventory.components[name]! + const { ok, missing } = componentStatus(component) + + if (!ok) { + broken++ + log.error(`${red('✗')} ${name} — ${i18n.t('commands.status.missingFiles', { files: missing.join(', ') })}`) + continue + } + + if (!component.origin) { + localOnly++ + log.message(`${yellow('·')} ${name} ${dim(i18n.t('commands.status.noOrigin'))}`) + continue + } + + healthy++ + log.message( + `${green('✓')} ${name} ${dim(`${component.origin.registry} · ${component.origin.example}`)}`, + ) + } + + outro(i18n.t('commands.status.summary', { + healthy, + broken, + local: localOnly, + total: names.length, + })) + + if (broken > 0) { + process.exitCode = 1 + } + }, +}) diff --git a/packages/shared/src/constants/registry.ts b/packages/shared/src/constants/registry.ts new file mode 100644 index 0000000..c3c9819 --- /dev/null +++ b/packages/shared/src/constants/registry.ts @@ -0,0 +1,30 @@ +/** Static origin publishing the `vuetify add` registry. */ +export const REGISTRY_ORIGIN = 'https://0.vuetifyjs.com' + +/** Registry payload version this CLI understands. */ +export const REGISTRY_VERSION = 1 + +export const V0 = '@vuetify/v0' + +/** + * Factory installed in app code — used when scanning for an existing setup. + * User-facing copy names the docs surface instead (`THEME_PLUGIN_LABEL`). + */ +export const THEME_PLUGIN = 'createThemePlugin' + +/** Docs / registry name users recognize. */ +export const THEME_PLUGIN_LABEL = 'useTheme' + +/** Registry item / CLI argument for `vuetify add`. */ +export const THEME_PLUGIN_COMMAND = 'use-theme' + +export const REGISTRY_TIMEOUT = 15_000 + +export const UNOCSS_CONFIGS = [ + 'uno.config.ts', + 'uno.config.js', + 'uno.config.mjs', + 'unocss.config.ts', + 'unocss.config.js', + 'unocss.config.mjs', +] diff --git a/packages/shared/src/functions/diff.ts b/packages/shared/src/functions/diff.ts new file mode 100644 index 0000000..5154ae6 --- /dev/null +++ b/packages/shared/src/functions/diff.ts @@ -0,0 +1,96 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'pathe' +import { loadInventory, resolveRegistryUrl } from './inventory' +import { getIndex, getItem, match } from './registry' +import type { RegistryExample, RegistryItem } from './registry' + +export interface DiffLine { + file: string + /** `only-remote` = present upstream, absent on disk (would be added by refresh). */ + status: 'same' | 'changed' | 'missing-local' | 'only-remote' | 'only-local' +} + +export interface DiffResult { + name: string + origin?: string + lines: DiffLine[] +} + +function remoteExample ( + item: RegistryItem, + exampleId: string, +): RegistryExample | undefined { + const exact = item.examples.find(e => e.id === exampleId) + if (exact) return exact + // Unknown id — do not silently compare against a different example. + if (exampleId && exampleId !== 'install') return undefined + return item.examples[0] +} + +/** + * Compare an inventory entry's on-disk files to its registry origin example. + */ +export async function diffComponent ( + name: string, + options: { cwd?: string, registry?: string } = {}, +): Promise { + const cwd = options.cwd ?? process.cwd() + const inventory = await loadInventory(cwd) + const local = inventory.components[name] + if (!local) { + throw new Error(`"${name}" is not in vuetify.json — nothing to diff`) + } + + if (!local.origin || local.origin.example === 'install') { + return { + name, + lines: local.files.map(file => ({ file, status: 'only-local' as const })), + } + } + + const origin = resolveRegistryUrl(inventory, options.registry ?? local.origin.registry) + + const index = await getIndex(origin) + const entry = match(index, local.origin.name)[0] + ?? index.items.find(i => i.name === local.origin!.name) + if (!entry) { + throw new Error(`Registry at ${origin} has no item "${local.origin.name}"`) + } + + const item = await getItem(entry, origin) + const example = remoteExample(item, local.origin.example) + if (!example) { + throw new Error(`No example "${local.origin.example}" on ${local.origin.name}`) + } + + const remoteByName = new Map(example.files.map(f => [f.name, f.content])) + const lines: DiffLine[] = [] + const seen = new Set() + + for (const file of local.files) { + seen.add(file) + const abs = join(cwd, local.path, file) + let localContent: string | null = null + try { + localContent = await readFile(abs, 'utf8') + } catch { + lines.push({ file, status: 'missing-local' }) + continue + } + const remote = remoteByName.get(file) + if (remote === undefined) { + lines.push({ file, status: 'only-local' }) + } else if (remote === localContent) { + lines.push({ file, status: 'same' }) + } else { + lines.push({ file, status: 'changed' }) + } + } + + for (const file of example.files) { + if (seen.has(file.name)) continue + lines.push({ file: file.name, status: 'only-remote' }) + } + + return { name, origin, lines } +} diff --git a/packages/shared/src/functions/feature.ts b/packages/shared/src/functions/feature.ts new file mode 100644 index 0000000..6b3127e --- /dev/null +++ b/packages/shared/src/functions/feature.ts @@ -0,0 +1,495 @@ +// Types +import type { RegistryExample, RegistryIndex, RegistryIndexEntry, RegistryItem, TokenContract } from './registry' +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { cancel, confirm, isCancel, log, note, select, spinner } from '@clack/prompts' +import { dim, underline } from 'kolorist' +import { loadFile } from 'magicast' +import { getDefaultExportOptions } from 'magicast/helpers' +import { dirname, join, relative, resolve } from 'pathe' +import { THEME_PLUGIN, THEME_PLUGIN_COMMAND, THEME_PLUGIN_LABEL, UNOCSS_CONFIGS, V0 } from '../constants/registry' +import { i18n } from '../i18n' +import { addDependency } from '../utils/installDependencies' +import { getProjectPackageJSON } from '../utils/package' +import { loadInventory, recordComponent, resolveRegistryUrl } from './inventory' +import { installPlugin, isPluginItem, pluginInstalled, recipeFor } from './plugin-install' +import { getContract, getIndex, getItem, match } from './registry' +import { groupedRegistryOptions } from './registry-options' + +export interface FeatureOptions { + name?: string + example?: string + dir?: string + registry?: string + overwrite?: boolean + yes?: boolean + cwd?: string +} + +/** Styles entry points a Tailwind v4 project is likely to declare its theme in. */ +const STYLESHEETS = [ + 'src/tailwind.css', + 'src/style.css', + 'src/styles/main.css', + 'src/assets/main.css', + 'app/assets/css/tailwind.css', + 'app/assets/css/main.css', + 'assets/css/tailwind.css', +] + +/** + * Unwinds out of a nested prompt without tearing the process down mid-write. + * + * The user-facing message is logged where the problem is found, so this only + * carries the exit code back up to `addFeature`, which sets it and returns. + */ +class Bail extends Error { + constructor (readonly code: number) { + super('bail') + } +} + +function stop (): never { + cancel(i18n.t('prompts.cancel')) + throw new Bail(0) +} + +function unwrap (value: T | symbol): T { + if (isCancel(value)) { + stop() + } + return value as T +} + +function find (cwd: string, candidates: string[]) { + for (const candidate of candidates) { + const path = join(cwd, candidate) + if (existsSync(path)) { + return path + } + } + return null +} + +/** Resolve the user's argument to exactly one registry entry. */ +async function pick (index: RegistryIndex, options: FeatureOptions): Promise { + if (!options.name) { + const chosen = unwrap(await select({ + message: i18n.t('prompts.add.feature'), + options: groupedRegistryOptions( + index.items, + item => `${item.type}/${item.name}`, + ), + })) + + if (String(chosen).startsWith('__group:')) { + stop() + } + + return index.items.find(item => `${item.type}/${item.name}` === chosen)! + } + + const found = match(index, options.name) + + if (found.length === 0) { + const close = index.items + .filter(item => item.name.startsWith(options.name!.slice(0, 3))) + .slice(0, 5) + .map(item => item.title || item.name) + + log.error(i18n.t('commands.add.unknown', { name: options.name })) + if (close.length > 0) { + log.info(i18n.t('commands.add.suggest', { names: close.join(', ') })) + } + + throw new Bail(1) + } + + if (found.length === 1) { + return found[0] + } + + // Guessing which of several features the user meant is worse than stopping, + // and a scripted run has nobody to answer the prompt. + if (options.yes) { + log.error(i18n.t('commands.add.ambiguous', { name: options.name })) + log.info(i18n.t('commands.add.suggest', { names: found.map(item => item.title || item.name).join(', ') })) + throw new Bail(1) + } + + const chosen = unwrap(await select({ + message: i18n.t('prompts.add.resolve'), + options: groupedRegistryOptions( + found, + item => `${item.type}/${item.name}`, + ), + })) + + return found.find(item => `${item.type}/${item.name}` === chosen)! +} + +async function choose ( + item: RegistryItem, + options: FeatureOptions, +): Promise { + if (options.example) { + const found = item.examples.find(example => example.id === options.example) + if (found) { + return found + } + + log.error(i18n.t('commands.add.unknown', { name: options.example })) + log.info(i18n.t('commands.add.suggest', { names: item.examples.map(example => example.id).join(', ') })) + throw new Bail(1) + } + + // Plugins are install-first: demos are opt-in (flag, or confirm when interactive). + if (isPluginItem(item)) { + if (item.examples.length === 0) { + return null + } + if (options.yes) { + return null + } + const also = unwrap(await confirm({ + message: i18n.t('prompts.add.pluginExample'), + initialValue: false, + })) + if (!also) { + return null + } + } + + if (item.examples.length === 0) { + return null + } + + if (item.examples.length === 1) { + return item.examples[0] + } + + // Unlike an ambiguous name, every example here belongs to the feature the + // user asked for, so a scripted run can take the canonical one and proceed. + if (options.yes) { + return item.examples.find(example => example.id === 'basic') ?? item.examples[0] + } + + const chosen = unwrap(await select({ + message: i18n.t('prompts.add.example'), + options: item.examples.map(example => ({ + label: example.title, + value: example.id, + hint: example.files.length > 1 ? `${example.files.length} files` : undefined, + })), + })) + + return item.examples.find(example => example.id === chosen)! +} + +/** Install any package the example imports that the project does not have. */ +async function depend (example: RegistryExample, options: FeatureOptions) { + const pkg = await getProjectPackageJSON(options.cwd).catch(() => null) + + const present = new Set([ + ...Object.keys(pkg?.dependencies ?? {}), + ...Object.keys(pkg?.devDependencies ?? {}), + // Always present in a Vue project, and never worth reinstalling. + 'vue', + ]) + + const missing = example.dependencies.filter(name => !present.has(name)) + if (missing.length === 0) { + return + } + + const install = options.yes || unwrap(await confirm({ + message: i18n.t('prompts.add.install', { pkgs: missing.join(', ') }), + })) + + if (!install) { + return + } + + const loader = spinner() + loader.start(i18n.t('commands.add.deps', { pkgs: missing.join(', ') })) + await addDependency(missing, { cwd: options.cwd, silent: true }) + loader.stop(i18n.t('spinners.dependencies.installed')) +} + +/** + * Make sure the semantic utility classes in the copied markup resolve. + * + * Two independent layers have to be present: the useTheme plugin emits the + * `--v0-*` custom properties, and the UnoCSS/Tailwind config maps them onto + * `bg-primary`, `text-on-surface` and friends. Without both, the file lands + * looking broken and v0 takes the blame — so this warns loudly and offers the + * patch rather than writing silently. + */ +async function style (item: RegistryItem, example: RegistryExample, contract: TokenContract, options: FeatureOptions) { + const cwd = options.cwd ?? process.cwd() + + if (example.tokens.length === 0) { + return + } + + const pkg = await getProjectPackageJSON(cwd).catch(() => null) + const hasV0 = !!(pkg?.dependencies?.[V0] ?? pkg?.devDependencies?.[V0]) + + const uno = find(cwd, UNOCSS_CONFIGS) + const sheet = find(cwd, STYLESHEETS) + const target = uno ?? sheet + + if (!target) { + log.warn(i18n.t('commands.add.styling.none', { + name: item.name, + tokens: example.tokens.slice(0, 3).join(', '), + })) + // Neither engine is present, so there is nothing to infer from — show both + // rather than guessing at one and handing over syntax for the other. + note( + `${i18n.t('commands.add.styling.unocss')}\n${contract.unocss}\n\n${i18n.t('commands.add.styling.tailwind')}\n${contract.tailwind}`, + i18n.t('commands.add.styling.manual', { plugin: THEME_PLUGIN_LABEL }), + ) + return + } + + const content = await readFile(target, 'utf8') + + // Both templates write the whole contract at once, so the presence of any + // mapping means the block is already there. + if (!content.includes(contract.prefix)) { + const file = underline(relative(cwd, target)) + + log.warn(i18n.t('commands.add.styling.missing', { file })) + + const patch = options.yes || unwrap(await confirm({ + message: i18n.t('prompts.add.tokens', { file }), + })) + + if (patch) { + const patched = target === uno + ? await map(target, contract) + : await append(target, content, contract.tailwind) + + if (patched) { + log.success(i18n.t('commands.add.styling.patched', { file })) + } else { + note(contract.unocss, i18n.t('commands.add.styling.manual', { plugin: THEME_PLUGIN_LABEL })) + } + } else { + note(target === uno ? contract.unocss : contract.tailwind, i18n.t('commands.add.styling.manual', { plugin: THEME_PLUGIN_LABEL })) + } + } + + if (hasV0 && !await themed(cwd)) { + log.warn(i18n.t('commands.add.styling.theme', { plugin: THEME_PLUGIN_LABEL, command: THEME_PLUGIN_COMMAND, prefix: contract.prefix })) + } +} + +/** + * Add the color map to an UnoCSS config's default export. + * + * The block has to land inside `defineConfig({ ... })` — appending the snippet + * would leave the file syntactically broken — so this edits the AST and leaves + * any colors the project already defined untouched. Returns false when the + * config shape is one magicast cannot read, so the caller can fall back to + * printing the snippet. + */ +async function map (path: string, contract: TokenContract) { + try { + const mod = await loadFile(path) + const options = getDefaultExportOptions(mod) + + if (!options) { + return false + } + + options.theme ||= {} + options.theme.colors ||= {} + + for (const token of contract.tokens) { + options.theme.colors[token] ??= `var(${contract.prefix}${token})` + } + + await writeFile(path, mod.generate().code) + + return true + } catch { + return false + } +} + +/** `@theme inline` is valid at the end of a stylesheet, so appending is safe. */ +async function append (path: string, content: string, snippet: string) { + await writeFile(path, `${content.trimEnd()}\n\n${snippet}\n`) + + return true +} + +/** Whether the project installs the plugin that emits the custom properties. */ +async function themed (cwd: string) { + return pluginInstalled(THEME_PLUGIN, cwd) +} + +interface WriteResult { + written: string[] + /** Project-relative directory for the example (even when every file was skipped). */ + dir: string + /** Components alias used as the write base. */ + base: string +} + +async function write (example: RegistryExample, options: FeatureOptions): Promise { + const cwd = options.cwd ?? process.cwd() + const inventory = await loadInventory(cwd) + const nuxt = existsSync(join(cwd, 'nuxt.config.ts')) || existsSync(join(cwd, 'nuxt.config.js')) + // Prefer explicit --dir, then vuetify.json alias, then framework default. + const base = options.dir + ?? inventory.aliases.components + ?? (nuxt ? 'app/components' : 'src/components') + const dir = join(base, example.dir) + const written: string[] = [] + + for (const file of example.files) { + const path = resolve(cwd, dir, file.name) + const shown = relative(cwd, path) + + if (existsSync(path) && !options.overwrite) { + // `--yes` accepts prompts, but clobbering a user's file is not a default + // worth accepting — a scripted run skips and says so, and --overwrite is + // the way to ask for the replacement explicitly. + const replace = options.yes + ? false + : unwrap(await confirm({ + message: i18n.t('prompts.add.overwrite', { path: underline(shown) }), + initialValue: false, + })) + + if (!replace) { + log.info(i18n.t('commands.add.skipped', { path: shown })) + continue + } + } + + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, file.content) + + written.push(shown) + log.success(i18n.t('commands.add.wrote', { path: shown })) + } + + return { written, dir, base } +} + +export async function addFeature (options: FeatureOptions = {}) { + try { + return await run(options) + } catch (error) { + // A cancel or a resolution failure has already told the user why; anything + // else is a real fault and belongs on the surface with its message. + if (!(error instanceof Bail)) { + throw error + } + + process.exitCode = error.code + + return [] + } +} + +async function run (options: FeatureOptions) { + const cwd = options.cwd ?? process.cwd() + const inventory = await loadInventory(cwd) + const origin = resolveRegistryUrl(inventory, options.registry) + const written: string[] = [] + + const loader = spinner() + loader.start(i18n.t('spinners.registry.fetching')) + const index = await getIndex(origin) + loader.stop(i18n.t('spinners.registry.fetched')) + + const entry = await pick(index, options) + const item = await getItem(entry, origin) + const contract = await getContract(origin) + + // Plugins: wire create*Plugin into the app first; examples are optional. + if (isPluginItem(item)) { + const recipe = recipeFor(item.name, item.install) + if (recipe) { + const pkg = await getProjectPackageJSON(cwd).catch(() => null) + const hasV0 = !!(pkg?.dependencies?.[V0] ?? pkg?.devDependencies?.[V0]) + if (!hasV0) { + const install = options.yes || unwrap(await confirm({ + message: i18n.t('prompts.add.install', { pkgs: V0 }), + })) + if (install) { + const spin = spinner() + spin.start(i18n.t('commands.add.deps', { pkgs: V0 })) + await addDependency(V0, { cwd, silent: true }) + spin.stop(i18n.t('spinners.dependencies.installed')) + } + } + + const result = await installPlugin(recipe, { cwd, overwrite: options.overwrite }) + if (result.path) { + written.push(result.path) + await recordComponent({ + cwd, + name: item.name, + dir: dirname(result.path), + files: [result.path.split('/').pop()!], + entry: result.path.split('/').pop(), + title: item.title, + docs: item.docs, + origin: { + registry: origin.replace(/\/$/, ''), + name: item.name, + example: 'install', + v0: index.v0Version, + type: item.type, + }, + }) + } + } else { + log.warn(i18n.t('commands.add.plugin.unknown', { name: item.name })) + } + } + + const example = await choose(item, options) + + if (example) { + await depend(example, options) + await style(item, example, contract, options) + + const out = await write(example, options) + written.push(...out.written) + + if (out.written.length > 0) { + const entryFile = example.files.find(file => file.entry) + await recordComponent({ + cwd, + name: item.name, + dir: out.dir, + files: example.files.map(file => file.name), + entry: entryFile?.name, + componentsDir: out.base, + title: item.title || example.title, + docs: item.docs, + origin: { + registry: origin.replace(/\/$/, ''), + name: item.name, + example: example.id, + v0: index.v0Version, + type: item.type, + }, + }) + } + } + + if (written.length > 0) { + log.message(dim(i18n.t('commands.add.docs', { url: item.docs }))) + log.message(dim(i18n.t('commands.add.inventory', { file: 'vuetify.json' }))) + } + + return written +} diff --git a/packages/shared/src/functions/generate.ts b/packages/shared/src/functions/generate.ts new file mode 100644 index 0000000..d4657b0 --- /dev/null +++ b/packages/shared/src/functions/generate.ts @@ -0,0 +1,70 @@ +import { existsSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { join, relative } from 'pathe' +import { loadInventory, recordComponent } from './inventory' + +export interface GenerateOptions { + name: string + cwd?: string + dir?: string + overwrite?: boolean +} + +function toPascal (value: string): string { + return value + .replace(/[^a-zA-Z0-9]+/g, ' ') + .split(' ') + .filter(Boolean) + .map(part => part[0]!.toUpperCase() + part.slice(1)) + .join('') +} + +function scaffold (name: string): string { + // Bare markup — no utility classes; the host project may not use Uno/Tailwind. + return ` + + +` +} + +/** + * Scaffold a local component into the project inventory with no upstream origin. + */ +export async function generateComponent (options: GenerateOptions) { + const cwd = options.cwd ?? process.cwd() + const inventory = await loadInventory(cwd) + const base = options.dir ?? inventory.aliases.components + const pascal = toPascal(options.name) + if (!pascal) { + throw new Error('Component name is empty') + } + + const file = `${pascal}.vue` + const dir = base + const abs = join(cwd, dir, file) + + if (existsSync(abs) && !options.overwrite) { + throw new Error(`${relative(cwd, abs)} already exists (pass --overwrite)`) + } + + await mkdir(join(cwd, dir), { recursive: true }) + await writeFile(abs, scaffold(pascal)) + + await recordComponent({ + cwd, + name: pascal, + dir, + files: [file], + entry: file, + title: pascal, + componentsDir: base, + }) + + return { path: relative(cwd, abs).split('\\').join('/'), name: pascal } +} diff --git a/packages/shared/src/functions/index.ts b/packages/shared/src/functions/index.ts index 18e8ca3..662216b 100644 --- a/packages/shared/src/functions/index.ts +++ b/packages/shared/src/functions/index.ts @@ -1,6 +1,14 @@ export * from './analyze' export * from './create' +export * from './diff' export * from './docs' export * from './eslint' +export * from './feature' +export * from './generate' +export * from './inventory' +export * from './plugin-install' +export * from './refresh' +export * from './registry' +export * from './registry-build' export * from './scaffold' export * from './upgrade' diff --git a/packages/shared/src/functions/inventory.ts b/packages/shared/src/functions/inventory.ts new file mode 100644 index 0000000..3e59b17 --- /dev/null +++ b/packages/shared/src/functions/inventory.ts @@ -0,0 +1,192 @@ +/** + * Local component-library inventory (`vuetify.json`). + * + * Source of truth for tracking stays the user's git tree — this file only + * indexes what `vuetify add` (and later generate) put on disk, plus optional + * upstream origin metadata for diff/update in a later phase. + */ + +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { join, relative, resolve } from 'pathe' +import { REGISTRY_ORIGIN } from '../constants/registry' + +export const INVENTORY_VERSION = 1 +export const INVENTORY_FILE = 'vuetify.json' +/** @deprecated Prefer REGISTRY_ORIGIN — kept as alias for inventory call sites. */ +export const DEFAULT_REGISTRY = REGISTRY_ORIGIN +export const DEFAULT_COMPONENTS_DIR = 'src/components' + +export interface ComponentOrigin { + /** Registry origin URL used for the install. */ + registry: string + /** Feature name in that registry (`dialog`, `create-data-table`). */ + name: string + /** Example id within the feature (`basic`, `gallery`). */ + example: string + /** `@vuetify/v0` version advertised by the registry index at install time. */ + v0?: string + type?: 'components' | 'composables' +} + +export interface InventoryComponent { + /** Project-relative directory holding the written files. */ + path: string + /** Basenames written into that directory. */ + files: string[] + /** Entry basename (the demo file), when known. */ + entry?: string + /** Upstream seed, when the component came from a registry. */ + origin?: ComponentOrigin + title?: string + docs?: string +} + +export interface Inventory { + version: number + aliases: { + components: string + } + registries: Record + components: Record +} + +export function emptyInventory (componentsDir = DEFAULT_COMPONENTS_DIR): Inventory { + return { + version: INVENTORY_VERSION, + aliases: { components: componentsDir }, + registries: { '@vuetify': DEFAULT_REGISTRY }, + components: {}, + } +} + +export function inventoryPath (cwd = process.cwd()) { + return join(cwd, INVENTORY_FILE) +} + +export async function loadInventory (cwd = process.cwd()): Promise { + const path = inventoryPath(cwd) + if (!existsSync(path)) { + return emptyInventory() + } + + try { + const raw = JSON.parse(await readFile(path, 'utf8')) as Partial + return { + version: raw.version ?? INVENTORY_VERSION, + aliases: { + components: raw.aliases?.components ?? DEFAULT_COMPONENTS_DIR, + }, + registries: raw.registries ?? { '@vuetify': DEFAULT_REGISTRY }, + components: raw.components ?? {}, + } + } catch { + return emptyInventory() + } +} + +export async function saveInventory (inventory: Inventory, cwd = process.cwd()) { + const path = inventoryPath(cwd) + const body = `${JSON.stringify(inventory, null, 2)}\n` + await writeFile(path, body) + return path +} + +export interface RecordComponentOptions { + cwd?: string + /** Inventory key — feature name, e.g. `dialog`. */ + name: string + /** Absolute or cwd-relative directory that holds the files. */ + dir: string + files: string[] + entry?: string + origin?: ComponentOrigin + title?: string + docs?: string + /** Preferred components alias when creating the file. */ + componentsDir?: string +} + +/** + * Upsert one component into `vuetify.json`, creating the file if missing. + * Paths are stored project-relative with POSIX separators. + */ +export async function recordComponent (options: RecordComponentOptions) { + const cwd = options.cwd ?? process.cwd() + const inventory = await loadInventory(cwd) + + if (options.componentsDir) { + inventory.aliases.components = options.componentsDir + } + + if (options.origin?.registry) { + // Keep a short alias for the default origin; everything else by URL key. + if (options.origin.registry.replace(/\/$/, '') === DEFAULT_REGISTRY.replace(/\/$/, '')) { + inventory.registries['@vuetify'] = DEFAULT_REGISTRY + } else { + inventory.registries[options.origin.registry] = options.origin.registry + } + } + + const absDir = resolve(cwd, options.dir) + const relDir = relative(cwd, absDir) || options.dir + + inventory.components[options.name] = { + path: relDir.split('\\').join('/'), + files: options.files, + entry: options.entry, + origin: options.origin, + title: options.title, + docs: options.docs, + } + + await saveInventory(inventory, cwd) + return inventory +} + +/** True when every recorded basename still exists under the component path. */ +export function componentStatus ( + component: InventoryComponent, + cwd = process.cwd(), +): { ok: boolean, missing: string[] } { + const missing: string[] = [] + for (const file of component.files) { + const full = join(cwd, component.path, file) + if (!existsSync(full)) { + missing.push(file) + } + } + return { ok: missing.length === 0, missing } +} + +/** Resolve a registry alias (`@vuetify`) or absolute URL from inventory. */ +export function resolveRegistryUrl ( + inventory: Inventory, + aliasOrUrl?: string, +): string { + if (!aliasOrUrl) { + return inventory.registries['@vuetify'] ?? DEFAULT_REGISTRY + } + if (/^https?:\/\//i.test(aliasOrUrl)) { + return aliasOrUrl.replace(/\/$/, '') + } + const key = aliasOrUrl.startsWith('@') ? aliasOrUrl : `@${aliasOrUrl}` + const found = inventory.registries[key] ?? inventory.registries[aliasOrUrl] + if (!found) { + throw new Error(`Unknown registry "${aliasOrUrl}". Known: ${Object.keys(inventory.registries).join(', ') || '(none)'}`) + } + return found.replace(/\/$/, '') +} + +/** + * Parse `dialog`, `@vuetify/dialog`, or a bare name. + * Namespace maps to an inventory registries key. + */ +export function parseFeatureRef (query: string): { registry?: string, name: string } { + const trimmed = query.trim() + const m = trimmed.match(/^(@[\w-]+)\/(.+)$/) + if (m) { + return { registry: m[1], name: m[2] } + } + return { name: trimmed } +} diff --git a/packages/shared/src/functions/plugin-install.ts b/packages/shared/src/functions/plugin-install.ts new file mode 100644 index 0000000..f271d91 --- /dev/null +++ b/packages/shared/src/functions/plugin-install.ts @@ -0,0 +1,377 @@ +/** + * App-level install for registry plugins (useTheme → createThemePlugin, …). + * + * Plugins are install-first: wire the factory into the app entry, then optionally + * seed a usage example. Detection reuses factory name scanning so we don't + * double-register. + */ + +import { existsSync } from 'node:fs' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { log } from '@clack/prompts' +import { dim, underline } from 'kolorist' +import { loadFile } from 'magicast' +import { join, relative } from 'pathe' +import { V0 } from '../constants/registry' +import { i18n } from '../i18n' +import { addStatementToFunctionBody, isFunction } from '../utils/magicast' +import type { RegistryInstall } from './registry' + +export interface PluginRecipe { + /** Registry item name (kebab), e.g. use-theme */ + name: string + /** Docs label, e.g. useTheme */ + label: string + /** Factory export, e.g. createThemePlugin */ + factory: string + /** File under src/plugins/ (or app/plugins/) without path */ + file: string + /** Source of the default-export module */ + source: string +} + +/** + * Fallback map when the registry item has no `install` field (older payloads). + * Prefer `item.install` from the registry JSON once the seed ships it. + */ +export const PLUGIN_RECIPES: Record> = { + 'use-breakpoints': { label: 'useBreakpoints', factory: 'createBreakpointsPlugin' }, + 'use-date': { label: 'useDate', factory: 'createDatePlugin' }, + 'use-features': { label: 'useFeatures', factory: 'createFeaturesPlugin' }, + 'use-hydration': { label: 'useHydration', factory: 'createHydrationPlugin' }, + 'use-locale': { label: 'useLocale', factory: 'createLocalePlugin' }, + 'use-logger': { label: 'useLogger', factory: 'createLoggerPlugin' }, + 'use-notifications': { label: 'useNotifications', factory: 'createNotificationsPlugin' }, + 'use-permissions': { label: 'usePermissions', factory: 'createPermissionsPlugin' }, + 'use-reduced-motion': { label: 'useReducedMotion', factory: 'createReducedMotionPlugin' }, + 'use-rtl': { label: 'useRtl', factory: 'createRtlPlugin' }, + 'use-rules': { label: 'useRules', factory: 'createRulesPlugin' }, + 'use-stack': { label: 'useStack', factory: 'createStackPlugin' }, + 'use-storage': { label: 'useStorage', factory: 'createStoragePlugin' }, + 'use-theme': { label: 'useTheme', factory: 'createThemePlugin' }, + 'use-tooltip': { label: 'useTooltip', factory: 'createTooltipPlugin' }, +} + +/** Same scan targets as the theme preflight. */ +const SCAN_TARGETS = [ + 'src/plugins/vuetify.ts', + 'src/plugins/index.ts', + 'src/main.ts', + 'app/plugins/vuetify.ts', + 'app/plugins/index.ts', + 'plugins/vuetify.ts', + 'nuxt.config.ts', +] + +function pluginSource (factory: string, name: string): string { + if (name === 'use-theme') { + return `import { ${factory} } from '${V0}' + +export default ${factory}({ + default: 'light', + themes: { + light: { + dark: false, + colors: { + 'primary': '#3b82f6', + 'secondary': '#64748b', + 'error': '#ef4444', + 'info': '#1867c0', + 'success': '#22c55e', + 'warning': '#f59e0b', + 'background': '#f5f5f5', + 'surface': '#ffffff', + 'surface-tint': '#f5f5f5', + 'surface-variant': '#eeeeee', + 'divider': '#e0e0e0', + 'on-primary': '#ffffff', + 'on-secondary': '#ffffff', + 'on-error': '#ffffff', + 'on-info': '#ffffff', + 'on-success': '#ffffff', + 'on-warning': '#1a1a1a', + 'on-background': '#212121', + 'on-surface': '#212121', + 'on-surface-variant': '#666666', + }, + }, + dark: { + dark: true, + colors: { + 'primary': '#c4b5fd', + 'secondary': '#94a3b8', + 'error': '#f87171', + 'info': '#38bdf8', + 'success': '#4ade80', + 'warning': '#fb923c', + 'background': '#121212', + 'surface': '#1a1a1a', + 'surface-tint': '#2a2a2a', + 'surface-variant': '#1e1e1e', + 'divider': '#404040', + 'on-primary': '#1a1a1a', + 'on-secondary': '#1a1a1a', + 'on-error': '#1a1a1a', + 'on-info': '#1a1a1a', + 'on-success': '#1a1a1a', + 'on-warning': '#1a1a1a', + 'on-background': '#e0e0e0', + 'on-surface': '#e0e0e0', + 'on-surface-variant': '#a0a0a0', + }, + }, + }, +}) +` + } + + return `import { ${factory} } from '${V0}' + +export default ${factory}() +` +} + +function nuxtPluginSource (factory: string, name: string): string { + const body = name === 'use-theme' + ? `${factory}({ + default: 'light', + themes: { + light: { dark: false, colors: { primary: '#3b82f6', secondary: '#64748b', error: '#ef4444', info: '#1867c0', success: '#22c55e', warning: '#f59e0b', background: '#f5f5f5', surface: '#ffffff', 'surface-tint': '#f5f5f5', 'surface-variant': '#eeeeee', divider: '#e0e0e0', 'on-primary': '#ffffff', 'on-secondary': '#ffffff', 'on-error': '#ffffff', 'on-info': '#ffffff', 'on-success': '#ffffff', 'on-warning': '#1a1a1a', 'on-background': '#212121', 'on-surface': '#212121', 'on-surface-variant': '#666666' } }, + dark: { dark: true, colors: { primary: '#c4b5fd', secondary: '#94a3b8', error: '#f87171', info: '#38bdf8', success: '#4ade80', warning: '#fb923c', background: '#121212', surface: '#1a1a1a', 'surface-tint': '#2a2a2a', 'surface-variant': '#1e1e1e', divider: '#404040', 'on-primary': '#1a1a1a', 'on-secondary': '#1a1a1a', 'on-error': '#1a1a1a', 'on-info': '#1a1a1a', 'on-success': '#1a1a1a', 'on-warning': '#1a1a1a', 'on-background': '#e0e0e0', 'on-surface': '#e0e0e0', 'on-surface-variant': '#a0a0a0' } }, + }, + })` + : `${factory}()` + + return `import { ${factory} } from '${V0}' + +export default defineNuxtPlugin(nuxtApp => { + nuxtApp.vueApp.use(${body}) +}) +` +} + +/** + * Resolve an install recipe. Registry `install` wins; CLI map is the fallback + * for older seeds and offline fixtures. + */ +export function recipeFor (name: string, install?: RegistryInstall | null): PluginRecipe | null { + const fromRegistry = install?.factory + ? { + label: install.label || name, + factory: install.factory, + file: install.file || `${name.replace(/^use-/, '')}.ts`, + } + : null + + const base = fromRegistry ?? (PLUGIN_RECIPES[name] + ? { + label: PLUGIN_RECIPES[name].label, + factory: PLUGIN_RECIPES[name].factory, + file: `${name.replace(/^use-/, '')}.ts`, + } + : null) + + if (!base) return null + + return { + name, + label: base.label, + factory: base.factory, + file: base.file.endsWith('.ts') || base.file.endsWith('.js') + ? base.file + : `${base.file}.ts`, + source: pluginSource(base.factory, name), + } +} + +export async function pluginInstalled (factory: string, cwd: string): Promise { + for (const candidate of SCAN_TARGETS) { + const content = await readFile(join(cwd, candidate), 'utf8').catch(() => '') + if (content.includes(factory)) return true + } + + // Written modules live under plugins/.ts — not always in SCAN_TARGETS. + for (const dir of ['src/plugins', 'app/plugins', 'plugins']) { + const root = join(cwd, dir) + if (!existsSync(root)) continue + for (const file of await readdir(root).catch(() => [] as string[])) { + if (!file.endsWith('.ts') && !file.endsWith('.js')) continue + const content = await readFile(join(root, file), 'utf8').catch(() => '') + if (content.includes(factory)) return true + } + } + + return false +} + +export interface InstallPluginResult { + /** Project-relative path of the plugin module written or already present */ + path: string | null + /** Whether we wrote/wired something new */ + installed: boolean + /** Snippet when we could not auto-wire */ + manual?: string +} + +/** + * Write the plugin module and register it on the app when the project shape is known. + */ +export async function installPlugin ( + recipe: PluginRecipe, + options: { cwd?: string, overwrite?: boolean } = {}, +): Promise { + const cwd = options.cwd ?? process.cwd() + const nuxt = existsSync(join(cwd, 'nuxt.config.ts')) || existsSync(join(cwd, 'nuxt.config.js')) + + const already = await pluginInstalled(recipe.factory, cwd) + // `--overwrite` (refresh) rewrites the module even when the factory is present. + if (already && !options.overwrite) { + log.info(i18n.t('commands.add.plugin.already', { + plugin: recipe.label, + factory: recipe.factory, + })) + return { path: null, installed: false } + } + + if (nuxt) { + const dir = existsSync(join(cwd, 'app/plugins')) ? 'app/plugins' : 'plugins' + await mkdir(join(cwd, dir), { recursive: true }) + const rel = join(dir, recipe.file) + const abs = join(cwd, rel) + if (existsSync(abs) && !options.overwrite) { + log.info(i18n.t('commands.add.skipped', { path: rel })) + return { path: rel, installed: false } + } + await writeFile(abs, nuxtPluginSource(recipe.factory, recipe.name)) + log.success(i18n.t('commands.add.plugin.wrote', { + plugin: recipe.label, + path: underline(rel), + })) + return { path: rel, installed: true } + } + + // Vue SPA: prefer src/plugins/ + registerPlugins + const pluginsDir = 'src/plugins' + await mkdir(join(cwd, pluginsDir), { recursive: true }) + const rel = join(pluginsDir, recipe.file) + const abs = join(cwd, rel) + + if (!existsSync(abs) || options.overwrite) { + await writeFile(abs, recipe.source) + log.success(i18n.t('commands.add.plugin.wrote', { + plugin: recipe.label, + path: underline(rel), + })) + } + + // Already wired on a previous install — only rewrite the module on refresh. + if (already && options.overwrite) { + return { path: rel, installed: true } + } + + const wired = await wireRegisterPlugins(cwd, recipe) + || await wireMainTs(cwd, recipe) + + if (!wired) { + const snippet = `import ${camel(recipe.file)} from '@/plugins/${recipe.file.replace(/\.ts$/, '')}'\n\napp.use(${camel(recipe.file)})` + log.warn(i18n.t('commands.add.plugin.manual', { plugin: recipe.label })) + log.message(dim(snippet)) + return { path: rel, installed: true, manual: snippet } + } + + return { path: rel, installed: true } +} + +function camel (file: string) { + return file + .replace(/\.ts$/, '') + .replace(/-([a-z])/g, (_, c) => c.toUpperCase()) +} + +async function wireRegisterPlugins (cwd: string, recipe: PluginRecipe): Promise { + const path = join(cwd, 'src/plugins/index.ts') + if (!existsSync(path)) return false + + try { + const mod = await loadFile(path) + const local = camel(recipe.file) + const from = `./${recipe.file.replace(/\.ts$/, '')}` + + mod.imports.$prepend({ + from, + imported: 'default', + local, + }) + + const register = mod.exports.registerPlugins + if (isFunction(register)) { + addStatementToFunctionBody(register, `app.use(${local})`) + } else { + return false + } + + await writeFile(path, mod.generate().code) + log.success(i18n.t('commands.add.plugin.wired', { + plugin: recipe.label, + path: underline(relative(cwd, path)), + })) + return true + } catch { + return false + } +} + +async function wireMainTs (cwd: string, recipe: PluginRecipe): Promise { + const path = join(cwd, 'src/main.ts') + if (!existsSync(path)) return false + + const content = await readFile(path, 'utf8') + if (content.includes(recipe.factory) || content.includes(`plugins/${recipe.file.replace(/\.ts$/, '')}`)) { + return true + } + + // Prefer non-magicast append when main is a simple createApp bootstrap + if (!content.includes('createApp') || !content.includes('.mount')) { + return false + } + + const local = camel(recipe.file) + const importLine = `import ${local} from '@/plugins/${recipe.file.replace(/\.ts$/, '')}'\n` + let next = content + + if (!content.includes(importLine.trim()) && !content.includes(`from '@/plugins/${recipe.file.replace(/\.ts$/, '')}'`)) { + // After last import + const importBlock = content.match(/^(?:import[\s\S]*?from\s+['"][^'"]+['"];?\s*\n)+/m) + if (importBlock) { + next = content.slice(0, importBlock[0].length) + importLine + content.slice(importBlock[0].length) + } else { + next = importLine + content + } + } + + if (!next.includes(`app.use(${local})`)) { + next = next.replace( + /(const\s+app\s*=\s*createApp\([^)]*\)\s*\n)/, + `$1\napp.use(${local})\n`, + ) + if (!next.includes(`app.use(${local})`)) { + next = next.replace( + /(app\.mount\()/, + `app.use(${local})\n\n$1`, + ) + } + } + + if (next === content) return false + + await writeFile(path, next) + log.success(i18n.t('commands.add.plugin.wired', { + plugin: recipe.label, + path: underline(relative(cwd, path)), + })) + return true +} + +export function isPluginItem (item: { category?: string }) { + return item.category === 'plugins' +} diff --git a/packages/shared/src/functions/refresh.ts b/packages/shared/src/functions/refresh.ts new file mode 100644 index 0000000..99a3bdb --- /dev/null +++ b/packages/shared/src/functions/refresh.ts @@ -0,0 +1,31 @@ +import { addFeature } from './feature' +import { loadInventory } from './inventory' + +export interface RefreshOptions { + name: string + cwd?: string + yes?: boolean + overwrite?: boolean + registry?: string +} + +/** + * Re-fetch a tracked component from its origin registry and overwrite local files. + */ +export async function refreshComponent (options: RefreshOptions) { + const cwd = options.cwd ?? process.cwd() + const inventory = await loadInventory(cwd) + const local = inventory.components[options.name] + if (!local?.origin) { + throw new Error(`"${options.name}" has no registry origin — cannot refresh`) + } + + return addFeature({ + name: local.origin.name, + example: local.origin.example === 'install' ? undefined : local.origin.example, + cwd, + yes: options.yes ?? true, + overwrite: options.overwrite ?? true, + registry: options.registry ?? local.origin.registry, + }) +} diff --git a/packages/shared/src/functions/registry-build.ts b/packages/shared/src/functions/registry-build.ts new file mode 100644 index 0000000..ffe3713 --- /dev/null +++ b/packages/shared/src/functions/registry-build.ts @@ -0,0 +1,135 @@ +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'pathe' +import { DEFAULT_REGISTRY, loadInventory } from './inventory' +import type { RegistryExample, RegistryFile, RegistryIndex, RegistryInstall, RegistryItem } from './registry' +import { getProjectPackageJSON } from '../utils/package' +import { REGISTRY_VERSION, V0 } from '../constants/registry' + +export interface RegistryBuildOptions { + cwd?: string + outDir?: string +} + +function installFromName (name: string, title?: string): RegistryInstall { + const bare = name.replace(/^use-/, '') + const pascal = bare + .split('-') + .filter(Boolean) + .map(part => part[0]!.toUpperCase() + part.slice(1)) + .join('') + return { + factory: `create${pascal}Plugin`, + label: title || `use${pascal}`, + file: `${bare}.ts`, + } +} + +/** + * Emit a static registry from the local inventory + files on disk. + * Same shape as the official seed so others can `vuetify add --registry `. + */ +export async function buildLocalRegistry (options: RegistryBuildOptions = {}) { + const cwd = options.cwd ?? process.cwd() + const outDir = options.outDir ?? 'registry' + const inventory = await loadInventory(cwd) + const pkg = await getProjectPackageJSON(cwd).catch(() => null) + const v0Version = (pkg?.dependencies?.[V0] ?? pkg?.devDependencies?.[V0] ?? '0.0.0').replace(/^[\^~]/, '') + + const items: RegistryItem[] = [] + + for (const [name, component] of Object.entries(inventory.components)) { + const files: RegistryFile[] = [] + for (const file of component.files) { + const abs = join(cwd, component.path, file) + if (!existsSync(abs)) continue + const content = await readFile(abs, 'utf8') + files.push({ + path: `${component.path}/${file}`.split('\\').join('/'), + name: file, + entry: file === component.entry || (component.entry === undefined && file.endsWith('.vue')), + content, + }) + } + if (files.length === 0) continue + + // Ensure exactly one entry + if (!files.some(f => f.entry)) { + const lastVue = [...files].reverse().find(f => f.name.endsWith('.vue')) + if (lastVue) lastVue.entry = true + else files.at(-1)!.entry = true + } + + const example: RegistryExample = { + id: 'default', + title: component.title || name, + description: '', + dir: component.path.replace(/^src\/components\/?/, '') || name, + files, + dependencies: [V0], + tokens: [], + icons: { collections: [], classes: [] }, + } + + // dir for consumers: strip leading components alias when possible + const alias = inventory.aliases.components.replace(/\/$/, '') + if (example.dir.startsWith(alias)) { + example.dir = example.dir.slice(alias.length).replace(/^\//, '') || name + } + + const itemName = name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() + const isPluginInstall = component.origin?.example === 'install' + items.push({ + name: itemName, + type: component.origin?.type ?? 'components', + category: isPluginInstall ? 'plugins' : 'local', + level: 'local', + title: component.title || name, + description: component.docs ?? '', + docs: component.docs ?? '', + // Keep on-disk files as a default example so customized plugin modules + // (e.g. a themed createThemePlugin) re-publish; install is still install-first. + examples: [example], + ...(isPluginInstall + ? { install: installFromName(itemName, component.title || name) } + : {}), + }) + } + + items.sort((a, b) => a.name.localeCompare(b.name)) + + const index: RegistryIndex = { + version: REGISTRY_VERSION, + v0Version, + tokens: [], + items: items.map(item => ({ + name: item.name, + type: item.type, + category: item.category, + level: item.level, + title: item.title, + description: item.description, + docs: item.docs, + examples: item.examples.map(e => e.id), + })), + } + + const root = join(cwd, outDir) + await mkdir(join(root), { recursive: true }) + await writeFile(join(root, 'index.json'), `${JSON.stringify(index, null, 2)}\n`) + await writeFile(join(root, 'tokens.json'), `${JSON.stringify({ + version: REGISTRY_VERSION, + tokens: [], + prefix: '--v0-', + unocss: '', + tailwind: '', + }, null, 2)}\n`) + + for (const item of items) { + const dir = join(root, item.type) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, `${item.name}.json`), `${JSON.stringify(item, null, 2)}\n`) + } + + return { outDir, count: items.length, origin: DEFAULT_REGISTRY } +} diff --git a/packages/shared/src/functions/registry-options.ts b/packages/shared/src/functions/registry-options.ts new file mode 100644 index 0000000..9cad6e5 --- /dev/null +++ b/packages/shared/src/functions/registry-options.ts @@ -0,0 +1,73 @@ +import type { RegistryIndexEntry } from './registry' + +export interface SelectOption { + label: string + value: string + hint?: string + disabled?: boolean +} + +function header (label: string): SelectOption { + // clack only dims/strikethroughs disabled rows — no real header style — so a + // leading mark is what makes groups scannable in the list. + return { label: `── ${label}`, value: `__group:${label}`, disabled: true } +} + +function row ( + item: RegistryIndexEntry, + value: (item: RegistryIndexEntry) => string, +): SelectOption { + return { + label: item.title || item.name, + value: value(item), + hint: item.category, + } +} + +function byTitle (a: RegistryIndexEntry, b: RegistryIndexEntry) { + return (a.title || a.name).localeCompare(b.title || b.name) +} + +/** + * Partition registry items into labeled groups for clack `select`. + * + * Order: Components → Plugins → Composables → Transformers. Plugins and + * transformers are `type: composables` on the wire but different maturity + * categories — keep them out of the generic composables bucket. + */ +export function groupedRegistryOptions ( + items: RegistryIndexEntry[], + value: (item: RegistryIndexEntry) => string = item => item.name, +): SelectOption[] { + const components = items.filter(item => item.type === 'components').toSorted(byTitle) + const plugins = items + .filter(item => item.type === 'composables' && item.category === 'plugins') + .toSorted(byTitle) + const transformers = items + .filter(item => item.type === 'composables' && item.category === 'transformers') + .toSorted(byTitle) + const composables = items + .filter(item => + item.type === 'composables' + && item.category !== 'plugins' + && item.category !== 'transformers', + ) + .toSorted(byTitle) + + const options: SelectOption[] = [] + + function push (label: string, group: RegistryIndexEntry[]) { + if (group.length === 0) return + options.push(header(label)) + for (const item of group) { + options.push(row(item, value)) + } + } + + push('Components', components) + push('Plugins', plugins) + push('Composables', composables) + push('Transformers', transformers) + + return options +} diff --git a/packages/shared/src/functions/registry.ts b/packages/shared/src/functions/registry.ts new file mode 100644 index 0000000..e259e6a --- /dev/null +++ b/packages/shared/src/functions/registry.ts @@ -0,0 +1,143 @@ +import { REGISTRY_ORIGIN, REGISTRY_TIMEOUT, REGISTRY_VERSION } from '../constants/registry' +import { i18n } from '../i18n' + +export type ItemType = 'components' | 'composables' + +export interface RegistryFile { + path: string + name: string + entry: boolean + content: string +} + +/** Icon soft-deps — collections are install units; classes are audit detail. */ +export interface RegistryIcons { + collections: string[] + classes: string[] +} + +/** + * App-level install recipe for plugins (`useTheme` → `createThemePlugin`). + * Emitted by the registry for `category: plugins` items; CLI falls back to a + * built-in map when absent (older payloads). + */ +export interface RegistryInstall { + /** Factory export, e.g. `createThemePlugin`. */ + factory: string + /** Docs surface label, e.g. `useTheme`. */ + label: string + /** Module basename under `src/plugins/`, e.g. `theme.ts`. */ + file: string +} + +export interface RegistryExample { + id: string + title: string + description: string + dir: string + files: RegistryFile[] + dependencies: string[] + tokens: string[] + /** Present from registry v1 seed; optional for older payloads. */ + icons?: RegistryIcons +} + +export interface RegistryItem { + name: string + type: ItemType + category: string + level: string + title: string + description: string + docs: string + examples: RegistryExample[] + /** Present on plugin items when the registry ships an install recipe. */ + install?: RegistryInstall +} + +export interface RegistryIndexEntry { + name: string + type: ItemType + category: string + level: string + title: string + description: string + docs: string + examples: string[] +} + +export interface RegistryIndex { + version: number + v0Version: string + tokens: string[] + items: RegistryIndexEntry[] +} + +export interface TokenContract { + version: number + tokens: string[] + prefix: string + unocss: string + tailwind: string +} + +const RE_TRAILING_SLASH = /\/$/ +const RE_SEPARATORS = /[\s_]+/g +const RE_FACTORY_PREFIX = /^(create|use)-/ + +async function get (origin: string, path: string): Promise { + const url = `${origin.replace(RE_TRAILING_SLASH, '')}/registry/${path}` + + const response = await fetch(url, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT) }) + .catch((error: Error) => { + throw new Error(i18n.t('errors.registry.unreachable', { url, reason: error.message })) + }) + + if (!response.ok) { + throw new Error(i18n.t('errors.registry.status', { url, status: response.status })) + } + + return await response.json() as T +} + +export async function getIndex (origin = REGISTRY_ORIGIN) { + const index = await get(origin, 'index.json') + + // A newer registry may describe items in a shape this CLI cannot write. + if (index.version > REGISTRY_VERSION) { + throw new Error(i18n.t('errors.registry.version', { found: index.version, expected: REGISTRY_VERSION })) + } + + return index +} + +export async function getItem (entry: RegistryIndexEntry, origin = REGISTRY_ORIGIN) { + return await get(origin, `${entry.type}/${entry.name}.json`) +} + +export async function getContract (origin = REGISTRY_ORIGIN) { + return await get(origin, 'tokens.json') +} + +/** + * Candidate registry entries for a user-typed name. + * + * Exact matches win outright. Failing that, `add popover` should still find the + * `usePopover` composable and `add data table` the `createDataTable` one, so + * the fallback strips the `create`/`use` prefix and tolerates a loose match. + */ +export function match (index: RegistryIndex, query: string): RegistryIndexEntry[] { + const needle = query.trim().toLowerCase().replace(RE_SEPARATORS, '-') + + const exact = index.items.filter(item => item.name === needle) + if (exact.length > 0) { + return exact + } + + const bare = index.items.filter(item => item.name.replace(RE_FACTORY_PREFIX, '') === needle) + if (bare.length > 0) { + return bare + } + + return index.items.filter(item => item.name.includes(needle) || needle.includes(item.name)) +} diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index eb011d5..bbb26cc 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -144,11 +144,43 @@ "opening": "Opening %{url}" }, "add": { - "description": "Add tools to the project", + "description": "Add an integration or a component to the project", "integration": { - "description": "Integration to add (choices: %{choices})", + "description": "Integration or component to add (integrations: %{choices})", "available": "Available integrations: %{choices}", "invalid": "Invalid integration: %{integration}. Available: %{choices}" + }, + "intro": "Add %{name} to your project", + "args": { + "example": "Example to add when a feature ships more than one", + "dir": "Directory to write the files into", + "registry": "Registry origin to read from", + "overwrite": "Overwrite existing files without asking", + "yes": "Accept every prompt with its default" + }, + "unknown": "Nothing in the registry matches \"%{name}\".", + "ambiguous": "\"%{name}\" matches more than one thing — name it exactly, or drop --yes to choose.", + "suggest": "Did you mean: %{names}?", + "wrote": "Wrote %{path}", + "skipped": "Skipped %{path}", + "deps": "Installing %{pkgs}", + "docs": "Docs: %{url}", + "styling": { + "missing": "Semantic colors are not mapped in %{file}, so the copied markup will render unstyled.", + "none": "No UnoCSS or Tailwind config found. %{name} styles itself with semantic utility classes (%{tokens}), which need one of them.", + "patched": "Mapped semantic colors in %{file}", + "manual": "Add this to your styles, then make sure the %{plugin} plugin is installed so the custom properties exist:", + "unocss": "UnoCSS — inside defineConfig in uno.config.ts:", + "tailwind": "Tailwind — in your CSS entry:", + "theme": "No %{plugin} plugin found. Without it the %{prefix} custom properties are never emitted and every semantic color falls back to nothing. Add it with `vuetify add %{command}`." + }, + "inventory": "Tracked in %{file}", + "plugin": { + "already": "%{plugin} is already installed (%{factory} found in the project).", + "wrote": "Wrote %{plugin} plugin module → %{path}", + "wired": "Registered %{plugin} in %{path}", + "manual": "Could not auto-register %{plugin}. Add this to your app entry:", + "unknown": "No install recipe for \"%{name}\" yet — wrote nothing at the app level." } }, "update": { @@ -218,12 +250,77 @@ "rate_limited": "GitHub rate limit reached. Set GITHUB_TOKEN to raise the limit.", "failed": "Failed to fetch release notes (%{status}).", "none_found": "No matching release found." + }, + "list": { + "description": "List components tracked in vuetify.json", + "intro": "Local component library", + "empty": "No components tracked yet.", + "hint": "Run `vuetify add ` to seed one from the registry.", + "count": "%{count} component(s)", + "local": "local", + "missing": "%{name}: missing on disk — %{files}", + "args": { + "json": "Print the raw vuetify.json inventory" + } + }, + "status": { + "description": "Check that tracked components still exist on disk", + "intro": "Component library status", + "noInventory": "No vuetify.json in this project.", + "missingFiles": "missing %{files}", + "noOrigin": "local (no registry origin)", + "summary": "%{healthy} ok · %{broken} broken · %{local} local · %{total} total" + }, + "generate": { + "description": "Scaffold a local component (no registry origin)", + "intro": "Generate %{name}", + "wrote": "Wrote %{path}", + "args": { + "name": "Component name (PascalCase or kebab-case)", + "dir": "Directory under the project (defaults to vuetify.json aliases.components)", + "overwrite": "Overwrite an existing file" + } + }, + "diff": { + "description": "Compare a tracked component to its registry origin", + "intro": "Diff %{name}", + "done": "Diff complete", + "args": { + "name": "Inventory key (e.g. dialog, use-theme)" + } + }, + "refresh": { + "description": "Re-fetch a tracked component from its registry origin", + "intro": "Refresh %{name}", + "noop": "Nothing written — files already match, or the origin refused to overwrite.", + "args": { + "name": "Inventory key to re-fetch" + } + }, + "registry": { + "description": "Local registry tools", + "build": { + "description": "Emit a static registry from vuetify.json + on-disk files", + "intro": "Building local registry", + "done": "Wrote %{count} item(s) to %{dir}", + "hint": "Host the *parent* of %{dir}/ so `{origin}/registry/index.json` resolves (CLI always fetches under `/registry/`). Then: `vuetify add --registry `.", + "args": { + "outDir": "Output directory (default: registry)" + } + } } }, "prompts": { "proceed": "Do you want to proceed?", "add": { - "integration": "Choose an integration to add" + "integration": "Choose an integration to add", + "feature": "Choose what to add", + "resolve": "Several things match — which one?", + "example": "Which example?", + "install": "Install %{pkgs}?", + "tokens": "Map them in %{file}?", + "overwrite": "%{path} already exists. Overwrite?", + "pluginExample": "Also add a usage example from the docs?" }, "eslint": { "overwrite": "Found %{file}. Do you want to overwrite it?", @@ -347,7 +444,18 @@ }, "cancel": "Setup cancelled. You can run this command again anytime." }, + "errors": { + "registry": { + "unreachable": "Could not reach %{url} (%{reason}).", + "status": "%{url} responded with %{status}.", + "version": "The registry is version %{found}, but this CLI understands %{expected}. Update the CLI." + } + }, "spinners": { + "registry": { + "fetching": "Reading the registry...", + "fetched": "Registry read" + }, "template": { "downloading": "Downloading template %{template}...", "copied": "Template copied", diff --git a/packages/shared/src/i18n/locales/ru.json b/packages/shared/src/i18n/locales/ru.json index dd872f4..1325305 100644 --- a/packages/shared/src/i18n/locales/ru.json +++ b/packages/shared/src/i18n/locales/ru.json @@ -149,7 +149,8 @@ "description": "Какую интеграцию добавить (варианты: %{choices})", "available": "Доступные интеграции: %{choices}", "invalid": "Недопустимая интеграция: %{integration}. Доступные: %{choices}" - } + }, + "inventory": "Записано в %{file}" }, "update": { "description": "Обновить vuetify зависимости проекта", @@ -218,6 +219,26 @@ "rate_limited": "Достигнут лимит запросов GitHub. Установите GITHUB_TOKEN, чтобы увеличить лимит.", "failed": "Не удалось получить примечания к релизу (%{status}).", "none_found": "Подходящий релиз не найден." + }, + "list": { + "description": "Список компонентов из vuetify.json", + "intro": "Локальная библиотека компонентов", + "empty": "Компоненты ещё не отслеживаются.", + "hint": "Запустите `vuetify add `, чтобы добавить из реестра.", + "count": "%{count} компонент(ов)", + "local": "локальный", + "missing": "%{name}: нет на диске — %{files}", + "args": { + "json": "Вывести сырой vuetify.json" + } + }, + "status": { + "description": "Проверить, что отслеживаемые компоненты на месте", + "intro": "Статус библиотеки компонентов", + "noInventory": "В проекте нет vuetify.json.", + "missingFiles": "отсутствуют %{files}", + "noOrigin": "локальный (без origin реестра)", + "summary": "%{healthy} ok · %{broken} сломано · %{local} локальных · %{total} всего" } }, "prompts": {