|
| 1 | +import { Argument, Command, Option } from 'commander'; |
| 2 | +import { Box, Text, Newline, render } from 'ink'; |
| 3 | +import { Provider } from 'jotai'; |
| 4 | +import path from 'node:path'; |
| 5 | +import { logo } from '../..'; |
| 6 | +import { pluginSkeletons } from '../../content/plugin-skeletons'; |
| 7 | +import type { FlySystem } from '../../domain/contracts/fly-system'; |
| 8 | +import type { Logger } from '../../domain/contracts/logger'; |
| 9 | +import type { CommandBus, QueryBus } from '../../domain/contracts/bus'; |
| 10 | +import { ALLOWED_BITS, type AllowedBits } from '../../domain/use-cases/generate-plugin-keypair'; |
| 11 | +import { writeKeypairFiles } from '../../core/helpers/keypair-files'; |
| 12 | +import { CreatePluginJourney } from '../../ui/journeys/create-plugin/create-plugin.journey'; |
| 13 | +import type { CreatePluginStore } from '../../ui/journeys/create-plugin/create-store'; |
| 14 | + |
| 15 | +type Deps = { |
| 16 | + logger: Logger; |
| 17 | + flySystem: FlySystem; |
| 18 | + queryBus: QueryBus; |
| 19 | + commandBus: CommandBus; |
| 20 | + createPluginCommandStore: CreatePluginStore; |
| 21 | +}; |
| 22 | + |
| 23 | +type Flags = { |
| 24 | + name?: string; |
| 25 | + identifier?: string; |
| 26 | + author?: string; |
| 27 | + vendorUrl?: string; |
| 28 | + bits: string; |
| 29 | + kid: string; |
| 30 | + install: boolean; // commander: --no-install => false |
| 31 | + interactive: boolean; // commander: --no-interactive => false |
| 32 | +}; |
| 33 | + |
| 34 | +const EXIT_INVALID_FLAGS = 2; |
| 35 | + |
| 36 | +const kebab = (value: string) => |
| 37 | + value |
| 38 | + .toLowerCase() |
| 39 | + .trim() |
| 40 | + .replace(/[^a-z0-9]+/g, '-') |
| 41 | + .replace(/^-+|-+$/g, ''); |
| 42 | + |
| 43 | +const fail = (logger: Logger, code: number, message: string): never => { |
| 44 | + logger.error(message); |
| 45 | + logger.flush(); |
| 46 | + process.exit(code); |
| 47 | +}; |
| 48 | + |
| 49 | +export const createPluginCreateCommand = ({ |
| 50 | + logger, |
| 51 | + flySystem, |
| 52 | + queryBus, |
| 53 | + commandBus, |
| 54 | + createPluginCommandStore, |
| 55 | +}: Deps): Command => { |
| 56 | + const command = new Command('create'); |
| 57 | + command.description('Scaffold a new Crystallize plugin from a skeleton.'); |
| 58 | + command.addArgument(new Argument('<folder>', 'Target folder (must be empty or not exist).')); |
| 59 | + command.addArgument(new Argument('[skeleton-identifier]', 'Skeleton identifier to use.')); |
| 60 | + command.addOption(new Option('--name <name>', 'Plugin display name.')); |
| 61 | + command.addOption(new Option('--identifier <id>', 'Plugin identifier (kebab-case).')); |
| 62 | + command.addOption(new Option('--author <author>', 'Author name.')); |
| 63 | + command.addOption(new Option('--vendor-url <url>', 'Vendor URL.')); |
| 64 | + command.addOption(new Option('--bits <n>', 'RSA modulus length.').default('2048')); |
| 65 | + command.addOption(new Option('--kid <kid>', 'Key id.').default('public')); |
| 66 | + command.addOption(new Option('--no-install', 'Skip dependency installation.')); |
| 67 | + command.addOption(new Option('--no-interactive', 'Run headless (all inputs via flags).')); |
| 68 | + |
| 69 | + command.action(async (folder: string, skeletonIdentifier: string | undefined, flags: Flags, cmd: Command) => { |
| 70 | + await flySystem.createDirectoryOrFail(folder, 'Please provide an empty folder for the plugin.'); |
| 71 | + |
| 72 | + const bits = Number(flags.bits); |
| 73 | + if (!Number.isInteger(bits) || !ALLOWED_BITS.includes(bits as AllowedBits)) { |
| 74 | + fail( |
| 75 | + logger, |
| 76 | + EXIT_INVALID_FLAGS, |
| 77 | + `invalid --bits: ${flags.bits}. must be one of ${ALLOWED_BITS.join(', ')}`, |
| 78 | + ); |
| 79 | + } |
| 80 | + const skeleton = |
| 81 | + pluginSkeletons.find((s) => s.identifier === skeletonIdentifier) ?? |
| 82 | + (skeletonIdentifier |
| 83 | + ? fail(logger, EXIT_INVALID_FLAGS, `unknown skeleton "${skeletonIdentifier}"`) |
| 84 | + : undefined); |
| 85 | + |
| 86 | + const headless = flags.interactive === false || !process.stdin.isTTY; |
| 87 | + |
| 88 | + if (headless) { |
| 89 | + if (!skeleton) fail(logger, EXIT_INVALID_FLAGS, 'non-interactive mode requires a [skeleton-identifier]'); |
| 90 | + if (!flags.name) fail(logger, EXIT_INVALID_FLAGS, 'non-interactive mode requires --name'); |
| 91 | + if (!flags.author) fail(logger, EXIT_INVALID_FLAGS, 'non-interactive mode requires --author'); |
| 92 | + if (!flags.vendorUrl) fail(logger, EXIT_INVALID_FLAGS, 'non-interactive mode requires --vendor-url'); |
| 93 | + // Assign to typed locals so the try block retains non-optional types. |
| 94 | + // tsc does not propagate narrowing from `if (!x) fail(...)` into subsequent const assignments |
| 95 | + // (known limitation), so one `!` per local is required here. |
| 96 | + const resolvedSkeleton = skeleton!; |
| 97 | + const resolvedName = flags.name!; |
| 98 | + const resolvedAuthor = flags.author!; |
| 99 | + const resolvedVendorUrl = flags.vendorUrl!; |
| 100 | + const identifier = kebab(flags.identifier || resolvedName); |
| 101 | + |
| 102 | + try { |
| 103 | + const dlQuery = queryBus.createQuery('DownloadPluginSkeletonArchive', { |
| 104 | + skeleton: resolvedSkeleton, |
| 105 | + destination: folder, |
| 106 | + }); |
| 107 | + await queryBus.dispatch(dlQuery); |
| 108 | + logger.info('skeleton downloaded'); |
| 109 | + |
| 110 | + const kpCmd = commandBus.createCommand('GeneratePluginKeypair', { |
| 111 | + bits: bits as AllowedBits, |
| 112 | + kid: flags.kid, |
| 113 | + }); |
| 114 | + const { result: pair } = await commandBus.dispatch(kpCmd); |
| 115 | + if (!pair) fail(logger, 1, 'failed to generate keypair'); |
| 116 | + // pair is narrowed to non-undefined here; assign to a const so tsc retains that inside the try block. |
| 117 | + const resolvedPair = pair!; |
| 118 | + await writeKeypairFiles({ |
| 119 | + flySystem, |
| 120 | + logger, |
| 121 | + pair: resolvedPair, |
| 122 | + publicPath: path.join(folder, 'public.jwk.json'), |
| 123 | + privatePath: path.join(folder, 'private.jwk.json'), |
| 124 | + autoGitignore: true, |
| 125 | + }); |
| 126 | + logger.info('keypair generated'); |
| 127 | + |
| 128 | + const rtCmd = commandBus.createCommand('ReplacePluginTokens', { |
| 129 | + folder, |
| 130 | + tokens: { |
| 131 | + '{{plugin_name}}': resolvedName, |
| 132 | + '{{plugin_identifier}}': identifier, |
| 133 | + '{{author_name}}': resolvedAuthor, |
| 134 | + '{{vendor_url}}': resolvedVendorUrl, |
| 135 | + '{{public_jwk}}': JSON.stringify(resolvedPair.publicJwk), |
| 136 | + '{{private_jwk}}': JSON.stringify(resolvedPair.privateJwk), |
| 137 | + '{{kid}}': flags.kid, |
| 138 | + }, |
| 139 | + }); |
| 140 | + await commandBus.dispatch(rtCmd); |
| 141 | + logger.info('tokens substituted'); |
| 142 | + |
| 143 | + if (flags.install !== false) { |
| 144 | + const inCmd = commandBus.createCommand('InstallPluginDependencies', { folder }); |
| 145 | + await commandBus.dispatch(inCmd); |
| 146 | + logger.info('dependencies installed'); |
| 147 | + } |
| 148 | + } catch (error) { |
| 149 | + const message = error instanceof Error ? error.message : String(error); |
| 150 | + fail(logger, 1, `plugin scaffolding failed: ${message}. the folder ${folder} was left in place.`); |
| 151 | + } |
| 152 | + logger.success(`plugin created in ${folder}`); |
| 153 | + logger.flush(); |
| 154 | + return; |
| 155 | + } |
| 156 | + |
| 157 | + logger.setBuffered(true); |
| 158 | + const { storage, atoms } = createPluginCommandStore; |
| 159 | + storage.set(atoms.setFolderAtom, folder); |
| 160 | + if (skeleton) storage.set(atoms.setSkeletonAtom, skeleton); |
| 161 | + if (flags.install === false) storage.set(atoms.setSkipInstallAtom, true); |
| 162 | + if (cmd.getOptionValueSource('bits') === 'cli' || cmd.getOptionValueSource('kid') === 'cli') { |
| 163 | + storage.set(atoms.setKeypairAtom, { bits: bits as AllowedBits, kid: flags.kid }); |
| 164 | + } |
| 165 | + if (flags.name && flags.author && flags.vendorUrl) { |
| 166 | + storage.set(atoms.setInfoAtom, { |
| 167 | + name: flags.name, |
| 168 | + identifier: kebab(flags.identifier || flags.name), |
| 169 | + author: flags.author, |
| 170 | + vendorUrl: flags.vendorUrl, |
| 171 | + }); |
| 172 | + } |
| 173 | + |
| 174 | + console.log(logo); |
| 175 | + const { waitUntilExit } = render( |
| 176 | + <Box flexDirection="column" padding={1}> |
| 177 | + <Box flexDirection="column" marginBottom={1}> |
| 178 | + <Text> |
| 179 | + Let's create your Crystallize plugin! <Newline /> |
| 180 | + </Text> |
| 181 | + <Provider store={storage}> |
| 182 | + <CreatePluginJourney |
| 183 | + store={atoms} |
| 184 | + queryBus={queryBus} |
| 185 | + commandBus={commandBus} |
| 186 | + flySystem={flySystem} |
| 187 | + logger={logger} |
| 188 | + /> |
| 189 | + </Provider> |
| 190 | + </Box> |
| 191 | + </Box>, |
| 192 | + { exitOnCtrlC: true }, |
| 193 | + ); |
| 194 | + await waitUntilExit(); |
| 195 | + }); |
| 196 | + |
| 197 | + return command; |
| 198 | +}; |
0 commit comments