Skip to content

Commit c2cea10

Browse files
committed
feat(cli): new create plugin command
1 parent 26a7bdd commit c2cea10

23 files changed

Lines changed: 1236 additions & 23 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [5.28.0]
6+
7+
- added `plugin create <folder> [skeleton-identifier]` command to scaffold a new Crystallize plugin from a selectable git skeleton: downloads the skeleton tarball (re-rooting an optional monorepo subfolder), generates an RSA JWK keypair (reusing `plugin keygen`'s generator, writing `public.jwk.json` 0644 / `private.jwk.json` 0600 and ensuring `private.jwk.json` is gitignored even in a fresh non-git folder), substitutes mustache tokens, and installs dependencies. Interactive Ink journey by default; fully scriptable headless path via `--no-interactive` (`--name`, `--identifier`, `--author`, `--vendor-url`, `--bits`, `--kid`, `--no-install`).
8+
- `plugin create` token substitution covers file contents and file/directory names across the scaffold (skipping `node_modules`/`.git`, binary files, and symlinks): `{{plugin_name}}`, `{{plugin_identifier}}`, `{{author_name}}`, `{{vendor_url}}`, `{{public_jwk}}`, `{{private_jwk}}`, `{{kid}}`.
9+
- `plugin create` materializes `*.dist` templates to their final filenames before token substitution (e.g. `.env.dist` → `.env`, `dev-payload.config.jsonc.dist` → `dev-payload.config.jsonc`), without overwriting an existing target, so the generated files receive their tokens too.
10+
- the no-lockfile dependency-install default for `plugin create` is `npm` (lockfiles still auto-detect bun/pnpm/npm/yarn).
11+
- added bash completion for the new `plugin create` command.
12+
513
## [5.27.0]
614

715
- bumped `@crystallize/js-api-client` to ^7.2.0 (renamed `signature` → `signatureStatus`, `backendToken` → `backendTokenStatus` on the decrypter result; `envelope.config` → `envelope.configuration`; new `tenantId`, `event`, `signatureSecret`, `staticAuthToken` fields on `CrystallizePluginPayload`) and bumped `jose` to ^6 (drops the `KeyLike` re-export in favor of native `CryptoKey`).

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ The CLI enhances Developer Experience while also being CI/CD-friendly for automa
3939

4040
- **Mass Operations:** Experimental but functional. You can `dump` your content model as JSON, `run` mass operations, and `execute` sets of mutations.
4141

42+
- **Plugin Development:** Scaffold a new Crystallize plugin from a skeleton, generate its keypair, and wire up its config in one command.
43+
4244
## Interactivity
4345

4446
Most commands are interactive. If credentials are missing, or input is required, the CLI will prompt you. To disable interactivity (e.g., in a CI environment), use `--no-interactive` where applicable.
@@ -62,6 +64,30 @@ An interactive command designed to simplify setting up boilerplates.
6264
- `folder` (required): The installation directory.
6365
- `tenant-identifier` and `boilerplate-identifier` (optional): If omitted, a wizard will guide you.
6466

67+
## Plugin Development
68+
69+
Scaffold a new Crystallize plugin from a selectable git skeleton.
70+
71+
```bash
72+
~/crystallize plugin create <folder> [skeleton-identifier]
73+
```
74+
75+
- `folder` (required): Target directory (must be empty or not exist).
76+
- `skeleton-identifier` (optional): If omitted, an interactive wizard lets you pick one.
77+
78+
The command downloads the skeleton (a git repo, optionally a subfolder of a monorepo), generates an RSA JWK keypair (`public.jwk.json` / `private.jwk.json`, the private key auto-added to `.gitignore`), substitutes mustache tokens, and installs dependencies.
79+
80+
Tokens replaced across file contents and file/directory names: `{{plugin_name}}`, `{{plugin_identifier}}`, `{{author_name}}`, `{{vendor_url}}`, `{{public_jwk}}`, `{{private_jwk}}`, `{{kid}}`. Any `*.dist` template is also materialized to its final filename (e.g. `.env.dist` → `.env`) before substitution, without overwriting an existing target.
81+
82+
For CI / scripting, run non-interactively:
83+
84+
```bash
85+
~/crystallize plugin create ./my-plugin hono-plugin-skeleton --no-interactive \
86+
--name "My Plugin" --author "Acme" --vendor-url https://acme.example --no-install
87+
```
88+
89+
Other plugin subcommands (`keygen`, `encrypt-secret`, `decrypt-payload`) help generate keys and test the plugin payload protocol locally.
90+
6591
## Tenant Management
6692

6793
### Creating a Tenant

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@crystallize/cli",
3-
"version": "5.27.0",
3+
"version": "5.28.0",
44
"description": "Crystallize CLI",
55
"license": "MIT",
66
"contributors": [

‎src/command/plugin/create.tsx‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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+
};

‎src/command/plugin/keygen.ts‎

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { Command, Option } from 'commander';
22
import path from 'node:path';
33
import { chmod, stat } from 'node:fs/promises';
4-
import { spawnSync } from 'node:child_process';
54
import { createInterface } from 'node:readline/promises';
65
import pc from 'picocolors';
76
import type { Logger } from '../../domain/contracts/logger';
87
import type { FlySystem } from '../../domain/contracts/fly-system';
98
import type { CommandBus } from '../../domain/contracts/bus';
109
import { ALLOWED_BITS, type AllowedBits } from '../../domain/use-cases/generate-plugin-keypair';
10+
import { findGitRoot, gitignoreContains } from '../../core/helpers/keypair-files';
1111

1212
type Deps = {
1313
logger: Logger;
@@ -50,24 +50,6 @@ const promptYesNo = async (question: string): Promise<boolean> => {
5050
}
5151
};
5252

53-
const findGitRoot = (cwd: string): string | null => {
54-
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
55-
cwd,
56-
encoding: 'utf8',
57-
stdio: ['ignore', 'pipe', 'ignore'],
58-
});
59-
if (result.status !== 0) return null;
60-
const output = result.stdout.trim();
61-
return output.length > 0 ? output : null;
62-
};
63-
64-
const gitignoreContains = (content: string, name: string): boolean => {
65-
return content
66-
.split(/\r?\n/)
67-
.map((line) => line.trim())
68-
.some((line) => line === name || line === `/${name}` || line === `./${name}`);
69-
};
70-
7153
export const createPluginKeygenCommand = ({ logger, flySystem, commandBus }: Deps): Command => {
7254
const command = new Command('keygen');
7355
command.description('Generate an RSA JWK keypair for a Crystallize plugin revision.');

‎src/content/completion_file.bash‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,16 @@ _crystallize_completions() {
153153
;;
154154
plugin)
155155
if [[ "${COMP_CWORD}" -eq 2 ]]; then
156-
local options="keygen encrypt-secret decrypt-payload ${default_options}"
156+
local options="create keygen encrypt-secret decrypt-payload ${default_options}"
157157
COMPREPLY=($(compgen -W "${options}" -- "${cur}"))
158158
return 0
159159
fi
160160
case "${subcmd}" in
161+
create)
162+
local options="--name= --identifier= --author= --vendor-url= --bits= --kid= --no-install --no-interactive ${default_options}"
163+
COMPREPLY=($(compgen -W "${options}" -- "${cur}"))
164+
return 0
165+
;;
161166
keygen)
162167
local options="--bits= --kid= --out-dir= --public-out= --private-out= --stdout --public-only --force --yes ${default_options}"
163168
COMPREPLY=($(compgen -W "${options}" -- "${cur}"))

‎src/content/plugin-skeletons.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { PluginSkeleton } from '../domain/contracts/models/plugin-skeleton';
2+
3+
export const pluginSkeletons: PluginSkeleton[] = [
4+
{
5+
identifier: 'hono-plugin-skeleton',
6+
name: 'Hono Plugin Skeleton',
7+
baseline: 'Minimal Crystallize plugin server built with Hono and React',
8+
description: 'A vendor-hosted plugin upstream server: config schema, post-install webhook, payload decryption.',
9+
git: 'https://github.com/CrystallizeAPI/plugins',
10+
subfolder: 'plugins/crystallize-plugin-skeleton',
11+
branch: 'main',
12+
},
13+
{
14+
identifier: 'cloudflare-hono-plugin-buddy',
15+
name: 'Cloudflare Hono Plugin Buddy (not a Skeleton but an example)',
16+
baseline: 'Crystallize plugin server built with Hono and React, designed for deployment on Cloudflare Workers.',
17+
description:
18+
'A vendor-hosted plugin upstream server: config schema, post-install webhook, payload decryption. Includes Cloudflare-specific optimizations and deployment configuration.',
19+
git: 'https://github.com/CrystallizeAPI/plugins',
20+
subfolder: 'plugins/crystallize-buddy',
21+
branch: 'main',
22+
},
23+
{
24+
identifier: 'hello-world-plugin',
25+
name: 'Hello World Plugin (not a Skeleton but an example)',
26+
baseline: 'A simple Crystallize plugin server.',
27+
description:
28+
'A minimal plugin server that responds to installation and configuration events with basic logging. Ideal for learning and testing plugin development.',
29+
git: 'https://github.com/CrystallizeAPI/plugins',
30+
subfolder: 'plugins/hello-world',
31+
branch: 'main',
32+
},
33+
];

0 commit comments

Comments
 (0)