Skip to content

Commit 39e26b6

Browse files
committed
feat(cli): display operation logs in real-time during mass-operation run
1 parent debd638 commit 39e26b6

6 files changed

Lines changed: 200 additions & 41 deletions

File tree

components/cli/CHANGELOG.md

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

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

5+
## [5.21.0]
6+
7+
- display operation logs in real-time during `mass-operation run`, with color-coded status and a final drain to ensure all logs are shown
8+
59
## [5.20.0]
610

711
- bump the schema lib to 6.9.0

components/cli/CLAUDE.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Build & Run Commands
6+
7+
```bash
8+
make run ARGS="<command>" # Run CLI locally (e.g. ARGS="whoami")
9+
make staging-run ARGS="<command>" # Run against staging environment
10+
make build # Build single-platform binary
11+
make build-all # Build all platform binaries
12+
make codeclean # Format with prettier
13+
```
14+
15+
The runtime is **Bun** (>=1.3). No test framework is configured.
16+
17+
## Architecture
18+
19+
This is the **Crystallize CLI** (`@crystallize/cli`), a Bun-compiled TypeScript CLI using React (Ink) for terminal UI.
20+
21+
### Key Patterns
22+
23+
- **CQRS**: CommandBus (mutations) and QueryBus (reads) via `missive.js`. Handlers are registered in `src/core/di.ts`.
24+
- **Dependency Injection**: Awilix container configured in `src/core/di.ts` — all services are singletons. Command factories receive their dependencies from the container.
25+
- **Commander.js**: CLI commands are created as factory functions returning `Command` instances, organized by namespace (root, boilerplate, mass-operation, tenant, token, image, file).
26+
- **Interactive UI**: React components via Ink with Jotai atoms for state. Most commands support `--no-interactive` for CI/CD.
27+
28+
### Source Layout
29+
30+
- `src/index.ts` — Entry point
31+
- `src/command/` — CLI command definitions (each exports a factory function)
32+
- `src/domain/use-cases/` — Business logic handlers (CQRS)
33+
- `src/domain/contracts/` — Interfaces (logger, fly-system, bus, models)
34+
- `src/core/di.ts` — DI container wiring (command registration, handler setup)
35+
- `src/core/` — Infrastructure (logger, file system abstraction, credentials, runner)
36+
- `src/ui/components/` — Reusable Ink/React terminal components
37+
- `src/ui/journeys/` — Multi-step interactive flows
38+
39+
### Adding a New Command
40+
41+
1. Create a factory in `src/command/<group>/` returning a `Command`
42+
2. Define any use-case handlers in `src/domain/use-cases/`
43+
3. Register in `src/core/di.ts` under the appropriate command group
44+
45+
### File System & Process Execution
46+
47+
File I/O uses a `FlySystem` abstraction (`src/domain/contracts/fly-system.ts`, implemented via Bun APIs). Subprocesses use `Bun.spawn` wrapped in `src/core/create-runner.ts`.
48+
49+
### Credentials
50+
51+
Resolved in priority order: env vars (`CRYSTALLIZE_ACCESS_TOKEN_ID`/`SECRET`) → CLI flags (`--token_id`/`--token_secret`) → stored file (`~/.crystallize/credentials.json`).
52+
53+
## Code Style
54+
55+
- Prettier: 4-space indent, single quotes, trailing commas, semicolons, 120 char width
56+
- Commit messages: conventional commits (feat, fix, chore, refactor, etc.)
57+
- TypeScript strict mode with `verbatimModuleSyntax`

components/cli/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.20.0",
3+
"version": "5.21.0",
44
"description": "Crystallize CLI",
55
"module": "src/index.ts",
66
"repository": "https://github.com/CrystallizeAPI/crystallize-cli",

components/cli/src/command/mass-operation/run.tsx

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export type MassOperationBulkTaskResponse = {
1414
bulkTask: MassOperationBulkTaskError | MassOperationBulkTaskSuccess;
1515
};
1616

17+
type OperationLogNode = { id: string; input: string; output: string; message: string; status: string; statusCode: number };
18+
type OperationLogsResponse = {
19+
operationLogs: {
20+
pageInfo: { endCursor: string; hasNextPage: boolean };
21+
edges: { node: OperationLogNode }[];
22+
};
23+
};
24+
1725
type Deps = {
1826
logger: Logger;
1927
commandBus: CommandBus;
@@ -90,20 +98,75 @@ export const createRunMassOperationCommand = ({
9098
accessTokenSecret: credentials.ACCESS_TOKEN_SECRET,
9199
});
92100

101+
const fetchAndDisplayLogs = async (
102+
taskId: string,
103+
cursor: string | null,
104+
): Promise<{ cursor: string | null; hasNextPage: boolean }> => {
105+
const res = await crystallizeClient.nextPimApi<OperationLogsResponse>(getOperationLogs, {
106+
operationId: taskId,
107+
after: cursor || '',
108+
first: 100,
109+
});
110+
const { operationLogs } = res;
111+
if (!operationLogs?.edges) {
112+
return { cursor, hasNextPage: false };
113+
}
114+
for (const { node } of operationLogs.edges) {
115+
const colorFn =
116+
node.statusCode >= 200 && node.statusCode < 300
117+
? pc.green
118+
: node.statusCode >= 400
119+
? pc.red
120+
: pc.yellow;
121+
logger.info(`${colorFn(`[${node.statusCode}]`)} ${node.status} - Operation ${node.id}: ${node.message}`);
122+
if (node.input) {
123+
logger.debug(` Input: ${JSON.stringify(node.input)}`);
124+
}
125+
if (node.output) {
126+
logger.debug(` Output: ${JSON.stringify(node.output)}`);
127+
}
128+
}
129+
return {
130+
cursor: operationLogs.pageInfo.endCursor || cursor,
131+
hasNextPage: operationLogs.pageInfo.hasNextPage,
132+
};
133+
};
134+
93135
logger.info(`Now, Waiting for task ${pc.yellow(startedTask.id)} to complete...`);
136+
let logCursor: string | null = null;
137+
94138
while (startedTask.status !== 'complete') {
95-
logger.info(`Task status: ${pc.yellow(startedTask.status)}`);
96139
await new Promise((resolve) => setTimeout(resolve, 1000));
97-
const res: MassOperationBulkTaskResponse =
98-
await crystallizeClient.nextPimApi<MassOperationBulkTaskResponse>(getMassOperationBulkTask, {
99-
id: startedTask.id,
100-
});
101-
const { bulkTask } = res;
140+
const results: [MassOperationBulkTaskResponse, { cursor: string | null; hasNextPage: boolean }] =
141+
await Promise.all([
142+
crystallizeClient.nextPimApi<MassOperationBulkTaskResponse>(getMassOperationBulkTask, {
143+
id: startedTask.id,
144+
}),
145+
fetchAndDisplayLogs(startedTask.id, logCursor),
146+
]);
147+
const [taskRes, logResult] = results;
148+
logCursor = logResult.cursor;
149+
// Drain all available log pages before continuing
150+
while (logResult.hasNextPage) {
151+
const next = await fetchAndDisplayLogs(startedTask.id, logCursor);
152+
logCursor = next.cursor;
153+
logResult.hasNextPage = next.hasNextPage;
154+
}
155+
const { bulkTask } = taskRes;
102156
if ('error' in bulkTask) {
103157
throw new Error(bulkTask.error);
104158
}
105159
startedTask = bulkTask;
106160
}
161+
162+
// Drain remaining logs
163+
let hasMoreLogs = true;
164+
while (hasMoreLogs) {
165+
const logResult = await fetchAndDisplayLogs(startedTask.id, logCursor);
166+
logCursor = logResult.cursor;
167+
hasMoreLogs = logResult.hasNextPage;
168+
}
169+
107170
logger.success(`Task completed successfully. Task ID: ${pc.yellow(startedTask.id)}`);
108171
} catch (error) {
109172
if (error instanceof ZodError) {
@@ -131,3 +194,13 @@ query GET($id:ID!) {
131194
}
132195
}
133196
}`;
197+
198+
const getOperationLogs = `#graphql
199+
query GET_OPERATION_LOGS($operationId: ID!, $after: String, $first: Int) {
200+
operationLogs(after: $after, first: $first, filter: { operationId: $operationId }) {
201+
... on OperationLogConnection {
202+
pageInfo { endCursor hasNextPage }
203+
edges { node { id input output message status statusCode } }
204+
}
205+
}
206+
}`;
Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,51 @@
11
import { createClient, type ClientConfiguration, type CreateClientOptions } from '@crystallize/js-api-client';
22
import type { AsyncCreateClient } from '../domain/contracts/credential-retriever';
3+
import type { Logger } from '../domain/contracts/logger';
34

45
type Deps = {
56
crystallizeEnvironment: 'staging' | 'production';
7+
logLevels: ('info' | 'debug')[]
8+
logger: Logger;
69
};
710
export const createCrystallizeClientBuilder =
8-
({ crystallizeEnvironment }: Deps): AsyncCreateClient =>
9-
async (configuration: ClientConfiguration, options?: CreateClientOptions) => {
10-
if (configuration.tenantIdentifier.length > 0 && (configuration.tenantId || '').length === 0) {
11-
const tempClient = createClient({
12-
...configuration,
13-
origin: crystallizeEnvironment === 'staging' ? '-dev.crystallize.digital' : '.crystallize.com',
14-
});
15-
const tenantInfo = await tempClient.nextPimApi(
16-
`query { tenant(identifier:"${configuration.tenantIdentifier}") { ... on Tenant { id } } }`,
17-
);
18-
if (!tenantInfo.tenant.id) {
19-
throw new Error(`Tenant Id for identifier ${configuration.tenantIdentifier} not found`);
11+
({ crystallizeEnvironment, logLevels, logger }: Deps): AsyncCreateClient =>
12+
async (configuration: ClientConfiguration, options?: CreateClientOptions) => {
13+
14+
if (logLevels.includes('debug')) {
15+
options = {
16+
...options,
17+
profiling: {
18+
onRequestResolved({ resolutionTimeMs, serverTimeMs }, query, variables) {
19+
logger.debug(`Query: ${query}`);
20+
logger.debug(`Variables: ${JSON.stringify(variables)}`);
21+
logger.debug(`Resolution time: ${resolutionTimeMs}ms, Server time: ${serverTimeMs}ms`);
22+
}
23+
}
24+
}
2025
}
21-
configuration.tenantId = tenantInfo.tenant.id;
22-
}
23-
return createClient(
24-
{
25-
...configuration,
26-
origin: crystallizeEnvironment === 'staging' ? '-dev.crystallize.digital' : '.crystallize.com',
27-
},
28-
options,
29-
);
30-
};
26+
27+
if (configuration.tenantIdentifier.length > 0 && (configuration.tenantId || '').length === 0) {
28+
const tempClient = createClient({
29+
...configuration,
30+
origin: crystallizeEnvironment === 'staging' ? '-dev.crystallize.digital' : '.crystallize.com',
31+
});
32+
const tenantInfo = await tempClient.nextPimApi<{
33+
tenant: {
34+
id: string;
35+
};
36+
}>(
37+
`query { tenant(identifier:"${configuration.tenantIdentifier}") { ... on Tenant { id } } }`,
38+
);
39+
if (!tenantInfo.tenant.id) {
40+
throw new Error(`Tenant Id for identifier ${configuration.tenantIdentifier} not found`);
41+
}
42+
configuration.tenantId = tenantInfo.tenant.id;
43+
}
44+
return createClient(
45+
{
46+
...configuration,
47+
origin: crystallizeEnvironment === 'staging' ? '-dev.crystallize.digital' : '.crystallize.com',
48+
},
49+
options,
50+
);
51+
};

components/cli/src/domain/core/fetch-available-tenant-identifier.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ type Deps = {
77

88
export const createFetchAvailableTenantIdentifier =
99
({ createCrystallizeClient }: Deps) =>
10-
async (credentials: PimCredentials, identifier: string) => {
11-
const apiClient = await createCrystallizeClient({
12-
tenantIdentifier: '',
13-
sessionId: credentials.sessionId,
14-
accessTokenId: credentials.ACCESS_TOKEN_ID,
15-
accessTokenSecret: credentials.ACCESS_TOKEN_SECRET,
16-
});
17-
const result = await apiClient.pimApi(
18-
`query { tenant { suggestIdentifier ( desired: "${identifier}" ) { suggestion } } }`,
19-
);
20-
return result.tenant?.suggestIdentifier?.suggestion || identifier;
21-
};
10+
async (credentials: PimCredentials, identifier: string) => {
11+
const apiClient = await createCrystallizeClient({
12+
tenantIdentifier: '',
13+
sessionId: credentials.sessionId,
14+
accessTokenId: credentials.ACCESS_TOKEN_ID,
15+
accessTokenSecret: credentials.ACCESS_TOKEN_SECRET,
16+
});
17+
const result = await apiClient.pimApi<{
18+
tenant: {
19+
suggestIdentifier: {
20+
suggestion: string;
21+
};
22+
};
23+
}>(`query { tenant { suggestIdentifier ( desired: "${identifier}" ) { suggestion } } }`);
24+
return result.tenant?.suggestIdentifier?.suggestion || identifier;
25+
};

0 commit comments

Comments
 (0)