Skip to content

Commit e2c9984

Browse files
authored
feat(cli): implement console and file loggers for migrations (#372)
Fixes WDX-189 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces a structured console/file logger wired into migrations (generate/run/rollback) with run-scoped JSONL files and a new `logs` command to list/prune them. > > - **Logging infrastructure** > - **New logger**: Adds `utils/logger` with pluggable transports. > - **File/Console transports**: Implement `logger-transport-file` (JSONL, max-files pruning, list/prune helpers) and `logger-transport-console` (formatted output) with tests. > - **Program bootstrap**: Initialize logger per run in `program.ts` (per-command JSONL file under `logs/<space>`), enable UI. > - **Filesystem utils**: Add `importModule`, `getLogsPath`, `appendToFileSync`. > - **Error utils**: Add `toError`; pipe errors to logger. > - **Migrations commands** > - **Run**: Replace konsola/progress with `UI`; log lifecycle and summaries; improved pipelines and error codes; summaries via UI. > - **Generate**: Use `UI` + logger; spinner + success path logged. > - **Rollback**: Use `UI` + logger; per-story spinners; result summary logged. > - **Streams/Actions**: Dynamic module import via `importModule`; detailed logging and standardized error codes. > - **Logs CLI** > - **Command `logs`**: New `list` and `prune` subcommands operating on `logs/<space>` via `FileTransport` helpers; usage docs. > - **Constants/Index** > - Add `commands.LOGS`, `directories.log`, wire command in CLI entry; update color palette. > - **Tests** > - Add/adjust tests for logger transports, logs command, and migrations flows (logging, dry-run, errors). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1760ea3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent f4d5258 commit e2c9984

32 files changed

+1493
-641
lines changed

packages/cli/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ npm-debug.log*
77
yarn-debug.log*
88
yarn-error.log*
99
lerna-debug.log*
10+
# Exclude the logs command
11+
!/src/commands/logs
1012

1113
# Diagnostic reports (https://nodejs.org/api/report.html)
1214
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Logs Command
2+
3+
The `logs` command lets you inspect and manage logs.
4+
5+
## Subcommands
6+
7+
### `list`
8+
9+
Show available run artifacts for the selected space.
10+
11+
```bash
12+
storyblok logs list --space YOUR_SPACE_ID
13+
```
14+
15+
### `prune`
16+
17+
Delete stored logs.
18+
19+
```bash
20+
storyblok logs prune --space YOUR_SPACE_ID [--keep <count>]
21+
```
22+
23+
Options:
24+
- `--keep <count>` retains the most recent `count` runs and deletes the rest (default `0`, meaning remove all).
25+
26+
Note: use the same `--path` option you used when running the command producing the logs.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { commands } from '../../constants';
2+
import { getProgram } from '../../program';
3+
4+
const program = getProgram();
5+
6+
export const logsCommand = program
7+
.command(commands.LOGS)
8+
.alias('lg')
9+
.description(`Inspect and manage logs.`)
10+
.option('-s, --space <space>', 'The space ID.')
11+
.option('-p, --path <path>', 'Path to the directory containing the logs directory. Defaults to \'.storyblok\'.');
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import './command';
2+
import './list';
3+
import './prune';
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { vol } from 'memfs';
3+
import path from 'node:path';
4+
// Import the main components module first to ensure proper initialization
5+
import '../index';
6+
import { logsCommand } from '../command';
7+
import { getLogsPath } from '../../../utils/filesystem';
8+
9+
vi.mock('node:fs');
10+
vi.mock('node:fs/promises');
11+
12+
vi.spyOn(console, 'info');
13+
vi.spyOn(console, 'log');
14+
15+
const LOGS_FILE_DIR = getLogsPath('logs', '12345');
16+
17+
const preconditions = {
18+
hasLogFiles() {
19+
vol.fromJSON({
20+
[path.join(LOGS_FILE_DIR, 'storyblok-migrations-run-1234567890.jsonl')]: 'foo',
21+
[path.join(LOGS_FILE_DIR, 'storyblok-migrations-run-1234567891.jsonl')]: 'foo',
22+
[path.join(LOGS_FILE_DIR, 'storyblok-components-push-1234567892.jsonl')]: 'foo',
23+
});
24+
},
25+
hasNoLogFiles() {
26+
vol.reset();
27+
},
28+
hasEmptyLogDirectory() {
29+
vol.fromJSON({
30+
'logs/12345/.gitkeep': '',
31+
});
32+
},
33+
};
34+
35+
describe('logs list command', () => {
36+
beforeEach(() => {
37+
vi.resetAllMocks();
38+
vi.clearAllMocks();
39+
vol.reset();
40+
});
41+
42+
it('should list available log files', async () => {
43+
preconditions.hasLogFiles();
44+
45+
await logsCommand.parseAsync(['node', 'test', 'list', '--space', '12345']);
46+
47+
expect(console.info).toHaveBeenCalledWith(
48+
expect.stringContaining('Found 3 log files for space "12345":'),
49+
);
50+
expect(console.log).toHaveBeenCalledWith(
51+
expect.stringContaining('storyblok-components-push-1234567892.jsonl'),
52+
);
53+
expect(console.log).toHaveBeenCalledWith(
54+
expect.stringContaining('storyblok-migrations-run-1234567890.jsonl'),
55+
);
56+
expect(console.log).toHaveBeenCalledWith(
57+
expect.stringContaining('storyblok-migrations-run-1234567891.jsonl'),
58+
);
59+
});
60+
61+
it('should handle no logs found when directory does not exist', async () => {
62+
preconditions.hasNoLogFiles();
63+
64+
await logsCommand.parseAsync(['node', 'test', 'list', '--space', '12345']);
65+
66+
expect(console.info).toHaveBeenCalledWith(
67+
expect.stringContaining('No logs found for space "12345"'),
68+
);
69+
});
70+
71+
it('should handle no logs found when directory is empty', async () => {
72+
preconditions.hasEmptyLogDirectory();
73+
74+
await logsCommand.parseAsync(['node', 'test', 'list', '--space', '12345']);
75+
76+
expect(console.info).toHaveBeenCalledWith(
77+
expect.stringContaining('No logs found for space "12345"'),
78+
);
79+
});
80+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getLogsPath } from '../../../utils/filesystem';
2+
import { getUI } from '../../../utils/ui';
3+
import { logsCommand } from '../command';
4+
import { FileTransport } from '../../../utils/logger-transport-file';
5+
import { directories } from '../../../constants';
6+
7+
logsCommand.command('list')
8+
.description('List logs')
9+
.action(async () => {
10+
const { space, path } = logsCommand.opts();
11+
const ui = getUI();
12+
const logsPath = getLogsPath(directories.log, space, path);
13+
const logFiles = FileTransport.listLogFiles(logsPath);
14+
15+
if (logFiles.length === 0) {
16+
ui.info(`No logs found for space "${space}".`);
17+
return;
18+
}
19+
20+
ui.info(`Found ${logFiles.length} log file${logFiles.length === 1 ? '' : 's'} for space "${space}":`);
21+
ui.list(logFiles);
22+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { vol } from 'memfs';
3+
import path from 'node:path';
4+
// Import the main components module first to ensure proper initialization
5+
import '../index';
6+
import { logsCommand } from '../command';
7+
import { getLogsPath } from '../../../utils/filesystem';
8+
9+
vi.mock('node:fs');
10+
vi.mock('node:fs/promises');
11+
12+
vi.spyOn(console, 'info');
13+
vi.spyOn(console, 'log');
14+
15+
const LOGS_FILE_DIR = getLogsPath('logs', '12345');
16+
17+
const preconditions = {
18+
hasLogFiles() {
19+
vol.fromJSON({
20+
[path.join(LOGS_FILE_DIR, 'storyblok-migrations-run-1234567890.jsonl')]: 'foo',
21+
[path.join(LOGS_FILE_DIR, 'storyblok-migrations-run-1234567891.jsonl')]: 'foo',
22+
[path.join(LOGS_FILE_DIR, 'storyblok-components-push-1234567892.jsonl')]: 'foo',
23+
});
24+
},
25+
};
26+
27+
describe('logs prune command', () => {
28+
beforeEach(() => {
29+
vi.resetAllMocks();
30+
vi.clearAllMocks();
31+
vol.reset();
32+
});
33+
34+
it('should delete all logs when keep is 0 (default)', async () => {
35+
preconditions.hasLogFiles();
36+
37+
await logsCommand.parseAsync(['node', 'test', 'prune', '--space', '12345']);
38+
39+
expect(console.info).toHaveBeenCalledWith(
40+
expect.stringContaining('Deleted 3 log files'),
41+
);
42+
const remainingFiles = Object.keys(vol.toJSON())
43+
.filter(path => path.includes('.jsonl'));
44+
expect(remainingFiles).toHaveLength(0);
45+
});
46+
47+
it('should keep specified number of recent logs', async () => {
48+
preconditions.hasLogFiles();
49+
50+
await logsCommand.parseAsync(['node', 'test', 'prune', '--space', '12345', '--keep', '2']);
51+
52+
expect(console.info).toHaveBeenCalledWith(
53+
expect.stringContaining('Deleted 1 log file'),
54+
);
55+
const remainingFiles = Object.keys(vol.toJSON())
56+
.filter(path => path.includes('.jsonl'));
57+
expect(remainingFiles).toHaveLength(2);
58+
});
59+
60+
it('should not delete logs when keep count equals total', async () => {
61+
preconditions.hasLogFiles();
62+
63+
await logsCommand.parseAsync(['node', 'test', 'prune', '--space', '12345', '--keep', '3']);
64+
65+
expect(console.info).toHaveBeenCalledWith(
66+
expect.stringContaining('Deleted 0 log files'),
67+
);
68+
const remainingFiles = Object.keys(vol.toJSON())
69+
.filter(path => path.includes('.jsonl'));
70+
expect(remainingFiles).toHaveLength(3);
71+
});
72+
73+
it('should not delete logs when keep count exceeds total', async () => {
74+
preconditions.hasLogFiles();
75+
76+
await logsCommand.parseAsync(['node', 'test', 'prune', '--space', '12345', '--keep', '10']);
77+
78+
expect(console.info).toHaveBeenCalledWith(
79+
expect.stringContaining('Deleted 0 log files'),
80+
);
81+
const remainingFiles = Object.keys(vol.toJSON())
82+
.filter(path => path.includes('.jsonl'));
83+
expect(remainingFiles).toHaveLength(3);
84+
});
85+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { getLogsPath } from '../../../utils/filesystem';
2+
import { getUI } from '../../../utils/ui';
3+
import { logsCommand } from '../command';
4+
import { FileTransport } from '../../../utils/logger-transport-file';
5+
import { directories } from '../../../constants';
6+
7+
logsCommand.command('prune')
8+
.description('Prune logs')
9+
.option('--keep <number>', 'Max number of log files to keep (default `0`, meaning remove all)', Number.parseInt, 0)
10+
.action(async ({ keep }: { keep: number }) => {
11+
const { space, path } = logsCommand.opts();
12+
const ui = getUI();
13+
const logsPath = getLogsPath(directories.log, space, path);
14+
const deletedFilesCount = FileTransport.pruneLogFiles(logsPath, keep);
15+
16+
ui.info(`Deleted ${deletedFilesCount} log file${deletedFilesCount === 1 ? '' : 's'}`);
17+
});

0 commit comments

Comments
 (0)