Skip to content

Commit debd638

Browse files
committed
feat(cli): introduce a new command to help user to build their mass op files
1 parent 4d3b6a9 commit debd638

7 files changed

Lines changed: 353 additions & 3 deletions

File tree

components/cli/CHANGELOG.md

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

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

5+
## [5.20.0]
6+
7+
- bump the schema lib to 6.9.0
8+
- add a new `mass-operation` `add-operation` command to help people create their mass operation file.
9+
510
## [5.15.0]
611

712
- bump the schema lib to 6.8.0

components/cli/bun.lockb

0 Bytes
Binary file not shown.

components/cli/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@crystallize/cli",
3-
"version": "5.15.0",
3+
"version": "5.20.0",
44
"description": "Crystallize CLI",
55
"module": "src/index.ts",
66
"repository": "https://github.com/CrystallizeAPI/crystallize-cli",
@@ -27,7 +27,7 @@
2727
},
2828
"dependencies": {
2929
"@crystallize/js-api-client": "^5.3.0",
30-
"@crystallize/schema": "^6.8.0",
30+
"@crystallize/schema": "^6.9.0",
3131
"awilix": "^12.0.5",
3232
"cli-spinners": "^3.4.0",
3333
"commander": "^14.0.2",
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { Argument, Command, Option } from 'commander';
2+
import type { Logger } from '../../domain/contracts/logger';
3+
import type { FlySystem } from '../../domain/contracts/fly-system';
4+
import { OperationSchema } from '@crystallize/schema/mass-operation';
5+
import { render } from 'ink';
6+
import { Box, Text } from 'ink';
7+
import { MultiSelect } from '../../ui/components/multi-select';
8+
type Deps = {
9+
logger: Logger;
10+
flySystem: FlySystem;
11+
};
12+
13+
/**
14+
* Zod v4 runtime schema introspection type.
15+
* We walk schema internals to generate operation skeletons,
16+
* which requires accessing properties not on the public z.ZodType interface.
17+
*/
18+
type SchemaNode = {
19+
_zod: {
20+
def: {
21+
type: string;
22+
innerType?: SchemaNode;
23+
values?: unknown[];
24+
options?: SchemaNode[];
25+
left?: SchemaNode;
26+
right?: SchemaNode;
27+
};
28+
};
29+
shape?: Record<string, SchemaNode>;
30+
};
31+
32+
type OperationOption = {
33+
label: string;
34+
value: string;
35+
group: string;
36+
schema: SchemaNode;
37+
};
38+
39+
function getSchemaDefType(schema: SchemaNode): string {
40+
return schema._zod.def.type;
41+
}
42+
43+
function unwrap(schema: SchemaNode): SchemaNode {
44+
const def = schema._zod.def;
45+
if ((def.type === 'optional' || def.type === 'nullable') && def.innerType) {
46+
return unwrap(def.innerType);
47+
}
48+
return schema;
49+
}
50+
51+
function isOptional(schema: SchemaNode): boolean {
52+
const type = getSchemaDefType(schema);
53+
return type === 'optional' || type === 'nullable';
54+
}
55+
56+
function generateDefault(schema: SchemaNode): unknown {
57+
const def = schema._zod.def;
58+
59+
switch (def.type) {
60+
case 'string':
61+
return '';
62+
case 'number':
63+
return 0;
64+
case 'boolean':
65+
return false;
66+
case 'literal':
67+
return def.values?.[0];
68+
case 'array':
69+
return [];
70+
case 'object':
71+
return generateObjectSkeleton(schema, false);
72+
case 'optional':
73+
case 'nullable':
74+
if (def.innerType) return generateDefault(def.innerType);
75+
return null;
76+
case 'union':
77+
if (def.options?.[0]) {
78+
return generateDefault(def.options[0]);
79+
}
80+
return null;
81+
case 'enum':
82+
return def.values?.[0] ?? '';
83+
case 'intersection': {
84+
const left = def.left ? generateDefault(def.left) : null;
85+
const right = def.right ? generateDefault(def.right) : null;
86+
if (typeof left === 'object' && typeof right === 'object' && left && right) {
87+
return { ...left, ...right };
88+
}
89+
return left ?? right;
90+
}
91+
default:
92+
return null;
93+
}
94+
}
95+
96+
function generateObjectSkeleton(schema: SchemaNode, isRoot: boolean): Record<string, unknown> {
97+
const { shape } = schema;
98+
if (!shape) return {};
99+
100+
const result: Record<string, unknown> = {};
101+
for (const [key, fieldSchema] of Object.entries(shape)) {
102+
if (key === '_ref') continue;
103+
104+
const opt = isOptional(fieldSchema);
105+
const alwaysInclude = isRoot && (key === 'resourceIdentifier' || key === 'itemId');
106+
107+
if (!opt || alwaysInclude) {
108+
if (alwaysInclude && opt) {
109+
result[key] = generateDefault(unwrap(fieldSchema));
110+
} else {
111+
result[key] = generateDefault(fieldSchema);
112+
}
113+
}
114+
}
115+
return result;
116+
}
117+
118+
function generateSkeleton(schema: SchemaNode): Record<string, unknown> {
119+
return generateObjectSkeleton(schema, true);
120+
}
121+
122+
function getOperationOptions(): OperationOption[] {
123+
const options = (OperationSchema.options as unknown as SchemaNode[]).map((schema) => {
124+
const intent: string = (schema.shape?.intent?._zod?.def?.values?.[0] as string) ?? '';
125+
const groupKey = intent.split('/')[0];
126+
const group = groupKey.charAt(0).toUpperCase() + groupKey.slice(1);
127+
128+
return {
129+
label: intent,
130+
value: intent,
131+
group,
132+
schema,
133+
};
134+
});
135+
options.sort((a, b) => a.group.localeCompare(b.group));
136+
return options;
137+
}
138+
139+
export const createAddOperationMassOperationCommand = ({ logger, flySystem }: Deps): Command => {
140+
const command = new Command('add-operation');
141+
command.description('Add operation skeleton(s) to a Mass Operation file.');
142+
command.addArgument(new Argument('<file>', 'The Mass Operation JSON file to add operations to.'));
143+
command.addOption(
144+
new Option('--operation <intent>', 'Operation intent to add (repeatable).').argParser<string[]>(
145+
(value: string, previous: string[]) => (previous ? [...previous, value] : [value]),
146+
),
147+
);
148+
command.addOption(new Option('--no-interactive', 'Disable the interactive mode.'));
149+
150+
command.action(async (file: string, flags: { operation?: string[]; interactive: boolean }) => {
151+
const allOptions = getOperationOptions();
152+
const validIntents = new Set(allOptions.map((o) => o.value));
153+
154+
let data: { version: string; operations: Record<string, unknown>[] };
155+
if (await flySystem.isFileExists(file)) {
156+
data = await flySystem.loadJsonFile<typeof data>(file);
157+
logger.note(`Loaded existing file: ${file}`);
158+
} else {
159+
data = { version: '1.0.0', operations: [] };
160+
logger.note(`Creating new file: ${file}`);
161+
}
162+
163+
let selectedIntents: string[];
164+
165+
if (flags.interactive && !flags.operation) {
166+
// Interactive mode
167+
let picked: string[] = [];
168+
const { waitUntilExit, unmount } = render(
169+
<Box flexDirection="column" padding={1}>
170+
<Text>Select operations to add:</Text>
171+
<MultiSelect<string>
172+
options={allOptions.map((o) => ({
173+
label: o.label,
174+
value: o.value,
175+
group: o.group,
176+
}))}
177+
onConfirm={(values) => {
178+
picked = values;
179+
unmount();
180+
}}
181+
/>
182+
</Box>,
183+
{ exitOnCtrlC: true },
184+
);
185+
await waitUntilExit();
186+
187+
if (picked.length === 0) {
188+
logger.warn('No operations selected.');
189+
return;
190+
}
191+
selectedIntents = picked;
192+
} else {
193+
// Non-interactive mode
194+
if (!flags.operation || flags.operation.length === 0) {
195+
throw new Error('You must provide at least one --operation <intent> when not running interactively.');
196+
}
197+
for (const intent of flags.operation) {
198+
if (!validIntents.has(intent)) {
199+
throw new Error(
200+
`Invalid operation intent: "${intent}". Valid intents: ${[...validIntents].join(', ')}`,
201+
);
202+
}
203+
}
204+
selectedIntents = flags.operation;
205+
}
206+
207+
for (const intent of selectedIntents) {
208+
const option = allOptions.find((o) => o.value === intent);
209+
if (!option) continue;
210+
const skeleton = generateSkeleton(option.schema);
211+
data.operations.push(skeleton);
212+
logger.info(`Added operation: ${intent}`);
213+
}
214+
215+
await flySystem.saveFile(file, JSON.stringify(data, null, 2) + '\r\n');
216+
logger.success(`Saved ${selectedIntents.length} operation(s) to ${file}.`);
217+
});
218+
219+
return command;
220+
};

components/cli/src/content/completion_file.bash

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ _crystallize_completions() {
3838
;;
3939
mass-operation)
4040
if [[ "${COMP_CWORD}" -eq 2 ]]; then
41-
local options="run dump-content-model execute-mutations ${default_options}"
41+
local options="run dump-content-model execute-mutations add-operation ${default_options}"
4242
COMPREPLY=($(compgen -W "${options}" -- "${cur}"))
4343
return 0
4444
fi
@@ -58,6 +58,11 @@ _crystallize_completions() {
5858
COMPREPLY=($(compgen -W "${options}" -- "${cur}"))
5959
return 0
6060
;;
61+
add-operation)
62+
local options="--operation= --no-interactive ${default_options}"
63+
COMPREPLY=($(compgen -W "${options}" -- "${cur}"))
64+
return 0
65+
;;
6166
esac
6267
;;
6368
tenant)

components/cli/src/core/di.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { createGetShopAuthTokenHandler } from '../domain/use-cases/get-shop-toke
3939
import { createDumpContentModelMassOperationCommand } from '../command/mass-operation/dump-content-model';
4040
import { createCreateContentModelMassOperationFileHandler } from '../domain/use-cases/create-content-model-mass-operation';
4141
import { createExecuteMutationsCommand } from '../command/mass-operation/execute-mutations';
42+
import { createAddOperationMassOperationCommand } from '../command/mass-operation/add-operation';
4243
import { createImageUploadCommand } from '../command/images/upload';
4344
import { createUploadBinariesHandler } from '../domain/use-cases/upload-binaries';
4445
import { createExecuteMutationsHandler } from '../domain/use-cases/execute-extra-mutations';
@@ -107,6 +108,7 @@ export const buildServices = () => {
107108
executeMutationsCommand: Command;
108109
imageUploadCommand: Command;
109110
fileUploadCommand: Command;
111+
addOperationMassOperationCommand: Command;
110112
enrollTenantCommand: Command;
111113
serveCommand: Command;
112114
}>({
@@ -169,6 +171,7 @@ export const buildServices = () => {
169171
executeMutationsCommand: asFunction(createExecuteMutationsCommand).singleton(),
170172
imageUploadCommand: asFunction(createImageUploadCommand).singleton(),
171173
fileUploadCommand: asFunction(createFileUploadCommand).singleton(),
174+
addOperationMassOperationCommand: asFunction(createAddOperationMassOperationCommand).singleton(),
172175
enrollTenantCommand: asFunction(createEnrollTenantCommand).singleton(),
173176
serveCommand: asFunction(createServeCommand).singleton(),
174177
});
@@ -225,6 +228,7 @@ export const buildServices = () => {
225228
container.cradle.runMassOperationCommand,
226229
container.cradle.dumpContentModelMassOperationCommand,
227230
container.cradle.executeMutationsCommand,
231+
container.cradle.addOperationMassOperationCommand,
228232
],
229233
},
230234
tenant: {

0 commit comments

Comments
 (0)