diff --git a/components/cli/src/command/plugin/create.tsx b/components/cli/src/command/plugin/create.tsx index b63bb21..bd18c7c 100644 --- a/components/cli/src/command/plugin/create.tsx +++ b/components/cli/src/command/plugin/create.tsx @@ -9,8 +9,9 @@ import type { Logger } from '../../domain/contracts/logger'; import type { CommandBus, QueryBus } from '../../domain/contracts/bus'; import { ALLOWED_BITS, type AllowedBits } from '../../domain/use-cases/generate-plugin-keypair'; import { writeKeypairFiles } from '../../core/helpers/keypair-files'; +import { isValidReverseDns, resolveIdentifier, REVERSE_DNS_HINT } from '../../core/helpers/plugin-identifier'; import { CreatePluginJourney } from '../../ui/journeys/create-plugin/create-plugin.journey'; -import type { CreatePluginStore } from '../../ui/journeys/create-plugin/create-store'; +import { DEFAULT_PLUGIN_URL, type CreatePluginStore } from '../../ui/journeys/create-plugin/create-store'; type Deps = { logger: Logger; @@ -24,7 +25,7 @@ type Flags = { name?: string; identifier?: string; author?: string; - vendorUrl?: string; + pluginUrl?: string; bits: string; kid: string; install: boolean; // commander: --no-install => false @@ -33,13 +34,6 @@ type Flags = { const EXIT_INVALID_FLAGS = 2; -const kebab = (value: string) => - value - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - const fail = (logger: Logger, code: number, message: string): never => { logger.error(message); logger.flush(); @@ -58,9 +52,9 @@ export const createPluginCreateCommand = ({ command.addArgument(new Argument('', 'Target folder (must be empty or not exist).')); command.addArgument(new Argument('[skeleton-identifier]', 'Skeleton identifier to use.')); command.addOption(new Option('--name ', 'Plugin display name.')); - command.addOption(new Option('--identifier ', 'Plugin identifier (kebab-case).')); + command.addOption(new Option('--identifier ', `Plugin identifier (${REVERSE_DNS_HINT}).`)); command.addOption(new Option('--author ', 'Author name.')); - command.addOption(new Option('--vendor-url ', 'Vendor URL.')); + command.addOption(new Option('--plugin-url ', `Plugin URL (defaults to ${DEFAULT_PLUGIN_URL}).`)); command.addOption(new Option('--bits ', 'RSA modulus length.').default('2048')); command.addOption(new Option('--kid ', 'Key id.').default('public')); command.addOption(new Option('--no-install', 'Skip dependency installation.')); @@ -83,21 +77,26 @@ export const createPluginCreateCommand = ({ ? fail(logger, EXIT_INVALID_FLAGS, `unknown skeleton "${skeletonIdentifier}"`) : undefined); + // A provided --identifier must be valid reverse-DNS; otherwise derive a default from the name. + const resolveIdentifierOrFail = (rawId: string | undefined, name: string): string => + rawId && !isValidReverseDns(rawId.trim()) + ? fail(logger, EXIT_INVALID_FLAGS, `invalid --identifier "${rawId}". must be ${REVERSE_DNS_HINT}`) + : resolveIdentifier(rawId, name); + const headless = flags.interactive === false || !process.stdin.isTTY; if (headless) { if (!skeleton) fail(logger, EXIT_INVALID_FLAGS, 'non-interactive mode requires a [skeleton-identifier]'); if (!flags.name) fail(logger, EXIT_INVALID_FLAGS, 'non-interactive mode requires --name'); if (!flags.author) fail(logger, EXIT_INVALID_FLAGS, 'non-interactive mode requires --author'); - if (!flags.vendorUrl) fail(logger, EXIT_INVALID_FLAGS, 'non-interactive mode requires --vendor-url'); // Assign to typed locals so the try block retains non-optional types. // tsc does not propagate narrowing from `if (!x) fail(...)` into subsequent const assignments // (known limitation), so one `!` per local is required here. const resolvedSkeleton = skeleton!; const resolvedName = flags.name!; const resolvedAuthor = flags.author!; - const resolvedVendorUrl = flags.vendorUrl!; - const identifier = kebab(flags.identifier || resolvedName); + const resolvedPluginUrl = flags.pluginUrl ?? DEFAULT_PLUGIN_URL; + const identifier = resolveIdentifierOrFail(flags.identifier, resolvedName); try { const dlQuery = queryBus.createQuery('DownloadPluginSkeletonArchive', { @@ -131,7 +130,7 @@ export const createPluginCreateCommand = ({ '{{plugin_name}}': resolvedName, '{{plugin_identifier}}': identifier, '{{author_name}}': resolvedAuthor, - '{{vendor_url}}': resolvedVendorUrl, + '{{vendor_url}}': resolvedPluginUrl, '{{public_jwk}}': JSON.stringify(resolvedPair.publicJwk), '{{private_jwk}}': JSON.stringify(resolvedPair.privateJwk), '{{kid}}': flags.kid, @@ -162,12 +161,12 @@ export const createPluginCreateCommand = ({ if (cmd.getOptionValueSource('bits') === 'cli' || cmd.getOptionValueSource('kid') === 'cli') { storage.set(atoms.setKeypairAtom, { bits: bits as AllowedBits, kid: flags.kid }); } - if (flags.name && flags.author && flags.vendorUrl) { + if (flags.name && flags.author) { storage.set(atoms.setInfoAtom, { name: flags.name, - identifier: kebab(flags.identifier || flags.name), + identifier: resolveIdentifierOrFail(flags.identifier, flags.name), author: flags.author, - vendorUrl: flags.vendorUrl, + pluginUrl: flags.pluginUrl ?? DEFAULT_PLUGIN_URL, }); } diff --git a/components/cli/src/content/completion_file.bash b/components/cli/src/content/completion_file.bash index 42aca33..007107b 100644 --- a/components/cli/src/content/completion_file.bash +++ b/components/cli/src/content/completion_file.bash @@ -159,7 +159,7 @@ _crystallize_completions() { fi case "${subcmd}" in create) - local options="--name= --identifier= --author= --vendor-url= --bits= --kid= --no-install --no-interactive ${default_options}" + local options="--name= --identifier= --author= --plugin-url= --bits= --kid= --no-install --no-interactive ${default_options}" COMPREPLY=($(compgen -W "${options}" -- "${cur}")) return 0 ;; diff --git a/components/cli/src/core/helpers/plugin-identifier.test.ts b/components/cli/src/core/helpers/plugin-identifier.test.ts new file mode 100644 index 0000000..9b38f03 --- /dev/null +++ b/components/cli/src/core/helpers/plugin-identifier.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { deriveReverseDns, isValidReverseDns, kebab, resolveIdentifier } from './plugin-identifier'; + +describe('plugin-identifier', () => { + test('kebab normalizes arbitrary input', () => { + expect(kebab('My Cool Plugin')).toBe('my-cool-plugin'); + expect(kebab(' Spaced Out ')).toBe('spaced-out'); + expect(kebab('Already-kebab')).toBe('already-kebab'); + expect(kebab('Mixed_Case 123')).toBe('mixed-case-123'); + }); + + test('isValidReverseDns accepts well-formed reverse-DNS', () => { + expect(isValidReverseDns('com.yourcompany.plugin')).toBe(true); + expect(isValidReverseDns('com.acme')).toBe(true); + expect(isValidReverseDns('com.acme-inc.my-plugin')).toBe(true); + expect(isValidReverseDns('io.crystallize.plugins.demo')).toBe(true); + }); + + test('isValidReverseDns rejects malformed input', () => { + expect(isValidReverseDns('my-plugin')).toBe(false); // no dot + expect(isValidReverseDns('Com.Acme.Plugin')).toBe(false); // uppercase + expect(isValidReverseDns('com..plugin')).toBe(false); // empty segment + expect(isValidReverseDns('com.plugin.')).toBe(false); // trailing dot + expect(isValidReverseDns('.com.plugin')).toBe(false); // leading dot + expect(isValidReverseDns('com plugin')).toBe(false); // space + expect(isValidReverseDns('')).toBe(false); + }); + + test('deriveReverseDns builds a default from the plugin name', () => { + expect(deriveReverseDns('My Cool Plugin')).toBe('com.example.my-cool-plugin'); + expect(deriveReverseDns('')).toBe('com.example.my-plugin'); + }); + + test('resolveIdentifier prefers valid input, falls back to derived default', () => { + expect(resolveIdentifier('com.acme.demo', 'Whatever')).toBe('com.acme.demo'); + expect(resolveIdentifier(' com.acme.demo ', 'Whatever')).toBe('com.acme.demo'); + expect(resolveIdentifier('', 'My Plugin')).toBe('com.example.my-plugin'); + expect(resolveIdentifier(undefined, 'My Plugin')).toBe('com.example.my-plugin'); + }); + + test('resolveIdentifier throws on non-empty malformed input', () => { + expect(() => resolveIdentifier('Bad Id', 'Name')).toThrow(/invalid identifier/); + expect(() => resolveIdentifier('no-dots', 'Name')).toThrow(/reverse-DNS/); + }); +}); diff --git a/components/cli/src/core/helpers/plugin-identifier.ts b/components/cli/src/core/helpers/plugin-identifier.ts new file mode 100644 index 0000000..d20049c --- /dev/null +++ b/components/cli/src/core/helpers/plugin-identifier.ts @@ -0,0 +1,33 @@ +// Plugin identifiers are reverse-DNS (e.g. `com.yourcompany.plugin`): lowercase, +// dot-separated segments, each segment alphanumeric with optional internal hyphens, +// and at least one dot. +const REVERSE_DNS = /^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+$/; + +export const kebab = (value: string): string => + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +export const isValidReverseDns = (value: string): boolean => REVERSE_DNS.test(value); + +// Suggested default when the user does not provide an identifier. Derived from the +// plugin name, e.g. "My Cool Plugin" -> "com.example.my-cool-plugin". +export const deriveReverseDns = (name: string): string => `com.example.${kebab(name) || 'my-plugin'}`; + +export const REVERSE_DNS_HINT = 'reverse-DNS, e.g. com.yourcompany.plugin'; + +// Resolves the final identifier: a trimmed, valid input wins; an empty input falls +// back to the name-derived default. A non-empty but malformed input throws so the +// caller can surface a validation error. +export const resolveIdentifier = (input: string | undefined, name: string): string => { + const trimmed = (input ?? '').trim(); + if (trimmed.length === 0) { + return deriveReverseDns(name); + } + if (!isValidReverseDns(trimmed)) { + throw new Error(`invalid identifier "${trimmed}". must be ${REVERSE_DNS_HINT}`); + } + return trimmed; +}; diff --git a/components/cli/src/ui/journeys/create-plugin/actions/replace-tokens.tsx b/components/cli/src/ui/journeys/create-plugin/actions/replace-tokens.tsx index b36f5fc..4b45819 100644 --- a/components/cli/src/ui/journeys/create-plugin/actions/replace-tokens.tsx +++ b/components/cli/src/ui/journeys/create-plugin/actions/replace-tokens.tsx @@ -29,7 +29,7 @@ export const ReplaceTokens = ({ store, commandBus }: ReplaceTokensProps) => { '{{plugin_name}}': state.info.name, '{{plugin_identifier}}': state.info.identifier, '{{author_name}}': state.info.author, - '{{vendor_url}}': state.info.vendorUrl, + '{{vendor_url}}': state.info.pluginUrl, '{{public_jwk}}': state.publicJwkCompact, '{{private_jwk}}': state.privateJwkCompact, '{{kid}}': state.keypair.kid, diff --git a/components/cli/src/ui/journeys/create-plugin/create-plugin.journey.tsx b/components/cli/src/ui/journeys/create-plugin/create-plugin.journey.tsx index 02d5fc2..7a080df 100644 --- a/components/cli/src/ui/journeys/create-plugin/create-plugin.journey.tsx +++ b/components/cli/src/ui/journeys/create-plugin/create-plugin.journey.tsx @@ -55,7 +55,7 @@ export const CreatePluginJourney = ({ store, queryBus, commandBus, flySystem, lo {state.isTokensReplaced && } {state.isFullfilled && ( - {`## Plugin "${state.info?.name}" is ready!\n\nYour plugin has been scaffolded in \`${state.folder}\`.\n\n> **Keep \`private.jwk.json\` secret** — it is gitignored and must never be committed or shared.\n\n### Next steps\n\n- Register the plugin revision in Crystallize using the generated \`public.jwk.json\`.\n- Test payload decryption locally with \`crystallize plugin decrypt-payload\`.\n- See the Crystallize plugins docs at https://crystallize.com/learn/concepts/plugins\n`} + {`## Plugin "${state.info?.name}" is ready!\n\nYour plugin has been scaffolded in \`${state.folder}\`.\n\n> **Keep \`private.jwk.json\` secret** — it is gitignored and must never be committed or shared.\n\n### Next steps\n\n- Register the plugin revision in Crystallize using the generated \`public.jwk.json\`.\n- Test payload decryption locally with \`crystallize plugin decrypt-payload\`.\n- See the Crystallize plugins docs at https://crystallize.com/docs/developer/plugins\n`} )} ); diff --git a/components/cli/src/ui/journeys/create-plugin/create-store.ts b/components/cli/src/ui/journeys/create-plugin/create-store.ts index 8f44526..9215130 100644 --- a/components/cli/src/ui/journeys/create-plugin/create-store.ts +++ b/components/cli/src/ui/journeys/create-plugin/create-store.ts @@ -1,11 +1,15 @@ import { atom, createStore } from 'jotai'; import type { PluginSkeleton } from '../../../domain/contracts/models/plugin-skeleton'; +// Default plugin URL used when the deployment URL is not yet known. Matches the +// skeleton's dev server `PORT=5173`. +export const DEFAULT_PLUGIN_URL = 'http://localhost:5173/'; + export type PluginInfo = { name: string; identifier: string; author: string; - vendorUrl: string; + pluginUrl: string; }; export type KeypairSettings = { diff --git a/components/cli/src/ui/journeys/create-plugin/questions/collect-plugin-info.tsx b/components/cli/src/ui/journeys/create-plugin/questions/collect-plugin-info.tsx index 275f8bc..08f792e 100644 --- a/components/cli/src/ui/journeys/create-plugin/questions/collect-plugin-info.tsx +++ b/components/cli/src/ui/journeys/create-plugin/questions/collect-plugin-info.tsx @@ -3,19 +3,14 @@ import { UncontrolledTextInput } from 'ink-text-input'; import { useState } from 'react'; import { useAtom } from 'jotai'; import { colors } from '../../../../core/styles'; -import type { CreatePluginStore, PluginInfo } from '../create-store'; +import { deriveReverseDns, isValidReverseDns, REVERSE_DNS_HINT } from '../../../../core/helpers/plugin-identifier'; +import { DEFAULT_PLUGIN_URL, type CreatePluginStore, type PluginInfo } from '../create-store'; type CollectPluginInfoProps = { store: CreatePluginStore['atoms']; }; -type Field = 'name' | 'identifier' | 'author' | 'vendorUrl'; - -const toKebab = (value: string): string => - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); +type Field = 'name' | 'identifier' | 'author' | 'pluginUrl'; export const CollectPluginInfo = ({ store }: CollectPluginInfoProps) => { const [state] = useAtom(store.stateAtom); @@ -25,35 +20,42 @@ export const CollectPluginInfo = ({ store }: CollectPluginInfoProps) => { const [identifier, setIdentifier] = useState(''); const [author, setAuthor] = useState(''); const [currentField, setCurrentField] = useState('name'); + const [identifierError, setIdentifierError] = useState(); if (state.info) { return ( Plugin: {state.info.name} ( {state.info.identifier}) by{' '} - {state.info.author} — {state.info.vendorUrl} + {state.info.author} — {state.info.pluginUrl} ); } - const derivedIdentifier = toKebab(identifier || name) || 'my-plugin'; + const derivedIdentifier = deriveReverseDns(name); const handleSubmit = (value: string) => { if (currentField === 'name') { setName(value); setCurrentField('identifier'); } else if (currentField === 'identifier') { - setIdentifier(value); + const candidate = value.trim() || derivedIdentifier; + if (!isValidReverseDns(candidate)) { + setIdentifierError(`"${value.trim()}" is not valid — use ${REVERSE_DNS_HINT}.`); + return; + } + setIdentifierError(undefined); + setIdentifier(candidate); setCurrentField('author'); } else if (currentField === 'author') { setAuthor(value); - setCurrentField('vendorUrl'); - } else if (currentField === 'vendorUrl') { + setCurrentField('pluginUrl'); + } else if (currentField === 'pluginUrl') { const finalInfo: PluginInfo = { name, - identifier: derivedIdentifier, + identifier, author, - vendorUrl: value, + pluginUrl: value.trim() || DEFAULT_PLUGIN_URL, }; setInfo(finalInfo); } @@ -61,9 +63,9 @@ export const CollectPluginInfo = ({ store }: CollectPluginInfoProps) => { const fieldLabel = (): string => { if (currentField === 'name') return 'Plugin name:'; - if (currentField === 'identifier') return `Identifier (kebab-case, default "${toKebab(name) || 'my-plugin'}"):`; + if (currentField === 'identifier') return `Identifier (${REVERSE_DNS_HINT}, default "${derivedIdentifier}"):`; if (currentField === 'author') return 'Author:'; - return 'Vendor URL:'; + return `Plugin URL (default "${DEFAULT_PLUGIN_URL}"):`; }; return ( @@ -73,21 +75,22 @@ export const CollectPluginInfo = ({ store }: CollectPluginInfoProps) => { Name: {name} )} - {currentField !== 'identifier' && currentField !== 'name' && ( + {identifier && currentField !== 'identifier' && ( - Identifier: {derivedIdentifier} + Identifier: {identifier} )} - {author && currentField === 'vendorUrl' && ( + {author && currentField === 'pluginUrl' && ( Author: {author} )} + {currentField === 'identifier' && identifierError && {identifierError}} {fieldLabel()} - + );