@ldsg/cli 0.3.29 → 0.3.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/package-deps_build.json +22 -5
- package/.tsbuildinfo +1 -1
- package/CHANGELOG.json +26 -0
- package/CHANGELOG.md +8 -1
- package/dist/__tests__/commands.test.d.ts +1 -0
- package/dist/__tests__/commands.test.js +219 -0
- package/dist/__tests__/commands.test.js.map +1 -0
- package/dist/__tests__/formatter.test.d.ts +1 -0
- package/dist/__tests__/formatter.test.js +114 -0
- package/dist/__tests__/formatter.test.js.map +1 -0
- package/dist/__tests__/json-file-mutator.test.d.ts +1 -0
- package/dist/__tests__/json-file-mutator.test.js +103 -0
- package/dist/__tests__/json-file-mutator.test.js.map +1 -0
- package/dist/__tests__/source-resolver.test.d.ts +1 -0
- package/dist/__tests__/source-resolver.test.js +101 -0
- package/dist/__tests__/source-resolver.test.js.map +1 -0
- package/dist/cli.js +304 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/resource/convert.d.ts +11 -0
- package/dist/commands/resource/convert.js +42 -0
- package/dist/commands/resource/convert.js.map +1 -0
- package/dist/commands/resource/create.d.ts +14 -0
- package/dist/commands/resource/create.js +37 -0
- package/dist/commands/resource/create.js.map +1 -0
- package/dist/commands/resource/delete.d.ts +10 -0
- package/dist/commands/resource/delete.js +10 -0
- package/dist/commands/resource/delete.js.map +1 -0
- package/dist/commands/resource/diff.d.ts +10 -0
- package/dist/commands/resource/diff.js +72 -0
- package/dist/commands/resource/diff.js.map +1 -0
- package/dist/commands/resource/get.d.ts +6 -0
- package/dist/commands/resource/get.js +6 -0
- package/dist/commands/resource/get.js.map +1 -0
- package/dist/commands/resource/list.d.ts +30 -0
- package/dist/commands/resource/list.js +138 -0
- package/dist/commands/resource/list.js.map +1 -0
- package/dist/commands/resource/tree.d.ts +2 -0
- package/dist/commands/resource/tree.js +6 -0
- package/dist/commands/resource/tree.js.map +1 -0
- package/dist/commands/resource/update.d.ts +11 -0
- package/dist/commands/resource/update.js +13 -0
- package/dist/commands/resource/update.js.map +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/types/resource-record.d.ts +43 -0
- package/dist/types/resource-record.js +3 -0
- package/dist/types/resource-record.js.map +1 -0
- package/dist/utils/formatter.d.ts +12 -0
- package/dist/utils/formatter.js +96 -0
- package/dist/utils/formatter.js.map +1 -0
- package/dist/utils/json-file-mutator.d.ts +15 -0
- package/dist/utils/json-file-mutator.js +109 -0
- package/dist/utils/json-file-mutator.js.map +1 -0
- package/dist/utils/source-resolver.d.ts +4 -0
- package/dist/utils/source-resolver.js +93 -0
- package/dist/utils/source-resolver.js.map +1 -0
- package/jest.config.js +12 -0
- package/package.json +8 -6
- package/src/__tests__/commands.test.ts +262 -0
- package/src/__tests__/formatter.test.ts +133 -0
- package/src/__tests__/json-file-mutator.test.ts +136 -0
- package/src/__tests__/source-resolver.test.ts +125 -0
- package/src/cli.ts +309 -1
- package/src/commands/resource/convert.ts +52 -0
- package/src/commands/resource/create.ts +49 -0
- package/src/commands/resource/delete.ts +15 -0
- package/src/commands/resource/diff.ts +104 -0
- package/src/commands/resource/get.ts +5 -0
- package/src/commands/resource/list.ts +161 -0
- package/src/commands/resource/tree.ts +4 -0
- package/src/commands/resource/update.ts +21 -0
- package/src/constants.ts +3 -0
- package/src/types/resource-record.ts +52 -0
- package/src/utils/formatter.ts +106 -0
- package/src/utils/json-file-mutator.ts +137 -0
- package/src/utils/source-resolver.ts +102 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { resolveSource, isStdinSource, getSourceDescription } from '../utils/source-resolver';
|
|
5
|
+
|
|
6
|
+
jest.mock('fs-extra');
|
|
7
|
+
|
|
8
|
+
const mockedFs = fs as jest.Mocked<typeof fs> & {
|
|
9
|
+
pathExists: jest.Mock;
|
|
10
|
+
readJson: jest.Mock;
|
|
11
|
+
stat: jest.Mock;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('source-resolver', () => {
|
|
15
|
+
const tmpDir = os.tmpdir();
|
|
16
|
+
const testFilePath = path.join(tmpDir, 'test-resources.json');
|
|
17
|
+
const testDirPath = path.join(tmpDir, 'test-resource-dir');
|
|
18
|
+
const defaultFilePath = path.join(process.cwd(), 'resource-records.json');
|
|
19
|
+
|
|
20
|
+
const arrayData = [
|
|
21
|
+
{ id: '1', kind: 'application', parentId: null, settings: {} },
|
|
22
|
+
{ id: '2', kind: 'form', parentId: '1', settings: {} }
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const objectData = {
|
|
26
|
+
resourceRecords: [{ id: '1', kind: 'application', parentId: null, settings: {} }]
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
jest.resetAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('resolveSource', () => {
|
|
34
|
+
it('should read array format JSON file', async () => {
|
|
35
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
36
|
+
mockedFs.readJson.mockResolvedValue(arrayData);
|
|
37
|
+
mockedFs.stat.mockResolvedValue({ isDirectory: () => false } as fs.Stats);
|
|
38
|
+
|
|
39
|
+
const result = await resolveSource(testFilePath);
|
|
40
|
+
expect(result.resourceRecords).toHaveLength(2);
|
|
41
|
+
expect(result.format).toBe('array');
|
|
42
|
+
expect(result.filePath).toBe(testFilePath);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should read object format JSON file', async () => {
|
|
46
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
47
|
+
mockedFs.readJson.mockResolvedValue(objectData);
|
|
48
|
+
mockedFs.stat.mockResolvedValue({ isDirectory: () => false } as fs.Stats);
|
|
49
|
+
|
|
50
|
+
const result = await resolveSource(testFilePath);
|
|
51
|
+
expect(result.resourceRecords).toHaveLength(1);
|
|
52
|
+
expect(result.format).toBe('object');
|
|
53
|
+
expect(result.filePath).toBe(testFilePath);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should read directory by appending default filename', async () => {
|
|
57
|
+
const dirFile = path.join(testDirPath, 'resource-records.json');
|
|
58
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
59
|
+
mockedFs.readJson.mockResolvedValue(arrayData);
|
|
60
|
+
mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as fs.Stats);
|
|
61
|
+
|
|
62
|
+
const result = await resolveSource(testDirPath);
|
|
63
|
+
expect(mockedFs.readJson).toHaveBeenCalledWith(dirFile);
|
|
64
|
+
expect(result.resourceRecords).toHaveLength(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should throw on missing default file', async () => {
|
|
68
|
+
mockedFs.pathExists.mockResolvedValue(false);
|
|
69
|
+
|
|
70
|
+
await expect(resolveSource(undefined)).rejects.toThrow('Default source file not found');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should throw on non-existent file', async () => {
|
|
74
|
+
mockedFs.pathExists.mockResolvedValue(false);
|
|
75
|
+
mockedFs.stat.mockRejectedValue(new Error('ENOENT'));
|
|
76
|
+
mockedFs.readJson.mockRejectedValue(new Error('File not found'));
|
|
77
|
+
|
|
78
|
+
await expect(resolveSource('/nonexistent/file.json')).rejects.toThrow('Source not found');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should throw on invalid JSON format', async () => {
|
|
82
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
83
|
+
mockedFs.readJson.mockResolvedValue('invalid');
|
|
84
|
+
mockedFs.stat.mockResolvedValue({ isDirectory: () => false } as fs.Stats);
|
|
85
|
+
|
|
86
|
+
await expect(resolveSource(testFilePath)).rejects.toThrow('Invalid resource records format');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should throw on valid object without resourceRecords field', async () => {
|
|
90
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
91
|
+
mockedFs.readJson.mockResolvedValue({ foo: 'bar' });
|
|
92
|
+
mockedFs.stat.mockResolvedValue({ isDirectory: () => false } as fs.Stats);
|
|
93
|
+
|
|
94
|
+
await expect(resolveSource(testFilePath)).rejects.toThrow('Invalid resource records format');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('isStdinSource', () => {
|
|
99
|
+
it('should return true for -', () => {
|
|
100
|
+
expect(isStdinSource('-')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should return false for file paths', () => {
|
|
104
|
+
expect(isStdinSource('./data.json')).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should return false for undefined', () => {
|
|
108
|
+
expect(isStdinSource(undefined)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('getSourceDescription', () => {
|
|
113
|
+
it('should return default path for undefined', () => {
|
|
114
|
+
expect(getSourceDescription(undefined)).toBe('./resource-records.json');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return "stdin" for -', () => {
|
|
118
|
+
expect(getSourceDescription('-')).toBe('stdin');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should return the path itself', () => {
|
|
122
|
+
expect(getSourceDescription('./data.json')).toBe('./data.json');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -11,9 +11,25 @@ import {
|
|
|
11
11
|
ZipAppDataLayout
|
|
12
12
|
} from '@ldsg/utils';
|
|
13
13
|
import { Command } from 'commander';
|
|
14
|
-
import { DEFAULT_OUTPUT_PATH } from './constants';
|
|
14
|
+
import { DEFAULT_OUTPUT_PATH, DEFAULT_SOURCE_FILE, DEFAULT_SOURCE_PATH } from './constants';
|
|
15
15
|
import { getExtendAppDataParamsUsingNpmLdsgModule } from './utils/get-extend-app-data-params-using-npm-ldsg-module';
|
|
16
16
|
import { getExtendAppDataParamsUsingWorkspaceLdsgModule } from './utils/get-extend-app-data-params-using-workspace-ldsg-module';
|
|
17
|
+
import { resolveSource, isStdinSource, getSourceDescription } from './utils/source-resolver';
|
|
18
|
+
import {
|
|
19
|
+
formatSuccess,
|
|
20
|
+
formatError,
|
|
21
|
+
formatJson,
|
|
22
|
+
formatPrettyList,
|
|
23
|
+
formatPrettyTree,
|
|
24
|
+
formatPrettyDiff
|
|
25
|
+
} from './utils/formatter';
|
|
26
|
+
import { createResource } from './commands/resource/create';
|
|
27
|
+
import { updateResource } from './commands/resource/update';
|
|
28
|
+
import { deleteResource } from './commands/resource/delete';
|
|
29
|
+
import { listResources, getTree } from './commands/resource/list';
|
|
30
|
+
import { getResource } from './commands/resource/get';
|
|
31
|
+
import { convertResource } from './commands/resource/convert';
|
|
32
|
+
import { diffResource } from './commands/resource/diff';
|
|
17
33
|
|
|
18
34
|
const { version } = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
19
35
|
|
|
@@ -69,4 +85,296 @@ program
|
|
|
69
85
|
});
|
|
70
86
|
});
|
|
71
87
|
|
|
88
|
+
const resource = program.command('resource').aliases(['r']).description('Manage resource records');
|
|
89
|
+
|
|
90
|
+
resource
|
|
91
|
+
.command('list')
|
|
92
|
+
.description('List resource records by filter')
|
|
93
|
+
.option('--source <path>', `data source (default: ${DEFAULT_SOURCE_PATH})`)
|
|
94
|
+
.option('--kind <kind>', 'filter by resource kind')
|
|
95
|
+
.option('--parent-id <id>', 'filter by parent id')
|
|
96
|
+
.option('--root-id <id>', 'filter by root resource id')
|
|
97
|
+
.option('--pretty', 'human-readable output')
|
|
98
|
+
.action(async (options) => {
|
|
99
|
+
try {
|
|
100
|
+
const sourceDesc = getSourceDescription(options.source);
|
|
101
|
+
const sourceData = await resolveSource(options.source);
|
|
102
|
+
const { total, resources } = await listResources(sourceData.resourceRecords, {
|
|
103
|
+
kind: options.kind,
|
|
104
|
+
parentId: options.parentId,
|
|
105
|
+
rootId: options.rootId
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (options.pretty) {
|
|
109
|
+
console.log(
|
|
110
|
+
formatPrettyList(
|
|
111
|
+
resources.map((r) => ({
|
|
112
|
+
id: r.id,
|
|
113
|
+
kind: r.kind,
|
|
114
|
+
parentId: r.parentId,
|
|
115
|
+
title: (r.settings as Record<string, unknown>)?.title as string | undefined
|
|
116
|
+
}))
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
} else {
|
|
120
|
+
console.log(formatJson(formatSuccess({ source: sourceDesc, total, resources })));
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(formatJson(formatError('LIST_ERROR', (error as Error).message)));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
resource
|
|
129
|
+
.command('get <id>')
|
|
130
|
+
.description('Get a resource record by id')
|
|
131
|
+
.option('--source <path>', `data source (default: ${DEFAULT_SOURCE_PATH})`)
|
|
132
|
+
.option('--children', 'include direct children')
|
|
133
|
+
.option('--tree', 'include full subtree')
|
|
134
|
+
.option('--pretty', 'human-readable output')
|
|
135
|
+
.action(async (id, options) => {
|
|
136
|
+
try {
|
|
137
|
+
const sourceDesc = getSourceDescription(options.source);
|
|
138
|
+
const sourceData = await resolveSource(options.source);
|
|
139
|
+
const record = await getResource(sourceData.resourceRecords, id, {
|
|
140
|
+
children: options.children,
|
|
141
|
+
tree: options.tree
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!record) {
|
|
145
|
+
console.error(formatJson(formatError('NOT_FOUND', `Resource with id "${id}" not found`)));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (options.pretty) {
|
|
150
|
+
console.log(formatJson(record));
|
|
151
|
+
} else {
|
|
152
|
+
console.log(formatJson(formatSuccess({ source: sourceDesc, record })));
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error(formatJson(formatError('GET_ERROR', (error as Error).message)));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
resource
|
|
161
|
+
.command('tree [root-id]')
|
|
162
|
+
.description('Display resource tree')
|
|
163
|
+
.option('--source <path>', `data source (default: ${DEFAULT_SOURCE_PATH})`)
|
|
164
|
+
.option('--show-kind', 'show resource kind prefix')
|
|
165
|
+
.option('--pretty', 'human-readable output')
|
|
166
|
+
.action(async (rootId, options) => {
|
|
167
|
+
try {
|
|
168
|
+
const sourceDesc = getSourceDescription(options.source);
|
|
169
|
+
const sourceData = await resolveSource(options.source);
|
|
170
|
+
const result = await getTree(sourceData.resourceRecords, rootId, options.showKind);
|
|
171
|
+
|
|
172
|
+
if (options.pretty) {
|
|
173
|
+
console.log(formatPrettyTree(result.tree as never, options.showKind ?? false));
|
|
174
|
+
} else {
|
|
175
|
+
console.log(formatJson(formatSuccess({ source: sourceDesc, ...result })));
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error(formatJson(formatError('TREE_ERROR', (error as Error).message)));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
resource
|
|
184
|
+
.command('create')
|
|
185
|
+
.description('Create a new resource record')
|
|
186
|
+
.option('--source <path>', `data source (default: ${DEFAULT_SOURCE_PATH})`)
|
|
187
|
+
.option('--kind <kind>', 'resource kind')
|
|
188
|
+
.option('--parent-id <id>', 'parent resource id')
|
|
189
|
+
.option('--settings <json>', 'resource settings as JSON string')
|
|
190
|
+
.option('--from-file <path>', 'read full resource record from JSON file')
|
|
191
|
+
.option('--from-stdin', 'read full resource record from stdin')
|
|
192
|
+
.option('--dry-run', 'preview without writing')
|
|
193
|
+
.action(async (options) => {
|
|
194
|
+
try {
|
|
195
|
+
if (isStdinSource(options.source)) {
|
|
196
|
+
console.error(
|
|
197
|
+
formatJson(formatError('STDIN_NOT_WRITABLE', 'stdin source does not support write operations'))
|
|
198
|
+
);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const sourceData = await resolveSource(options.source);
|
|
203
|
+
|
|
204
|
+
let fromFileRecord: Record<string, unknown> | undefined;
|
|
205
|
+
|
|
206
|
+
if (options.fromFile) {
|
|
207
|
+
const fs = await import('fs-extra');
|
|
208
|
+
fromFileRecord = await fs.readJson(options.fromFile);
|
|
209
|
+
} else if (options.fromStdin) {
|
|
210
|
+
const chunks: Buffer[] = [];
|
|
211
|
+
process.stdin.setEncoding('utf-8');
|
|
212
|
+
for await (const chunk of process.stdin) {
|
|
213
|
+
chunks.push(Buffer.from(chunk));
|
|
214
|
+
}
|
|
215
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
216
|
+
fromFileRecord = JSON.parse(raw);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let settings: Record<string, unknown> | undefined;
|
|
220
|
+
if (options.settings) {
|
|
221
|
+
settings = JSON.parse(options.settings);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const { record } = await createResource({
|
|
225
|
+
sourceData,
|
|
226
|
+
kind: options.kind,
|
|
227
|
+
parentId: options.parentId,
|
|
228
|
+
settings,
|
|
229
|
+
fromFileRecord: fromFileRecord as never,
|
|
230
|
+
dryRun: options.dryRun
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
console.log(formatJson(formatSuccess({ record, dryRun: options.dryRun ?? false })));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error(formatJson(formatError('CREATE_ERROR', (error as Error).message)));
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
resource
|
|
241
|
+
.command('update <id>')
|
|
242
|
+
.description('Update a resource record')
|
|
243
|
+
.option('--source <path>', `data source (default: ${DEFAULT_SOURCE_PATH})`)
|
|
244
|
+
.option('--settings <json>', 'resource settings to merge (JSON string, required)')
|
|
245
|
+
.option('--dry-run', 'preview without writing')
|
|
246
|
+
.action(async (id, options) => {
|
|
247
|
+
try {
|
|
248
|
+
if (isStdinSource(options.source)) {
|
|
249
|
+
console.error(
|
|
250
|
+
formatJson(formatError('STDIN_NOT_WRITABLE', 'stdin source does not support write operations'))
|
|
251
|
+
);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const sourceData = await resolveSource(options.source);
|
|
256
|
+
|
|
257
|
+
if (!options.settings) {
|
|
258
|
+
console.error(formatJson(formatError('MISSING_SETTINGS', 'Required option: --settings <json>')));
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const settings = JSON.parse(options.settings);
|
|
263
|
+
const { record } = await updateResource({
|
|
264
|
+
sourceData,
|
|
265
|
+
id,
|
|
266
|
+
settings,
|
|
267
|
+
dryRun: options.dryRun
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
console.log(formatJson(formatSuccess({ record, dryRun: options.dryRun ?? false })));
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error(formatJson(formatError('UPDATE_ERROR', (error as Error).message)));
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
resource
|
|
278
|
+
.command('delete <id>')
|
|
279
|
+
.description('Delete a resource record and its children')
|
|
280
|
+
.option('--source <path>', `data source (default: ${DEFAULT_SOURCE_PATH})`)
|
|
281
|
+
.option('--dry-run', 'preview deletion scope without writing')
|
|
282
|
+
.action(async (id, options) => {
|
|
283
|
+
try {
|
|
284
|
+
if (isStdinSource(options.source)) {
|
|
285
|
+
console.error(
|
|
286
|
+
formatJson(formatError('STDIN_NOT_WRITABLE', 'stdin source does not support write operations'))
|
|
287
|
+
);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const sourceData = await resolveSource(options.source);
|
|
292
|
+
const { removed } = await deleteResource({
|
|
293
|
+
sourceData,
|
|
294
|
+
id,
|
|
295
|
+
dryRun: options.dryRun
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
console.log(
|
|
299
|
+
formatJson(
|
|
300
|
+
formatSuccess({
|
|
301
|
+
dryRun: options.dryRun ?? false,
|
|
302
|
+
removedCount: removed.length,
|
|
303
|
+
removed: removed.map((r) => ({ id: r.id, kind: r.kind }))
|
|
304
|
+
})
|
|
305
|
+
)
|
|
306
|
+
);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error(formatJson(formatError('DELETE_ERROR', (error as Error).message)));
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
resource
|
|
314
|
+
.command('convert')
|
|
315
|
+
.description('Convert resource records between aPaaS and Runtime formats')
|
|
316
|
+
.option('--from <format>', 'source format (apaas or runtime)')
|
|
317
|
+
.option('--to <format>', 'target format (apaas or runtime)')
|
|
318
|
+
.option('--input <path>', 'input file path')
|
|
319
|
+
.option('--output <path>', 'output file path (omit for stdout)')
|
|
320
|
+
.action(async (options) => {
|
|
321
|
+
try {
|
|
322
|
+
if (!options.from || !options.to || !options.input) {
|
|
323
|
+
console.error(formatJson(formatError('MISSING_ARGS', 'Required options: --from, --to, --input')));
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const result = await convertResource({
|
|
328
|
+
from: options.from,
|
|
329
|
+
to: options.to,
|
|
330
|
+
input: options.input,
|
|
331
|
+
output: options.output
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (result.outputPath) {
|
|
335
|
+
console.log(formatJson(formatSuccess({ outputPath: result.outputPath })));
|
|
336
|
+
} else {
|
|
337
|
+
console.log(formatJson(formatSuccess({ data: result.data })));
|
|
338
|
+
}
|
|
339
|
+
} catch (error) {
|
|
340
|
+
console.error(formatJson(formatError('CONVERT_ERROR', (error as Error).message)));
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
resource
|
|
346
|
+
.command('diff <id>')
|
|
347
|
+
.description('Diff a resource between two sources')
|
|
348
|
+
.option('--source-a <path>', 'first source file (required)')
|
|
349
|
+
.option('--source-b <path>', 'second source file (required)')
|
|
350
|
+
.option('--pretty', 'human-readable output')
|
|
351
|
+
.action(async (id, options) => {
|
|
352
|
+
try {
|
|
353
|
+
if (!options.sourceA || !options.sourceB) {
|
|
354
|
+
console.error(formatJson(formatError('MISSING_ARGS', 'Required options: --source-a, --source-b')));
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const sourceDataA = await resolveSource(options.sourceA);
|
|
359
|
+
const sourceDataB = await resolveSource(options.sourceB);
|
|
360
|
+
|
|
361
|
+
const result = await diffResource({
|
|
362
|
+
sourceA: getSourceDescription(options.sourceA),
|
|
363
|
+
sourceB: getSourceDescription(options.sourceB),
|
|
364
|
+
id,
|
|
365
|
+
recordsA: sourceDataA.resourceRecords,
|
|
366
|
+
recordsB: sourceDataB.resourceRecords
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (options.pretty) {
|
|
370
|
+
console.log(formatPrettyDiff(result));
|
|
371
|
+
} else {
|
|
372
|
+
console.log(formatJson(formatSuccess(result)));
|
|
373
|
+
}
|
|
374
|
+
} catch (error) {
|
|
375
|
+
console.error(formatJson(formatError('DIFF_ERROR', (error as Error).message)));
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
72
380
|
program.parse(process.argv);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { convertApaasResourceRecordsToResourceRecords } from '@ldsg/apaas-to-runtime';
|
|
3
|
+
import { ResourceRecords } from '@ldsg/types';
|
|
4
|
+
|
|
5
|
+
export interface ConvertOptions {
|
|
6
|
+
from: string;
|
|
7
|
+
to: string;
|
|
8
|
+
input: string;
|
|
9
|
+
output?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function convertResource(
|
|
13
|
+
options: ConvertOptions
|
|
14
|
+
): Promise<{ outputPath?: string; data: ResourceRecords }> {
|
|
15
|
+
const { from, to, input, output } = options;
|
|
16
|
+
|
|
17
|
+
if (from === 'runtime' && to === 'apaas') {
|
|
18
|
+
throw new Error('Runtime → aPaaS conversion is not supported yet');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (from !== 'apaas' || to !== 'runtime') {
|
|
22
|
+
throw new Error(`Unsupported conversion: ${from} → ${to}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!(await fs.pathExists(input))) {
|
|
26
|
+
throw new Error(`Input file not found: ${input}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rawData = await fs.readJson(input);
|
|
30
|
+
let resourceRecords: ResourceRecords;
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(rawData)) {
|
|
33
|
+
resourceRecords = rawData as ResourceRecords;
|
|
34
|
+
} else if (rawData && typeof rawData === 'object' && Array.isArray(rawData.resourceRecords)) {
|
|
35
|
+
resourceRecords = rawData.resourceRecords as ResourceRecords;
|
|
36
|
+
} else {
|
|
37
|
+
throw new Error('Invalid input format: expected an array or { resourceRecords: [] }');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { targetResourceRecords } = convertApaasResourceRecordsToResourceRecords({
|
|
41
|
+
resourceRecords
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (output) {
|
|
45
|
+
const isArrayInput = Array.isArray(rawData);
|
|
46
|
+
const outputData = isArrayInput ? targetResourceRecords : { resourceRecords: targetResourceRecords };
|
|
47
|
+
await fs.writeJson(output, outputData, { spaces: 2 });
|
|
48
|
+
return { outputPath: output, data: targetResourceRecords };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { data: targetResourceRecords };
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ResourceRecord } from '@ldsg/types';
|
|
2
|
+
import { SourceData } from '../../types/resource-record';
|
|
3
|
+
import { appendRecord, generateId } from '../../utils/json-file-mutator';
|
|
4
|
+
|
|
5
|
+
export interface CreateOptions {
|
|
6
|
+
sourceData: SourceData;
|
|
7
|
+
id?: string;
|
|
8
|
+
kind?: string;
|
|
9
|
+
parentId?: string;
|
|
10
|
+
settings?: Record<string, unknown>;
|
|
11
|
+
fromFileRecord?: Partial<ResourceRecord>;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function createResource(options: CreateOptions): Promise<{ record: ResourceRecord }> {
|
|
16
|
+
const { sourceData, dryRun } = options;
|
|
17
|
+
|
|
18
|
+
let record: ResourceRecord;
|
|
19
|
+
|
|
20
|
+
if (options.fromFileRecord) {
|
|
21
|
+
const partial = options.fromFileRecord;
|
|
22
|
+
if (!partial.kind) {
|
|
23
|
+
throw new Error('Missing required field: kind');
|
|
24
|
+
}
|
|
25
|
+
record = {
|
|
26
|
+
id: partial.id || generateId(),
|
|
27
|
+
kind: partial.kind,
|
|
28
|
+
parentId: partial.parentId ?? sourceData.resourceRecords[0]?.parentId ?? null,
|
|
29
|
+
settings: (partial.settings || {}) as Record<string, unknown>
|
|
30
|
+
};
|
|
31
|
+
} else {
|
|
32
|
+
if (!options.kind) {
|
|
33
|
+
throw new Error('Missing required option: --kind');
|
|
34
|
+
}
|
|
35
|
+
if (options.parentId === undefined) {
|
|
36
|
+
throw new Error('Missing required option: --parent-id');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
record = {
|
|
40
|
+
id: options.id || generateId(),
|
|
41
|
+
kind: options.kind,
|
|
42
|
+
parentId: options.parentId ?? null,
|
|
43
|
+
settings: options.settings || {}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = await appendRecord(sourceData, record, dryRun);
|
|
48
|
+
return { record: result.record };
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ResourceRecord } from '@ldsg/types';
|
|
2
|
+
import { SourceData } from '../../types/resource-record';
|
|
3
|
+
import { removeRecord } from '../../utils/json-file-mutator';
|
|
4
|
+
|
|
5
|
+
export interface DeleteOptions {
|
|
6
|
+
sourceData: SourceData;
|
|
7
|
+
id: string;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function deleteResource(options: DeleteOptions): Promise<{ removed: ResourceRecord[] }> {
|
|
12
|
+
const { sourceData, id, dryRun } = options;
|
|
13
|
+
const result = await removeRecord(sourceData, id, dryRun);
|
|
14
|
+
return { removed: result.removed };
|
|
15
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ResourceRecords } from '@ldsg/types';
|
|
2
|
+
import { DiffResult } from '../../types/resource-record';
|
|
3
|
+
|
|
4
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface DiffEntry {
|
|
9
|
+
path: string;
|
|
10
|
+
from?: unknown;
|
|
11
|
+
to?: unknown;
|
|
12
|
+
value?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function deepDiff(
|
|
16
|
+
objA: Record<string, unknown>,
|
|
17
|
+
objB: Record<string, unknown>,
|
|
18
|
+
basePath = ''
|
|
19
|
+
): {
|
|
20
|
+
changed: DiffEntry[];
|
|
21
|
+
added: DiffEntry[];
|
|
22
|
+
removed: DiffEntry[];
|
|
23
|
+
} {
|
|
24
|
+
const changed: DiffEntry[] = [];
|
|
25
|
+
const added: DiffEntry[] = [];
|
|
26
|
+
const removed: DiffEntry[] = [];
|
|
27
|
+
|
|
28
|
+
const allKeys = new Set([...Object.keys(objA), ...Object.keys(objB)]);
|
|
29
|
+
|
|
30
|
+
for (const key of allKeys) {
|
|
31
|
+
const path = basePath ? `${basePath}.${key}` : key;
|
|
32
|
+
const valA = objA[key];
|
|
33
|
+
const valB = objB[key];
|
|
34
|
+
|
|
35
|
+
if (key in objA && !(key in objB)) {
|
|
36
|
+
removed.push({ path, from: valA });
|
|
37
|
+
} else if (!(key in objA) && key in objB) {
|
|
38
|
+
added.push({ path, value: valB });
|
|
39
|
+
} else if (isObject(valA) && isObject(valB)) {
|
|
40
|
+
const nested = deepDiff(valA, valB, path);
|
|
41
|
+
changed.push(...nested.changed);
|
|
42
|
+
added.push(...nested.added);
|
|
43
|
+
removed.push(...nested.removed);
|
|
44
|
+
} else if (valA !== valB) {
|
|
45
|
+
changed.push({ path, from: valA, to: valB });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { changed, added, removed };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DiffOptions {
|
|
53
|
+
sourceA: string;
|
|
54
|
+
sourceB: string;
|
|
55
|
+
id: string;
|
|
56
|
+
recordsA: ResourceRecords;
|
|
57
|
+
recordsB: ResourceRecords;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function diffResource(options: DiffOptions): Promise<DiffResult> {
|
|
61
|
+
const { sourceA, sourceB, id, recordsA, recordsB } = options;
|
|
62
|
+
|
|
63
|
+
const recordA = recordsA.find((r) => r.id === id) || null;
|
|
64
|
+
const recordB = recordsB.find((r) => r.id === id) || null;
|
|
65
|
+
|
|
66
|
+
const result: DiffResult = {
|
|
67
|
+
id,
|
|
68
|
+
kind: (recordA || recordB)?.kind || '',
|
|
69
|
+
sourceA,
|
|
70
|
+
sourceB,
|
|
71
|
+
changed: [],
|
|
72
|
+
added: [],
|
|
73
|
+
removed: [],
|
|
74
|
+
sideA: recordA,
|
|
75
|
+
sideB: recordB
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (!recordA || !recordB) {
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (recordA.kind !== recordB.kind) {
|
|
83
|
+
result.changed.push({
|
|
84
|
+
path: 'kind',
|
|
85
|
+
from: recordA.kind,
|
|
86
|
+
to: recordB.kind
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (recordA.parentId !== recordB.parentId) {
|
|
91
|
+
result.changed.push({
|
|
92
|
+
path: 'parentId',
|
|
93
|
+
from: recordA.parentId || null,
|
|
94
|
+
to: recordB.parentId || null
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const settingsDiff = deepDiff(recordA.settings || {}, recordB.settings || {});
|
|
99
|
+
result.changed.push(...settingsDiff.changed);
|
|
100
|
+
result.added.push(...settingsDiff.added);
|
|
101
|
+
result.removed.push(...settingsDiff.removed);
|
|
102
|
+
|
|
103
|
+
return result;
|
|
104
|
+
}
|