Skip to content
Draft
12 changes: 12 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -26,6 +32,12 @@ export const main = defineCommand({
init,
presets,
add,
generate,
diff,
refresh,
registry,
list,
status,
mcp,
update,
docs,
Expand Down
92 changes: 81 additions & 11 deletions packages/shared/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -14,38 +22,100 @@ 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"...')
await runCommand(mcp, { rawArgs: ['install'] })
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'))
}
},
})
49 changes: 49 additions & 0 deletions packages/shared/src/commands/diff.ts
Original file line number Diff line number Diff line change
@@ -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
}
},
})
44 changes: 44 additions & 0 deletions packages/shared/src/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -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
}
},
})
6 changes: 6 additions & 0 deletions packages/shared/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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'
58 changes: 58 additions & 0 deletions packages/shared/src/commands/list.ts
Original file line number Diff line number Diff line change
@@ -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 }))
},
})
45 changes: 45 additions & 0 deletions packages/shared/src/commands/refresh.ts
Original file line number Diff line number Diff line change
@@ -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
}
},
})
Loading