diff --git a/eslint.config.ts b/eslint.config.ts index b3486f3bc..29c514fd4 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -33,6 +33,7 @@ const config: ReturnType = defineConfig( './bench/**', './playground/**', './packages/docs/**', + './packages/**/examples/**', './**/test/**', './**/src/**/*.test.ts', './**/src/**/*.test-d.ts' diff --git a/knip.config.ts b/knip.config.ts index 277bb1848..f3e619a75 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -9,6 +9,9 @@ export default { 'packages/gunshi': { entry: ['src/constants.ts'] }, + 'packages/plugin-completion': { + ignore: ['examples/**/*.ts'] + }, 'packages/docs': { entry: ['src/.vitepress/config.ts', 'src/.vitepress/theme/index.ts'] } diff --git a/package.json b/package.json index 01234342e..1af4c87fd 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build:docs": "pnpm -r build:docs", "build:gunshi": "pnpm -F gunshi build", "build:plugin": "pnpm -F @gunshi/bone build", + "build:plugin:completion": "pnpm -F @gunshi/plugin-completion build", "build:plugin:global": "pnpm -F @gunshi/plugin-global build", "build:plugin:i18n": "pnpm -F @gunshi/plugin-i18n build", "build:plugin:renderer": "pnpm -F @gunshi/plugin-renderer build", diff --git a/packages/gunshi/src/__snapshots__/cli.test.ts.snap b/packages/gunshi/src/__snapshots__/cli.test.ts.snap index 41b53314b..f5fce310e 100644 --- a/packages/gunshi/src/__snapshots__/cli.test.ts.snap +++ b/packages/gunshi/src/__snapshots__/cli.test.ts.snap @@ -57,7 +57,7 @@ OPTIONS: exports[`auto generate usage > loosely sub commands > command2 1`] = ` "USAGE: - COMMAND command2 + my-cli command2 OPTIONS: -h, --help Display this help message @@ -68,7 +68,16 @@ OPTIONS: exports[`auto generate usage > loosely sub commands > main 1`] = ` "USAGE: - COMMAND + my-cli [command1] + my-cli + +COMMANDS: + command2 + command1 + +For more info, run any command with the \`--help\` flag: + my-cli command2 --help + my-cli command1 --help OPTIONS: -h, --help Display this help message @@ -79,7 +88,16 @@ OPTIONS: exports[`auto generate usage > loosely sub commands 1`] = ` "USAGE: - COMMAND + my-cli [command1] + my-cli + +COMMANDS: + command2 + command1 + +For more info, run any command with the \`--help\` flag: + my-cli command2 --help + my-cli command1 --help OPTIONS: -h, --help Display this help message @@ -87,7 +105,7 @@ OPTIONS: -f, --foo USAGE: - COMMAND command2 + my-cli command2 OPTIONS: -h, --help Display this help message diff --git a/packages/gunshi/src/cli.test.ts b/packages/gunshi/src/cli.test.ts index 32a170a03..149c24e5e 100644 --- a/packages/gunshi/src/cli.test.ts +++ b/packages/gunshi/src/cli.test.ts @@ -390,6 +390,10 @@ describe('auto generate usage', () => { test('loosely sub commands', async () => { const utils = await import('./utils.ts') const log = defineMockLog(utils) + const meta = { + name: 'my-cli', + renderHeader: null // no header + } const entryArgs = { foo: { @@ -398,6 +402,7 @@ describe('auto generate usage', () => { } } satisfies Args const entry = { + name: 'command1', args: entryArgs, run: vi.fn() } satisfies Command> @@ -417,8 +422,10 @@ describe('auto generate usage', () => { const subCommands = new Map() subCommands.set('command2', command2) - expect(await cli(['-h'], entry, { subCommands })).toMatchSnapshot('main') - expect(await cli(['command2', '-h'], entry, { subCommands })).toMatchSnapshot('command2') + expect(await cli(['-h'], entry, { ...meta, subCommands })).toMatchSnapshot('main') + expect(await cli(['command2', '-h'], entry, { ...meta, subCommands })).toMatchSnapshot( + 'command2' + ) const message = log() expect(message).toMatchSnapshot() diff --git a/packages/gunshi/src/cli/core.ts b/packages/gunshi/src/cli/core.ts index ad8c48a9c..ba8109344 100644 --- a/packages/gunshi/src/cli/core.ts +++ b/packages/gunshi/src/cli/core.ts @@ -4,7 +4,7 @@ */ import { parseArgs, resolveArgs } from 'args-tokens' -import { ANONYMOUS_COMMAND_NAME, COMMAND_OPTIONS_DEFAULT, NOOP } from '../constants.ts' +import { ANONYMOUS_COMMAND_NAME, CLI_OPTIONS_DEFAULT, NOOP } from '../constants.ts' import { createCommandContext } from '../context.ts' import { createDecorators } from '../decorators.ts' import { createPluginContext } from '../plugin/context.ts' @@ -131,17 +131,17 @@ function resolveArguments( function createInitialSubCommands( options: CliOptions, - entry: Command | CommandRunner | LazyCommand + entryCmd: Command | CommandRunner | LazyCommand ): Map | LazyCommand> { const subCommands = new Map(options.subCommands) // add entry command to sub commands if there are sub commands - if (options.subCommands || subCommands.size > 0) { - if (isLazyCommand(entry)) { - if (entry.commandName) subCommands.set(entry.commandName, entry as LazyCommand) - } else if (typeof entry === 'object' && entry.name) { - subCommands.set(entry.name, entry as Command) - } + if ( + (options.subCommands || subCommands.size > 0) && + (isLazyCommand(entryCmd) || typeof entryCmd === 'object') + ) { + entryCmd.entry = true + subCommands.set(resolveEntryName(entryCmd as LazyCommand | Command), entryCmd) } return subCommands @@ -155,7 +155,7 @@ function normalizeCliOptions( // get the latest sub commands from plugin context (already includes entry command) const subCommands = new Map(pluginContext.subCommands) - const resolvedOptions = Object.assign(create>(), COMMAND_OPTIONS_DEFAULT, options, { + const resolvedOptions = Object.assign(create>(), CLI_OPTIONS_DEFAULT, options, { subCommands }) as CliOptions @@ -209,7 +209,7 @@ async function resolveCommand( } else { // inline command (command runner) return { - command: { run: entry as CommandRunner } as Command, + command: { run: entry as CommandRunner, entry: true } as Command, callMode: 'entry' } } @@ -251,8 +251,12 @@ async function resolveCommand( } } -function resolveEntryName(entry: Command): string { - return entry.name || ANONYMOUS_COMMAND_NAME +function resolveEntryName( + entry: Command | LazyCommand +): string { + return isLazyCommand(entry) + ? entry.commandName || ANONYMOUS_COMMAND_NAME + : entry.name || ANONYMOUS_COMMAND_NAME } function getPluginExtensions(plugins: Plugin[]): Record { diff --git a/packages/gunshi/src/constants.ts b/packages/gunshi/src/constants.ts index b596c6193..65ec551c8 100644 --- a/packages/gunshi/src/constants.ts +++ b/packages/gunshi/src/constants.ts @@ -9,7 +9,7 @@ export const ANONYMOUS_COMMAND_NAME = '(anonymous)' export const NOOP: () => void = () => {} -export const COMMAND_OPTIONS_DEFAULT: CliOptions = { +export const CLI_OPTIONS_DEFAULT: CliOptions = { name: undefined, description: undefined, version: undefined, diff --git a/packages/gunshi/src/context.ts b/packages/gunshi/src/context.ts index f4366d8c2..f152899d5 100644 --- a/packages/gunshi/src/context.ts +++ b/packages/gunshi/src/context.ts @@ -15,7 +15,7 @@ * @license MIT */ -import { ANONYMOUS_COMMAND_NAME, COMMAND_OPTIONS_DEFAULT, NOOP } from './constants.ts' +import { ANONYMOUS_COMMAND_NAME, CLI_OPTIONS_DEFAULT, NOOP } from './constants.ts' import { create, deepFreeze, isLazyCommand, log } from './utils.ts' import type { @@ -148,7 +148,7 @@ export async function createCommandContext< * setup the environment */ - const env = Object.assign(create>(), COMMAND_OPTIONS_DEFAULT, cliOptions) + const env = Object.assign(create>(), CLI_OPTIONS_DEFAULT, cliOptions) /** * create the command context diff --git a/packages/gunshi/src/definition.ts b/packages/gunshi/src/definition.ts index 70a67149b..171507a27 100644 --- a/packages/gunshi/src/definition.ts +++ b/packages/gunshi/src/definition.ts @@ -169,6 +169,7 @@ export function lazy( lazyCommand.args = definition.args lazyCommand.examples = definition.examples lazyCommand.internal = definition.internal + lazyCommand.entry = definition.entry // @ts-ignore - resource property is now provided by plugin-i18n if ('resource' in definition) { // @ts-ignore diff --git a/packages/gunshi/src/plugin.ts b/packages/gunshi/src/plugin.ts index e6f2dafc1..7c159aeaa 100644 --- a/packages/gunshi/src/plugin.ts +++ b/packages/gunshi/src/plugin.ts @@ -23,6 +23,8 @@ * @license MIT */ +export { CLI_OPTIONS_DEFAULT } from './constants.ts' +export { createCommandContext } from './context.ts' export { plugin } from './plugin/core.ts' export type { PluginContext } from './plugin/context.ts' diff --git a/packages/gunshi/src/types.ts b/packages/gunshi/src/types.ts index 59754576e..277f12623 100644 --- a/packages/gunshi/src/types.ts +++ b/packages/gunshi/src/types.ts @@ -414,6 +414,12 @@ export interface Command * @since v0.27.0 */ internal?: boolean + /** + * Whether this command is an entry command. + * @default undefined + * @since v0.27.0 + */ + entry?: boolean } /** diff --git a/packages/gunshi/src/utils.ts b/packages/gunshi/src/utils.ts index 54ced530c..4ee33955c 100644 --- a/packages/gunshi/src/utils.ts +++ b/packages/gunshi/src/utils.ts @@ -31,7 +31,8 @@ export async function resolveLazyCommand {} +}) + +const dev = define({ + name: 'dev', + description: 'Start dev server', + args: { + host: { + type: 'string', + description: 'Specify hostname', + short: 'H' + }, + port: { + type: 'number', + description: 'Specify port', + short: 'p' + } + }, + run: () => {} +}) + +const build = define({ + name: 'build', + description: 'Build project', + run: () => {} +}) + +const lint = define({ + name: 'lint', + description: 'Lint project', + args: { + files: { + type: 'positional', + description: 'Files to lint' + } + }, + run: () => {} +}) + +const subCommands = new Map>() +subCommands.set('dev', dev) +subCommands.set('build', build) +subCommands.set('lint', lint) + +await cli(process.argv.slice(2), entry, { + name: 'vite', + version: '0.0.0', + description: 'Vite CLI', + subCommands, + plugins: [ + completion({ + config: { + entry: { + args: { + config: { + handler: () => [ + { value: 'vite.config.ts', description: 'Vite config file' }, + { value: 'vite.config.js', description: 'Vite config file' } + ] + }, + mode: { + handler: () => [ + { value: 'development', description: 'Development mode' }, + { value: 'production', description: 'Production mode' } + ] + }, + logLevel: { + handler: () => [ + { value: 'info', description: 'Info level' }, + { value: 'warn', description: 'Warn level' }, + { value: 'error', description: 'Error level' }, + { value: 'silent', description: 'Silent level' } + ] + } + } + }, + subCommands: { + lint: { + handler: () => [ + { value: 'main.ts', description: 'Main file' }, + { value: 'index.ts', description: 'Index file' } + ] + }, + dev: { + args: { + port: { + handler: () => [ + { value: '3000', description: 'Development server port' }, + { value: '8080', description: 'Alternative port' } + ] + }, + host: { + handler: () => [ + { value: 'localhost', description: 'Localhost' }, + { value: '0.0.0.0', description: 'All interfaces' } + ] + } + } + } + } + } + }) + ] +}) diff --git a/packages/plugin-completion/package.json b/packages/plugin-completion/package.json index c6950a4fe..9cd9dae1d 100644 --- a/packages/plugin-completion/package.json +++ b/packages/plugin-completion/package.json @@ -55,12 +55,15 @@ "build": "tsdown", "lint:jsr": "jsr publish --dry-run --allow-dirty", "prepack": "pnpm build", - "typecheck:deno": "deno check ./src" + "typecheck:deno": "deno check --import-map=../../importmap.json ./src" }, "dependencies": { + "@bombsh/tab": "https://pkg.pr.new/bombshell-dev/tab@main", "@gunshi/plugin": "workspace:*" }, "devDependencies": { + "@gunshi/shared": "workspace:*", + "@types/node": "catalog:", "deno": "catalog:", "jsr": "catalog:", "jsr-exports-lint": "catalog:", diff --git a/packages/plugin-completion/src/__snapshots__/index.test.ts.snap b/packages/plugin-completion/src/__snapshots__/index.test.ts.snap new file mode 100644 index 000000000..ccf6ddad1 --- /dev/null +++ b/packages/plugin-completion/src/__snapshots__/index.test.ts.snap @@ -0,0 +1,128 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`default command > suggest duplicate options 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`default command > suggest duplicate options for short option 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`default command > suggest for inputting 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`default command > suggest for long option 1`] = ` +"--config Use specified config file +:4 +" +`; + +exports[`default command > suggest for short option 1`] = ` +"-c Use specified config file +:4 +" +`; + +exports[`default command > suggest value if option values correctly 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`no input 1`] = ` +"dev Start dev server +build Build project +lint Lint project +:4 +" +`; + +exports[`positional arguments > multiple positional arguments when ending with part of the value 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`positional arguments > single positional argument when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`positional arguments > suggest multiple positional arguments when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`subcommand > keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` +"--port Specify port +:4 +" +`; + +exports[`subcommand > not handle if unknown option 1`] = ` +":4 +" +`; + +exports[`subcommand > resolve value if long option and value 1`] = ` +"--port=3000 Development server port +:4 +" +`; + +exports[`subcommand > suggest for command only 1`] = ` +"dev Start dev server +:4 +" +`; + +exports[`subcommand > suggest for long option 1`] = ` +"--port Specify port +:4 +" +`; + +exports[`subcommand > suggest for option inputting 1`] = ` +"--host Specify hostname +--port Specify port +:4 +" +`; + +exports[`subcommand > suggest for short option 1`] = ` +"-H Specify hostname +:4 +" +`; + +exports[`subcommand > suggest if user ends with space after \`--port\` 1`] = ` +"3000 Development server port +8080 Alternative port +:4 +" +`; + +exports[`subcommand > user typed \`--port=\` and hasn't typed a space or value yet 1`] = ` +"--port=3000 Development server port +--port=8080 Alternative port +:4 +" +`; diff --git a/packages/plugin-completion/src/index.test.ts b/packages/plugin-completion/src/index.test.ts new file mode 100644 index 000000000..120aae347 --- /dev/null +++ b/packages/plugin-completion/src/index.test.ts @@ -0,0 +1,122 @@ +import { exec } from 'node:child_process' +import { describe, expect, test } from 'vitest' + +function runCommand(command: string): Promise { + return new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + reject(stderr) + } else { + resolve(stdout) + } + }) + }) +} + +const SCRIPT = `pnpx tsx packages/plugin-completion/examples/demo.node.ts complete --` + +test('no input', async () => { + const output = await runCommand(`${SCRIPT}`) + expect(output).toMatchSnapshot() +}) + +describe('default command', () => { + test('suggest for inputting', async () => { + const output = await runCommand(`${SCRIPT} --`) + expect(output).toMatchSnapshot() + }) + + test('suggest for long option', async () => { + const output = await runCommand(`${SCRIPT} --config`) + expect(output).toMatchSnapshot() + }) + + test('suggest duplicate options', async () => { + const output = await runCommand(`${SCRIPT} --config vite.config.js --`) + expect(output).toMatchSnapshot() + }) + + test('suggest value if option values correctly', async () => { + const output = await runCommand(`${SCRIPT} --config vite.config`) + expect(output).toMatchSnapshot() + }) + + test('suggest for short option', async () => { + const output = await runCommand(`${SCRIPT} -c `) + expect(output).toMatchSnapshot() + }) + + test('suggest duplicate options for short option', async () => { + const output = await runCommand(`${SCRIPT} -c vite.config.js --`) + expect(output).toMatchSnapshot() + }) +}) + +describe('subcommand', () => { + test('suggest for command only', async () => { + const output = await runCommand(`${SCRIPT} dev`) + expect(output).toMatchSnapshot() + }) + + test('suggest for option inputting', async () => { + const output = await runCommand(`${SCRIPT} dev --`) + expect(output).toMatchSnapshot() + }) + + test('suggest for long option', async () => { + const output = await runCommand(`${SCRIPT} dev --port`) + expect(output).toMatchSnapshot() + }) + + test('suggest for short option', async () => { + const output = await runCommand(`${SCRIPT} dev -H`) + expect(output).toMatchSnapshot() + }) + + test('not handle if unknown option', async () => { + const output = await runCommand(`${SCRIPT} dev --unknown`) + expect(output).toMatchSnapshot() + }) + + test('resolve value if long option and value', async () => { + const output = await runCommand(`${SCRIPT} dev --port=3`) + expect(output).toMatchSnapshot() + }) + + test('suggest if user ends with space after `--port`', async () => { + const output = await runCommand(`${SCRIPT} dev --port ""`) + expect(output).toMatchSnapshot() + }) + + test(`keep suggesting the --port option if user typed partial but didn't end with space`, async () => { + const output = await runCommand(`${SCRIPT} dev --po`) + expect(output).toMatchSnapshot() + }) + + test("user typed `--port=` and hasn't typed a space or value yet", async () => { + const output = await runCommand(`${SCRIPT} dev --port=`) + expect(output).toMatchSnapshot() + }) + + test.todo('suggest short option with equals sign', async () => { + const output = await runCommand(`${SCRIPT} dev -p=3`) + expect(output).toMatchSnapshot() + }) +}) + +describe('positional arguments', () => { + test('suggest multiple positional arguments when ending with space', async () => { + const output = await runCommand(`${SCRIPT} lint ""`) + expect(output).toMatchSnapshot() + }) + + test('multiple positional arguments when ending with part of the value', async () => { + const output = await runCommand(`${SCRIPT} lint ind`) + expect(output).toMatchSnapshot() + }) + + test('single positional argument when ending with space', async () => { + const output = await runCommand(`${SCRIPT} lint main.ts ""`) + expect(output).toMatchSnapshot() + }) +}) diff --git a/packages/plugin-completion/src/index.ts b/packages/plugin-completion/src/index.ts index d3c7d772c..16d830b63 100644 --- a/packages/plugin-completion/src/index.ts +++ b/packages/plugin-completion/src/index.ts @@ -3,22 +3,190 @@ * @license MIT */ +import { Completion, script } from '@bombsh/tab' import { plugin } from '@gunshi/plugin' +import { resolveLazyCommand } from '@gunshi/shared' +import { pluginId } from './types.ts' +import { quoteExec } from './utils.ts' -import type { PluginWithoutExtension } from '@gunshi/plugin' +import type { Handler } from '@bombsh/tab' +import type { Args, Command, LazyCommand, PluginContext, PluginWithExtension } from '@gunshi/plugin' +import type { CompletionCommandContext, CompletionConfig, CompletionOptions } from './types.ts' -export interface CompletionCommandContext {} +export * from './types.ts' + +const TERMINATOR = '--' + +const NOOP_HANDLER: Handler = () => { + return [] +} + +// NOTE(kazupon): we should use plugin-i18n for completion localization, but it is not ready yet. +// const i18nPluginId = namespacedId('i18n') /** * completion plugin for gunshi */ -export default function completion(): PluginWithoutExtension { +export default function completion( + options: CompletionOptions = {} +): PluginWithExtension { + const config = options.config || {} + const completion = new Completion() + return plugin({ - id: 'g:completion', + id: pluginId, name: 'completion', - setup(_ctx) { - // TODO(kazupon): implement completion plugin logic + // NOTE(kazupon): disable dependencies for now, because plugin-i18n is not still ready yet for completion + // dependencies: [{ id: i18nPluginId, optional: true }], + + async setup(ctx) { + /** + * add command for completion script generation + */ + + const completeName = 'complete' + ctx.addCommand(completeName, { + name: completeName, + // TODO(kazupon): support description localization + description: 'Generate shell completion script', + run: async cmdCtx => { + if (!cmdCtx.env.name) { + throw new Error('your cli name is not defined.') + } + + let shell: string | undefined = cmdCtx._[1] + if (shell === TERMINATOR) { + shell = undefined + } + + if (shell === undefined) { + const extra = cmdCtx._.slice(cmdCtx._.indexOf(TERMINATOR) + 1) + await completion.parse(extra) + } else { + script(shell as Parameters[0], cmdCtx.env.name, quoteExec()) + } + } + }) + + /** + * disable header renderer + */ + // TODO(kazupon): we might be change this to a more flexible way + ctx.decorateHeaderRenderer(async (_baseRenderer, _cmdCtx) => '') + }, + + // TODO(kazupon): type inference with plugin function type parameter + extension: (_ctx, _cmd): CompletionCommandContext => { + return {} as CompletionCommandContext + }, + + /** + * setup bombshell completion with `onExtension` hook + */ + + onExtension: async (ctx, _cmd) => { + // TODO(kazupon): type inference with plugin function type parameter, more improvements! + + // NOTE(kazupon): we should use plugin-i18n for completion localization, but it is not ready yet. + // const extensions = ctx.extensions as unknown as { [i18nPluginId]: I18nCommandContext } + // const i18n = extensions[i18nPluginId] + const subCommands = ctx.env.subCommands as ReadonlyMap + + const entry = [...subCommands].map(([_, cmd]) => cmd).find(cmd => cmd.entry) + if (!entry) { + throw new Error('entry command not found.') + } + + // NOTE(kazupon): we should use localizeDescription here, but it is not ready yet. + // const entryCtx = await createCommandContext(entry) + // const localizeDescription = localizable( + // entryCtx as unknown as CommandContext, + // cmd, + // i18n ? i18n.translate : undefined + // ) + + // setup root level completion + const isPositional = hasPositional(await resolveLazyCommand(entry as Command)) + const root = '' + completion.addCommand( + root, + entry.description || '', + // NOTE(kazupon): we should use localizeDescription here, but it is not ready yet. + // (await localizeDescription('description')) || entry.description || '', + isPositional ? [false] : [], + NOOP_HANDLER + ) + + const args = entry.args || (Object.create(null) as Args) + for (const [key, schema] of Object.entries(args)) { + if (schema.type === 'positional') { + continue // skip positional arguments on subcommands + } + // TODO(kazupon): more tweaking for root option completion + completion.addOption( + root, + `--${key}`, + schema.description || '', + // NOTE(kazupon): we should use localizeDescription here, but it is not ready yet. + // (await localizeDescription(resolveArgKey(key))) || schema.description || '', + config.entry?.args?.[key]?.handler || NOOP_HANDLER, + schema.short + ) + } + + await handleSubCommands(completion, subCommands, config.subCommands /* , i18n*/) } }) } + +async function handleSubCommands( + completion: Completion, + subCommands: PluginContext['subCommands'], + configs: Record = {} + // NOTE(kazupon): we should use i18n for subcommand localization, but it is not ready yet. + // i18n?: I18nCommandContext | undefined +) { + for (const [name, cmd] of subCommands) { + if (cmd.internal || cmd.entry || name === 'complete') { + continue // skip entry / internal command / completion command itself + } + + const resolvedCmd = await resolveLazyCommand(cmd) + // NOTE(kazupon): we should use localizeDescription here, but it is not ready yet. + // const ctx = await createCommandContext(resolvedCmd) + // const localizeDescription = localizable(ctx, resolvedCmd, i18n ? i18n.translate : undefined) + + const isPositional = hasPositional(resolvedCmd) + // TODO(kazupon): more tweaking for subcommand completion + const commandName = completion.addCommand( + name, + resolvedCmd.description || '', + // NOTE(kazupon): we should use localizeDescription here, but it is not ready yet. + // (await localizeDescription('description')) || resolvedCmd.description || '', + isPositional ? [false] : [], + configs?.[name]?.handler || NOOP_HANDLER + ) + + const args = resolvedCmd.args || (Object.create(null) as Args) + for (const [key, schema] of Object.entries(args)) { + if (schema.type === 'positional') { + continue // skip positional arguments on subcommands + } + // TODO(kazupon): more tweaking for subcommand option completion + completion.addOption( + commandName, + `--${key}`, + schema.description || '', + // NOTE(kazupon): we should use localizeDescription here, but it is not ready yet. + // (await localizeDescription(resolveArgKey(key))) || schema.description || '', + configs[commandName]?.args?.[key]?.handler || NOOP_HANDLER, + schema.short + ) + } + } +} + +function hasPositional(cmd: Command | LazyCommand) { + return cmd.args && Object.values(cmd.args).some(arg => arg.type === 'positional') +} diff --git a/packages/plugin-completion/src/types.ts b/packages/plugin-completion/src/types.ts new file mode 100644 index 000000000..9fad999bb --- /dev/null +++ b/packages/plugin-completion/src/types.ts @@ -0,0 +1,50 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { namespacedId, PLUGIN_PREFIX } from '@gunshi/shared' + +import type { Handler } from '@bombsh/tab' +import type { GenerateNamespacedKey } from '@gunshi/shared' + +/** + * The unique identifier for the completion plugin. + */ +export const pluginId: GenerateNamespacedKey<'completion', typeof PLUGIN_PREFIX> = + namespacedId('completion') + +/** + * Type representing the unique identifier for the completion plugin. + */ +export type PluginId = typeof pluginId + +/** + * Extended command context which provides utilities via completion plugin. + * These utilities are available via `CommandContext.extensions['g:completion']`. + */ +export interface CompletionCommandContext {} + +/** + * Completion configuration, which structure is similar `bombsh/tab`'s `CompletionConfig`. + */ +export interface CompletionConfig { + handler?: Handler + args?: Record +} + +/** + * Completion plugin options. + */ +export interface CompletionOptions { + config?: { + /** + * The entry point completion configuration. + */ + entry?: CompletionConfig + /** + * The handlers for subcommands. + */ + subCommands?: Record + } +} diff --git a/packages/plugin-completion/src/utils.ts b/packages/plugin-completion/src/utils.ts new file mode 100644 index 000000000..6ddf72e7e --- /dev/null +++ b/packages/plugin-completion/src/utils.ts @@ -0,0 +1,73 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +// NOTE(kazupon): comment out, because it is not used yet. +// import { CLI_OPTIONS_DEFAULT, createCommandContext as _createCommandContext } from '@gunshi/plugin' +// +// import type { Args, Command, CommandContext, LazyCommand } from '@gunshi/plugin' +// +// async function createCommandContext(cmd: Command | LazyCommand): Promise { +// return await _createCommandContext({ +// args: cmd.args || (Object.create(null) as Args), +// values: Object.create(null), +// positionals: [], +// rest: [], +// argv: [], +// tokens: [], +// omitted: false, +// callMode: cmd.entry ? 'entry' : 'subCommand', +// command: cmd, +// extensions: Object.create(null), +// cliOptions: CLI_OPTIONS_DEFAULT +// }) +// } + +function detectRuntime(): 'bun' | 'deno' | 'node' | 'unknown' { + // @ts-ignore -- NOTE: ignore, because `process` will detect ts compile error on `deno check` + if (globalThis.process !== undefined && globalThis.process.release?.name === 'node') { + return 'node' + } + // @ts-ignore -- NOTE: ignore, because development env is node.js + if (globalThis.Deno !== undefined) { + return 'deno' + } + // @ts-ignore -- NOTE: ignore, because development env is node.js + if (globalThis.Bun !== undefined) { + return 'bun' + } + return 'unknown' +} + +function quoteIfNeeded(path: string): string { + return path.includes(' ') ? `'${path}'` : path +} + +export function quoteExec(): string { + const runtime = detectRuntime() + switch (runtime) { + case 'node': { + // @ts-ignore -- NOTE: ignore, because `process` will detect ts compile error on `deno check` + const execPath = globalThis.process.execPath + // @ts-ignore -- NOTE: ignore, because `process` will detect ts compile error on `deno check` + const processArgs = globalThis.process.argv.slice(1) + const quotedExecPath = quoteIfNeeded(execPath) + // eslint-disable-next-line unicorn/no-array-callback-reference + const quotedProcessArgs = processArgs.map(quoteIfNeeded) + // @ts-ignore -- NOTE: ignore, because `process` will detect ts compile error on `deno check` + // eslint-disable-next-line unicorn/no-array-callback-reference + const quotedProcessExecArgs = globalThis.process.execArgv.map(quoteIfNeeded) + return `${quotedExecPath} ${quotedProcessExecArgs.join(' ')} ${quotedProcessArgs[0]}` + } + case 'deno': { + throw new Error('deno not implemented yet, welcome contributions :)') + } + case 'bun': { + throw new Error('bun not implemented yet, welcome contributions :)') + } + default: { + throw new Error('Unsupported your javascript runtime for completion script generation.') + } + } +} diff --git a/packages/plugin-completion/tsdown.config.ts b/packages/plugin-completion/tsdown.config.ts index 36adf282d..473a3abdf 100644 --- a/packages/plugin-completion/tsdown.config.ts +++ b/packages/plugin-completion/tsdown.config.ts @@ -7,6 +7,7 @@ const config: ReturnType = defineConfig({ clean: true, publint: true, dts: true, + noExternal: ['@bombsh/tab'], external: ['@gunshi/plugin'], hooks: { 'build:done': lintJsrExports() diff --git a/packages/plugin-global/src/decorator.ts b/packages/plugin-global/src/decorator.ts index 38fa45542..40fe37273 100644 --- a/packages/plugin-global/src/decorator.ts +++ b/packages/plugin-global/src/decorator.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { pluginId as I18n } from './types.ts' +import { pluginId as Global } from './types.ts' import type { CommandDecorator, DefaultGunshiParams } from '@gunshi/plugin' import type { GlobalCommandContext } from './extension.ts' @@ -22,7 +22,7 @@ const decorator: CommandDecorator<{ values, validationError, extensions: { - [I18n]: { showVersion, showHeader, showUsage, showValidationErrors } + [Global]: { showVersion, showHeader, showUsage, showValidationErrors } } } = ctx diff --git a/packages/plugin-i18n/src/types.ts b/packages/plugin-i18n/src/types.ts index 815db4dcc..267833289 100644 --- a/packages/plugin-i18n/src/types.ts +++ b/packages/plugin-i18n/src/types.ts @@ -13,19 +13,14 @@ import type { GunshiParamsConstraint, NormalizeToGunshiParams } from '@gunshi/plugin' -import { - ARG_PREFIX, - CommandArgKeys, - CommandBuiltinKeys, - namespacedId, - PLUGIN_PREFIX -} from '@gunshi/shared' +import { ARG_PREFIX, CommandBuiltinKeys, namespacedId, PLUGIN_PREFIX } from '@gunshi/shared' import type { BuiltinResourceKeys, GenerateNamespacedKey, KeyOfArgs, - RemovedIndex + RemovedIndex, + Translation } from '@gunshi/shared' /** @@ -56,14 +51,7 @@ export interface I18nCommandContext = DefaultGunshiP * - For custom keys: returns an empty string ('') * - For built-in keys (prefixed with '_:'): returns the key itself */ - translate: < - T extends string = CommandBuiltinKeys, - O = CommandArgKeys, - K = CommandBuiltinKeys | O | T - >( - key: K, - values?: Record - ) => string + translate: Translation } /** diff --git a/packages/plugin-renderer/src/index.ts b/packages/plugin-renderer/src/index.ts index 3852d17c3..b11385fe4 100644 --- a/packages/plugin-renderer/src/index.ts +++ b/packages/plugin-renderer/src/index.ts @@ -30,18 +30,10 @@ */ import { plugin } from '@gunshi/plugin' -import { - ARG_NEGATABLE_PREFIX, - ARG_PREFIX_AND_KEY_SEPARATOR, - BUILD_IN_PREFIX_AND_KEY_SEPARATOR, - DefaultResource, - namespacedId, - resolveExamples, - resolveLazyCommand -} from '@gunshi/shared' +import { localizable, namespacedId, resolveLazyCommand } from '@gunshi/shared' import { renderHeader } from './header.ts' import { pluginId as id } from './types.ts' -import { makeShortLongOptionPair, renderUsage } from './usage.ts' +import { renderUsage } from './usage.ts' import { renderValidationErrors } from './validation.ts' import type { @@ -54,7 +46,7 @@ import type { PluginWithExtension } from '@gunshi/plugin' import type { I18nCommandContext } from '@gunshi/plugin-i18n' -import type { CommandArgKeys, CommandBuiltinKeys } from '@gunshi/shared' +import type { CommandBuiltinKeys } from '@gunshi/shared' import type { PluginId, UsageRendererCommandContext } from './types.ts' export { renderHeader } from './header.ts' @@ -111,43 +103,8 @@ export default function renderer(): PluginWithExtension !cmd.internal).filter(Boolean)) } - async function text< - T extends string = CommandBuiltinKeys, - O = CommandArgKeys, - K = CommandBuiltinKeys | O | T - >(key: K, values: Record = Object.create(null)): Promise { - if (i18n) { - return i18n.translate(key, values) - } else { - if ((key as string).startsWith(BUILD_IN_PREFIX_AND_KEY_SEPARATOR)) { - const resKey = (key as string).slice(BUILD_IN_PREFIX_AND_KEY_SEPARATOR.length) - return DefaultResource[resKey as keyof typeof DefaultResource] || (key as string) - } else if ((key as string).startsWith(ARG_PREFIX_AND_KEY_SEPARATOR)) { - let argKey = (key as string).slice(ARG_PREFIX_AND_KEY_SEPARATOR.length) - let negatable = false - if (argKey.startsWith(ARG_NEGATABLE_PREFIX)) { - argKey = argKey.slice(ARG_NEGATABLE_PREFIX.length) - negatable = true - } - const schema = ctx.args[argKey as keyof typeof ctx.args] - return negatable && schema.type === 'boolean' && schema.negatable - ? `${DefaultResource['NEGATABLE']} ${makeShortLongOptionPair(schema, argKey, ctx.toKebab)}` - : schema.description || '' - } else { - // if the key is a built-in key 'description' and 'examples', return empty string, because the these keys are resolved by the renderer itself. - if (key === 'description') { - return '' - } else if (key === 'examples') { - return await resolveExamples(ctx, cmd.examples) - } else { - return key as string - } - } - } - } - return { - text, + text: localizable(ctx, cmd, i18n?.translate), loadCommands } }, diff --git a/packages/plugin-renderer/src/types.ts b/packages/plugin-renderer/src/types.ts index bb01fa4bf..3fa1be15a 100644 --- a/packages/plugin-renderer/src/types.ts +++ b/packages/plugin-renderer/src/types.ts @@ -6,7 +6,7 @@ import { namespacedId, PLUGIN_PREFIX } from '@gunshi/shared' import type { Command, DefaultGunshiParams, GunshiParams } from '@gunshi/plugin' -import type { CommandArgKeys, CommandBuiltinKeys, GenerateNamespacedKey } from '@gunshi/shared' +import type { CommandBuiltinKeys, GenerateNamespacedKey, Localization } from '@gunshi/shared' /** * The unique identifier for usage renderer plugin. @@ -28,14 +28,7 @@ export interface UsageRendererCommandContext = Defau /** * Render the text message */ - text: < - T extends string = CommandBuiltinKeys, - O = CommandArgKeys, - K = CommandBuiltinKeys | O | T - >( - key: K, - values?: Record - ) => Promise + text: Localization /** * Load commands * @returns A list of commands loaded from the command loader plugin. diff --git a/packages/plugin-renderer/src/usage.ts b/packages/plugin-renderer/src/usage.ts index 1d34bfd79..0ad3c79f4 100644 --- a/packages/plugin-renderer/src/usage.ts +++ b/packages/plugin-renderer/src/usage.ts @@ -8,6 +8,7 @@ import { COMMON_ARGS, resolveExamples as _resolvedExamples, kebabnize, + makeShortLongOptionPair, resolveArgKey, resolveBuiltInKey } from '@gunshi/shared' @@ -324,20 +325,6 @@ async function generateOptionsSymbols< : '' } -export function makeShortLongOptionPair( - schema: ArgSchema, - name: string, - toKebab?: boolean -): string { - // Convert camelCase to kebab-case for display in help text if toKebab is true - const displayName = toKebab || schema.toKebab ? kebabnize(name) : name - let key = `--${displayName}` - if (schema.short) { - key = `-${schema.short}, ${key}` - } - return key -} - /** * Get optional arguments pairs for usage * @param ctx A {@link CommandContext | command context} diff --git a/packages/shared/jsr.json b/packages/shared/jsr.json index d4210c0d9..a09b5c4fd 100644 --- a/packages/shared/jsr.json +++ b/packages/shared/jsr.json @@ -8,6 +8,7 @@ "publish": { "include": ["src/**/*.ts", "package.json", "jsr.json", "README.md", "CHANGELOG.md", "LICENSE"], "exclude": [ + "examples/**/*.ts", "src/**/*.test.ts", "src/**/*.test-d.ts", "**/__snapshots__/**", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c76baa7c2..7b03488b9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,7 @@ export * from 'gunshi/utils' export * from './constants.ts' +export * from './localize.ts' export { default as DefaultResource } from './resource.ts' export * from './utils.ts' diff --git a/packages/shared/src/localize.test.ts b/packages/shared/src/localize.test.ts new file mode 100644 index 000000000..126fd5603 --- /dev/null +++ b/packages/shared/src/localize.test.ts @@ -0,0 +1,101 @@ +import { define } from 'gunshi' +import { createCommandContext } from 'gunshi/context' +import { describe, expect, test, vi } from 'vitest' +import { localizable } from './localize.ts' +import { resolveArgKey, resolveBuiltInKey } from './utils.ts' + +const LANG_RESOURCES = { + description: 'これはcommand1の説明です', + foo: 'foo引数の説明' +} as Record + +async function setup() { + const command1 = define({ + name: 'command1', + description: 'Command 1 description', + args: { + foo: { + type: 'string', + description: 'Foo argument description', + short: 'f' + }, + bar: { + type: 'boolean', + description: 'Bar argument description', + negatable: true + } + }, + examples: () => { + return `command1 --foo value --no-bar` + } + }) + + const ctx = await createCommandContext({ + args: command1.args!, + values: {}, + positionals: [], + rest: [], + argv: [], + tokens: [], + omitted: false, + callMode: 'subCommand', + command: command1, + extensions: {}, + cliOptions: {} + }) + + return { ctx, command1 } +} + +test('with translation function', async () => { + const mockTranslate = vi + .fn() + .mockImplementation((key: string, _values: Record): string => { + return LANG_RESOURCES[key] || key + }) + + const { ctx, command1 } = await setup() + const localize = localizable(ctx, command1, mockTranslate) + + expect(await localize('description')).toEqual(LANG_RESOURCES['description']) + expect(await localize('foo')).toEqual(LANG_RESOURCES['foo']) + expect(await localize('nonexistent_key')).toEqual('nonexistent_key') +}) + +describe('without translation function', () => { + test('gunshi built-in keys', async () => { + const { ctx, command1 } = await setup() + const localize = localizable(ctx, command1) + + expect(await localize(resolveBuiltInKey('USAGE'))).toEqual('USAGE') + expect(await localize(resolveBuiltInKey('help'))).toEqual('Display this help message') + }) + + test('gunshi args keys', async () => { + const { ctx, command1 } = await setup() + const localize = localizable(ctx, command1) + + // normal argument + expect(await localize(resolveArgKey>('foo'))).toEqual( + 'Foo argument description' + ) + // negatable argument + expect(await localize(resolveArgKey>('no-bar'))).toEqual( + 'Negatable of --bar' + ) + // non-existent argument + expect(await localize(resolveArgKey('test'))).toEqual('test') + }) + + test('other keys', async () => { + const { ctx, command1 } = await setup() + const localize = localizable(ctx, command1) + + // `description` key + expect(await localize('description')).toEqual('') + // `examples` key + expect(await localize('examples')).toEqual('command1 --foo value --no-bar') + // other keys + expect(await localize('other_key')).toEqual('other_key') + }) +}) diff --git a/packages/shared/src/localize.ts b/packages/shared/src/localize.ts new file mode 100644 index 000000000..9a5d90972 --- /dev/null +++ b/packages/shared/src/localize.ts @@ -0,0 +1,68 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { + ARG_NEGATABLE_PREFIX, + ARG_PREFIX_AND_KEY_SEPARATOR, + BUILD_IN_PREFIX_AND_KEY_SEPARATOR +} from './constants.ts' +import DefaultResource from './resource.ts' +import { makeShortLongOptionPair, resolveExamples } from './utils.ts' + +import type { Command, CommandContext, DefaultGunshiParams, GunshiParams } from 'gunshi' +import type { CommandArgKeys, CommandBuiltinKeys, Translation } from './types.ts' + +export interface Localization< + T extends string = CommandBuiltinKeys, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + G extends GunshiParams = DefaultGunshiParams +> { + , K = CommandBuiltinKeys | O | T>( + key: K, + values?: Record + ): Promise +} + +export function localizable( + ctx: CommandContext, + cmd: Command, + translate?: Translation +): Localization { + async function localize(key: string, values?: Record): Promise { + if (translate) { + return translate(key as T, values) + } else { + if ((key as string).startsWith(BUILD_IN_PREFIX_AND_KEY_SEPARATOR)) { + const resKey = (key as string).slice(BUILD_IN_PREFIX_AND_KEY_SEPARATOR.length) + return DefaultResource[resKey as keyof typeof DefaultResource] || (key as string) + } else if ((key as string).startsWith(ARG_PREFIX_AND_KEY_SEPARATOR)) { + let argKey = (key as string).slice(ARG_PREFIX_AND_KEY_SEPARATOR.length) + let negatable = false + if (argKey.startsWith(ARG_NEGATABLE_PREFIX)) { + argKey = argKey.slice(ARG_NEGATABLE_PREFIX.length) + negatable = true + } + const schema = ctx.args[argKey as keyof typeof ctx.args] + if (!schema) { + return argKey + } + return negatable && schema.type === 'boolean' && schema.negatable + ? `${DefaultResource['NEGATABLE']} ${makeShortLongOptionPair(schema, argKey, ctx.toKebab)}` + : schema.description || '' + } else { + // if the key is a built-in key 'description' and 'examples', return empty string, because the these keys are resolved by the user. + if (key === 'description') { + return '' + } else if (key === 'examples') { + return await resolveExamples(ctx, cmd.examples) + } else { + return key as string + } + } + } + } + + return localize as Localization +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2c19dc2c5..fdd07322e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -5,7 +5,7 @@ import { ARG_PREFIX, BUILT_IN_KEY_SEPARATOR, BUILT_IN_PREFIX } from './constants.ts' -import type { Args } from 'gunshi' +import type { Args, DefaultGunshiParams, GunshiParams } from 'gunshi' type RemoveIndexSignature = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] @@ -69,3 +69,17 @@ export type CommandArgKeys = GenerateNamespacedKey< KeyOfArgs>, typeof ARG_PREFIX > + +/** + * Translation function interface + */ +export interface Translation< + T extends string = CommandBuiltinKeys, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + G extends GunshiParams = DefaultGunshiParams +> { + , K = CommandBuiltinKeys | O | T>( + key: K, + values?: Record + ): string +} diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 3705275c4..fc51abe54 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -3,10 +3,12 @@ * @license MIT */ +import { kebabnize } from 'gunshi/utils' import { ARG_PREFIX, BUILT_IN_KEY_SEPARATOR, BUILT_IN_PREFIX, PLUGIN_PREFIX } from './constants.ts' import type { Args, + ArgSchema, CommandContext, CommandExamplesFetcher, DefaultGunshiParams, @@ -49,3 +51,17 @@ export function namespacedId( ): GenerateNamespacedKey { return `${PLUGIN_PREFIX}${BUILT_IN_KEY_SEPARATOR}${id}` } + +export function makeShortLongOptionPair( + schema: ArgSchema, + name: string, + toKebab?: boolean +): string { + // Convert camelCase to kebab-case for display in help text if toKebab is true + const displayName = toKebab || schema.toKebab ? kebabnize(name) : name + let key = `--${displayName}` + if (schema.short) { + key = `-${schema.short}, ${key}` + } + return key +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3aa58fb98..0add6303e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,10 +276,19 @@ importers: packages/plugin-completion: dependencies: + '@bombsh/tab': + specifier: https://pkg.pr.new/bombshell-dev/tab@main + version: tab@https://pkg.pr.new/bombshell-dev/tab@main '@gunshi/plugin': specifier: workspace:* version: link:../plugin devDependencies: + '@gunshi/shared': + specifier: workspace:* + version: link:../shared + '@types/node': + specifier: 'catalog:' + version: 22.16.0 deno: specifier: 'catalog:' version: 2.4.0 @@ -1913,9 +1922,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@22.15.29': - resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} - '@types/node@22.16.0': resolution: {integrity: sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==} @@ -4497,6 +4503,10 @@ packages: resolution: {integrity: sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==} engines: {node: ^14.18.0 || >=16.0.0} + tab@https://pkg.pr.new/bombshell-dev/tab@main: + resolution: {tarball: https://pkg.pr.new/bombshell-dev/tab@main} + version: 0.0.0 + tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} @@ -6128,10 +6138,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@22.15.29': - dependencies: - undici-types: 6.21.0 - '@types/node@22.16.0': dependencies: undici-types: 6.21.0 @@ -6743,7 +6749,7 @@ snapshots: bun-types@1.2.18(@types/react@19.1.8): dependencies: - '@types/node': 22.15.29 + '@types/node': 22.16.0 '@types/react': 19.1.8 byte-size@9.0.1: {} @@ -9259,6 +9265,10 @@ snapshots: dependencies: '@pkgr/core': 0.2.4 + tab@https://pkg.pr.new/bombshell-dev/tab@main: + dependencies: + mri: 1.2.0 + tabbable@6.2.0: {} tapable@2.2.1: {} diff --git a/scripts/jsr.ts b/scripts/jsr.ts index a2f94c38d..4fa9f80fc 100644 --- a/scripts/jsr.ts +++ b/scripts/jsr.ts @@ -47,6 +47,7 @@ function updatePkgJson(pkg: string, json: Record): Record): Record