Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions components/cli/src/command/plugin/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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();
Expand All @@ -58,9 +52,9 @@ export const createPluginCreateCommand = ({
command.addArgument(new Argument('<folder>', 'Target folder (must be empty or not exist).'));
command.addArgument(new Argument('[skeleton-identifier]', 'Skeleton identifier to use.'));
command.addOption(new Option('--name <name>', 'Plugin display name.'));
command.addOption(new Option('--identifier <id>', 'Plugin identifier (kebab-case).'));
command.addOption(new Option('--identifier <id>', `Plugin identifier (${REVERSE_DNS_HINT}).`));
command.addOption(new Option('--author <author>', 'Author name.'));
command.addOption(new Option('--vendor-url <url>', 'Vendor URL.'));
command.addOption(new Option('--plugin-url <url>', `Plugin URL (defaults to ${DEFAULT_PLUGIN_URL}).`));
command.addOption(new Option('--bits <n>', 'RSA modulus length.').default('2048'));
command.addOption(new Option('--kid <kid>', 'Key id.').default('public'));
command.addOption(new Option('--no-install', 'Skip dependency installation.'));
Expand All @@ -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', {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}

Expand Down
2 changes: 1 addition & 1 deletion components/cli/src/content/completion_file.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
;;
Expand Down
45 changes: 45 additions & 0 deletions components/cli/src/core/helpers/plugin-identifier.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
33 changes: 33 additions & 0 deletions components/cli/src/core/helpers/plugin-identifier.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const CreatePluginJourney = ({ store, queryBus, commandBus, flySystem, lo
{state.isTokensReplaced && <InstallDependencies store={store} commandBus={commandBus} />}
<Messages messages={state.messages} />
{state.isFullfilled && (
<Success>{`## 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`}</Success>
<Success>{`## 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`}</Success>
)}
</>
);
Expand Down
6 changes: 5 additions & 1 deletion components/cli/src/ui/journeys/create-plugin/create-store.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -25,45 +20,52 @@ export const CollectPluginInfo = ({ store }: CollectPluginInfoProps) => {
const [identifier, setIdentifier] = useState('');
const [author, setAuthor] = useState('');
const [currentField, setCurrentField] = useState<Field>('name');
const [identifierError, setIdentifierError] = useState<string>();

if (state.info) {
return (
<Text>
Plugin: <Text color={colors.highlight}>{state.info.name}</Text> (
<Text color={colors.highlight}>{state.info.identifier}</Text>) by{' '}
<Text color={colors.highlight}>{state.info.author}</Text> — {state.info.vendorUrl}
<Text color={colors.highlight}>{state.info.author}</Text> — {state.info.pluginUrl}
</Text>
);
}

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);
}
};

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 (
Expand All @@ -73,21 +75,22 @@ export const CollectPluginInfo = ({ store }: CollectPluginInfoProps) => {
Name: <Text color={colors.highlight}>{name}</Text>
</Text>
)}
{currentField !== 'identifier' && currentField !== 'name' && (
{identifier && currentField !== 'identifier' && (
<Text dimColor>
Identifier: <Text color={colors.highlight}>{derivedIdentifier}</Text>
Identifier: <Text color={colors.highlight}>{identifier}</Text>
</Text>
)}
{author && currentField === 'vendorUrl' && (
{author && currentField === 'pluginUrl' && (
<Text dimColor>
Author: <Text color={colors.highlight}>{author}</Text>
</Text>
)}
{currentField === 'identifier' && identifierError && <Text color={colors.error}>{identifierError}</Text>}
<Box>
<Box marginRight={1}>
<Text>{fieldLabel()}</Text>
</Box>
<UncontrolledTextInput key={currentField} onSubmit={handleSubmit} />
<UncontrolledTextInput key={`${currentField}-${identifierError ?? ''}`} onSubmit={handleSubmit} />
</Box>
</Box>
);
Expand Down
Loading