@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.
Files changed (77) hide show
  1. package/.rush/temp/package-deps_build.json +22 -5
  2. package/.tsbuildinfo +1 -1
  3. package/CHANGELOG.json +26 -0
  4. package/CHANGELOG.md +8 -1
  5. package/dist/__tests__/commands.test.d.ts +1 -0
  6. package/dist/__tests__/commands.test.js +219 -0
  7. package/dist/__tests__/commands.test.js.map +1 -0
  8. package/dist/__tests__/formatter.test.d.ts +1 -0
  9. package/dist/__tests__/formatter.test.js +114 -0
  10. package/dist/__tests__/formatter.test.js.map +1 -0
  11. package/dist/__tests__/json-file-mutator.test.d.ts +1 -0
  12. package/dist/__tests__/json-file-mutator.test.js +103 -0
  13. package/dist/__tests__/json-file-mutator.test.js.map +1 -0
  14. package/dist/__tests__/source-resolver.test.d.ts +1 -0
  15. package/dist/__tests__/source-resolver.test.js +101 -0
  16. package/dist/__tests__/source-resolver.test.js.map +1 -0
  17. package/dist/cli.js +304 -0
  18. package/dist/cli.js.map +1 -1
  19. package/dist/commands/resource/convert.d.ts +11 -0
  20. package/dist/commands/resource/convert.js +42 -0
  21. package/dist/commands/resource/convert.js.map +1 -0
  22. package/dist/commands/resource/create.d.ts +14 -0
  23. package/dist/commands/resource/create.js +37 -0
  24. package/dist/commands/resource/create.js.map +1 -0
  25. package/dist/commands/resource/delete.d.ts +10 -0
  26. package/dist/commands/resource/delete.js +10 -0
  27. package/dist/commands/resource/delete.js.map +1 -0
  28. package/dist/commands/resource/diff.d.ts +10 -0
  29. package/dist/commands/resource/diff.js +72 -0
  30. package/dist/commands/resource/diff.js.map +1 -0
  31. package/dist/commands/resource/get.d.ts +6 -0
  32. package/dist/commands/resource/get.js +6 -0
  33. package/dist/commands/resource/get.js.map +1 -0
  34. package/dist/commands/resource/list.d.ts +30 -0
  35. package/dist/commands/resource/list.js +138 -0
  36. package/dist/commands/resource/list.js.map +1 -0
  37. package/dist/commands/resource/tree.d.ts +2 -0
  38. package/dist/commands/resource/tree.js +6 -0
  39. package/dist/commands/resource/tree.js.map +1 -0
  40. package/dist/commands/resource/update.d.ts +11 -0
  41. package/dist/commands/resource/update.js +13 -0
  42. package/dist/commands/resource/update.js.map +1 -0
  43. package/dist/constants.d.ts +2 -0
  44. package/dist/constants.js +3 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/types/resource-record.d.ts +43 -0
  47. package/dist/types/resource-record.js +3 -0
  48. package/dist/types/resource-record.js.map +1 -0
  49. package/dist/utils/formatter.d.ts +12 -0
  50. package/dist/utils/formatter.js +96 -0
  51. package/dist/utils/formatter.js.map +1 -0
  52. package/dist/utils/json-file-mutator.d.ts +15 -0
  53. package/dist/utils/json-file-mutator.js +109 -0
  54. package/dist/utils/json-file-mutator.js.map +1 -0
  55. package/dist/utils/source-resolver.d.ts +4 -0
  56. package/dist/utils/source-resolver.js +93 -0
  57. package/dist/utils/source-resolver.js.map +1 -0
  58. package/jest.config.js +12 -0
  59. package/package.json +8 -6
  60. package/src/__tests__/commands.test.ts +262 -0
  61. package/src/__tests__/formatter.test.ts +133 -0
  62. package/src/__tests__/json-file-mutator.test.ts +136 -0
  63. package/src/__tests__/source-resolver.test.ts +125 -0
  64. package/src/cli.ts +309 -1
  65. package/src/commands/resource/convert.ts +52 -0
  66. package/src/commands/resource/create.ts +49 -0
  67. package/src/commands/resource/delete.ts +15 -0
  68. package/src/commands/resource/diff.ts +104 -0
  69. package/src/commands/resource/get.ts +5 -0
  70. package/src/commands/resource/list.ts +161 -0
  71. package/src/commands/resource/tree.ts +4 -0
  72. package/src/commands/resource/update.ts +21 -0
  73. package/src/constants.ts +3 -0
  74. package/src/types/resource-record.ts +52 -0
  75. package/src/utils/formatter.ts +106 -0
  76. package/src/utils/json-file-mutator.ts +137 -0
  77. 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
+ }
@@ -0,0 +1,5 @@
1
+ import { ResourceRecords } from '@ldsg/types';
2
+ import { getResource } from './list';
3
+
4
+ export { getResource };
5
+ export type GetOptions = { children?: boolean; tree?: boolean };