@ldsg/cli 0.3.38 → 0.3.41

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 (59) hide show
  1. package/.rush/temp/package-deps_build.json +36 -33
  2. package/.rush/temp/shrinkwrap-deps.json +4 -1
  3. package/.tsbuildinfo +1 -1
  4. package/CHANGELOG.json +69 -0
  5. package/CHANGELOG.md +22 -1
  6. package/README.md +87 -4
  7. package/dist/__tests__/commands.test.js +68 -12
  8. package/dist/__tests__/commands.test.js.map +1 -1
  9. package/dist/__tests__/formatter.test.js +18 -3
  10. package/dist/__tests__/formatter.test.js.map +1 -1
  11. package/dist/__tests__/generate-app-index-ts.test.d.ts +1 -0
  12. package/dist/__tests__/generate-app-index-ts.test.js +109 -0
  13. package/dist/__tests__/generate-app-index-ts.test.js.map +1 -0
  14. package/dist/__tests__/get-extend-app-data-params-using-workspace-ldsg-module.test.d.ts +1 -0
  15. package/dist/__tests__/get-extend-app-data-params-using-workspace-ldsg-module.test.js +81 -0
  16. package/dist/__tests__/get-extend-app-data-params-using-workspace-ldsg-module.test.js.map +1 -0
  17. package/dist/__tests__/json-file-mutator.test.js +54 -9
  18. package/dist/__tests__/json-file-mutator.test.js.map +1 -1
  19. package/dist/__tests__/mutate.test.d.ts +1 -0
  20. package/dist/__tests__/mutate.test.js +334 -0
  21. package/dist/__tests__/mutate.test.js.map +1 -0
  22. package/dist/cli.js +131 -50
  23. package/dist/cli.js.map +1 -1
  24. package/dist/commands/resource/create.js +1 -1
  25. package/dist/commands/resource/create.js.map +1 -1
  26. package/dist/commands/resource/list.js +1 -1
  27. package/dist/commands/resource/list.js.map +1 -1
  28. package/dist/commands/resource/mutate.d.ts +80 -0
  29. package/dist/commands/resource/mutate.js +311 -0
  30. package/dist/commands/resource/mutate.js.map +1 -0
  31. package/dist/constants.d.ts +3 -0
  32. package/dist/constants.js +4 -1
  33. package/dist/constants.js.map +1 -1
  34. package/dist/types/get-extend-app-data-params.d.ts +4 -1
  35. package/dist/utils/get-extend-app-data-params-using-workspace-ldsg-module/index.js +22 -6
  36. package/dist/utils/get-extend-app-data-params-using-workspace-ldsg-module/index.js.map +1 -1
  37. package/jest.config.js +3 -4
  38. package/package.json +4 -5
  39. package/src/__tests__/commands.test.ts +68 -12
  40. package/src/__tests__/formatter.test.ts +18 -3
  41. package/src/__tests__/generate-app-index-ts.test.ts +141 -0
  42. package/src/__tests__/get-extend-app-data-params-using-workspace-ldsg-module.test.ts +109 -0
  43. package/src/__tests__/json-file-mutator.test.ts +54 -9
  44. package/src/__tests__/mutate.test.ts +392 -0
  45. package/src/cli.ts +179 -54
  46. package/src/commands/resource/create.ts +3 -3
  47. package/src/commands/resource/list.ts +1 -1
  48. package/src/commands/resource/mutate.ts +542 -0
  49. package/src/constants.ts +4 -0
  50. package/src/types/get-extend-app-data-params.ts +4 -1
  51. package/src/utils/get-extend-app-data-params-using-workspace-ldsg-module/index.ts +43 -7
  52. package/.rush/temp/chunked-rush-logs/cli.format.chunks.jsonl +0 -2
  53. package/.rush/temp/package-deps_format.json +0 -38
  54. package/dist/commands/resource/convert.d.ts +0 -11
  55. package/dist/commands/resource/convert.js +0 -44
  56. package/dist/commands/resource/convert.js.map +0 -1
  57. package/rush-logs/cli.format.log +0 -2
  58. package/src/commands/resource/convert.ts +0 -54
  59. package/tsconfig.build.tsbuildinfo +0 -1
@@ -0,0 +1,392 @@
1
+ import axios from "axios";
2
+ import fs from "fs-extra";
3
+ import { ResourceRecords } from "@ldsg/types";
4
+ import {
5
+ applyIdMapToRecords,
6
+ buildIdMap,
7
+ buildMutatePlan,
8
+ mutateRecordsDirect,
9
+ mutateResourceRecords,
10
+ mutateResources,
11
+ pullRemoteResourceRecords,
12
+ purifyResourceRecord,
13
+ } from "../commands/resource/mutate";
14
+ import { SourceData } from "../types/resource-record";
15
+ import { DEFAULT_RRM_GRAPHQL_ENDPOINT } from "../constants";
16
+
17
+ jest.mock("axios");
18
+ jest.mock("fs-extra");
19
+
20
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
21
+ const mockedFs = fs as jest.Mocked<typeof fs>;
22
+
23
+ const localRecords: ResourceRecords = [
24
+ {
25
+ id: "app-temp-1",
26
+ kind: "application",
27
+ parentId: null,
28
+ settings: { slug: "my-app", title: "My App", description: "" },
29
+ },
30
+ {
31
+ id: "form-temp-1",
32
+ kind: "form",
33
+ parentId: "app-temp-1",
34
+ settings: { slug: "login", title: "Login", description: "" },
35
+ },
36
+ {
37
+ id: "existing-app",
38
+ kind: "application",
39
+ parentId: null,
40
+ settings: { slug: "existing-application", title: "Existing App", description: "", extra: true },
41
+ },
42
+ ];
43
+
44
+ const remoteRecords: ResourceRecords = [
45
+ {
46
+ id: "existing-app",
47
+ kind: "application",
48
+ parentId: null,
49
+ settings: { slug: "existing-application", title: "Existing App", description: "" },
50
+ },
51
+ {
52
+ id: "remote-only-app",
53
+ kind: "application",
54
+ parentId: null,
55
+ settings: { slug: "remote-only-application", title: "Remote Only", description: "" },
56
+ },
57
+ ];
58
+
59
+ const sourceData: SourceData = {
60
+ resourceRecords: localRecords,
61
+ format: "array",
62
+ filePath: "/tmp/resource-records.json",
63
+ };
64
+
65
+ describe("mutate command", () => {
66
+ beforeEach(() => {
67
+ jest.resetAllMocks();
68
+ });
69
+
70
+ describe("purifyResourceRecord", () => {
71
+ it("should keep only id, kind, parentId, settings", () => {
72
+ const result = purifyResourceRecord({
73
+ id: "x",
74
+ kind: "table",
75
+ parentId: "app1",
76
+ settings: { title: "T" },
77
+ extraField: "nope",
78
+ } as any);
79
+
80
+ expect(result).toEqual({
81
+ id: "x",
82
+ kind: "table",
83
+ parentId: "app1",
84
+ settings: { title: "T" },
85
+ });
86
+ expect(result).not.toHaveProperty("extraField");
87
+ });
88
+ });
89
+
90
+ describe("buildMutatePlan", () => {
91
+ it("should split create/update/delete like console", () => {
92
+ const plan = buildMutatePlan(localRecords, remoteRecords);
93
+
94
+ expect(plan.createResourceRecords.map((r) => r.id)).toEqual(["app-temp-1", "form-temp-1"]);
95
+ expect(plan.updateResourceRecords.map((r) => r.id)).toEqual(["existing-app"]);
96
+ expect(plan.deleteResourceRecords.map((r) => r.id)).toEqual(["remote-only-app"]);
97
+ expect(plan.deleteResourceRecords[0].settings.isDeleted).toBe(true);
98
+ expect(plan.remoteCount).toBe(2);
99
+ expect(plan.localCount).toBe(3);
100
+ });
101
+
102
+ it("should skip soft-delete when deleteMissing=false", () => {
103
+ const plan = buildMutatePlan(localRecords, remoteRecords, { deleteMissing: false });
104
+ expect(plan.deleteResourceRecords).toHaveLength(0);
105
+ expect(plan.createResourceRecords).toHaveLength(2);
106
+ });
107
+ });
108
+
109
+ describe("replaceIdsDeep / applyIdMapToRecords", () => {
110
+ it("should replace id, parentId and settings references", () => {
111
+ const idMap = {
112
+ "app-temp-1": "6a76d34f09e2c7630c859301",
113
+ "form-temp-1": "6a76d34f09e2c7630c859302",
114
+ };
115
+
116
+ const result = applyIdMapToRecords(localRecords, idMap);
117
+
118
+ expect(result[0].id).toBe("6a76d34f09e2c7630c859301");
119
+ expect(result[1].parentId).toBe("6a76d34f09e2c7630c859301");
120
+ expect(result[1].id).toBe("6a76d34f09e2c7630c859302");
121
+ });
122
+
123
+ it("should replace exact-string values inside settings", () => {
124
+ const record: any = {
125
+ id: "obj-temp",
126
+ kind: "object",
127
+ parentId: "app-temp-1",
128
+ settings: {
129
+ databaseResourceId: "app-temp-1",
130
+ title: "keep app-temp-1 as literal substring?",
131
+ },
132
+ };
133
+ const result = applyIdMapToRecords([record], { "app-temp-1": "SERVER-APP" }) as any;
134
+
135
+ expect(result[0].settings.databaseResourceId).toBe("SERVER-APP");
136
+ expect(result[0].settings.title).toBe("keep app-temp-1 as literal substring?");
137
+ });
138
+ });
139
+
140
+ describe("buildIdMap", () => {
141
+ it("should map by index and skip identity", () => {
142
+ const map = buildIdMap(
143
+ [
144
+ { id: "a", kind: "k", parentId: null, settings: {} },
145
+ { id: "b", kind: "k", parentId: null, settings: {} },
146
+ ],
147
+ [
148
+ { id: "server-a", kind: "k", parentId: null, settings: {} },
149
+ { id: "b", kind: "k", parentId: null, settings: {} },
150
+ ],
151
+ );
152
+
153
+ expect(map).toEqual({ a: "server-a" });
154
+ });
155
+
156
+ it("should return empty map on length mismatch", () => {
157
+ const map = buildIdMap([{ id: "a", kind: "k", parentId: null, settings: {} }], []);
158
+ expect(map).toEqual({});
159
+ });
160
+ });
161
+
162
+ describe("mutateResourceRecords", () => {
163
+ it("should POST the exact mutation payload", async () => {
164
+ mockedAxios.post.mockResolvedValue({
165
+ data: {
166
+ data: {
167
+ mutateResourceRecords: {
168
+ createdResourceRecords: [{ id: "s1" }],
169
+ updatedResourceRecords: [{ id: "u1" }],
170
+ },
171
+ },
172
+ },
173
+ });
174
+
175
+ const res = await mutateResourceRecords({
176
+ endpoint: DEFAULT_RRM_GRAPHQL_ENDPOINT,
177
+ token: "TOKEN",
178
+ createResourceRecords: [{ id: "a", kind: "k", parentId: null, settings: {} }],
179
+ updateResourceRecords: [],
180
+ });
181
+
182
+ const [, body, config] = mockedAxios.post.mock.calls[0];
183
+ expect((body as any).variables.createResourceRecords).toEqual([
184
+ { id: "a", kind: "k", parentId: null, settings: {} },
185
+ ]);
186
+ expect((config as any).headers.authorization).toBe("Bearer TOKEN");
187
+ expect((body as any).query).toContain("mutateResourceRecords");
188
+ expect(res.createdResourceRecords[0].id).toBe("s1");
189
+ });
190
+
191
+ it("should throw on GraphQL errors", async () => {
192
+ mockedAxios.post.mockResolvedValue({
193
+ data: { errors: [{ message: "boom" }] },
194
+ });
195
+
196
+ await expect(
197
+ mutateResourceRecords({
198
+ endpoint: DEFAULT_RRM_GRAPHQL_ENDPOINT,
199
+ createResourceRecords: [],
200
+ updateResourceRecords: [],
201
+ }),
202
+ ).rejects.toThrow("boom");
203
+ });
204
+ });
205
+
206
+ describe("mutateResources (auto mode)", () => {
207
+ beforeEach(() => {
208
+ mockedAxios.post.mockImplementation(async (url, body: any) => {
209
+ const query = String(body.query ?? "");
210
+ if (query.includes("resourceRecordConnection")) {
211
+ return {
212
+ data: {
213
+ data: {
214
+ resourceRecordConnection: {
215
+ pageInfo: { hasNextPage: false },
216
+ edges: remoteRecords.map((r) => ({ node: r })),
217
+ },
218
+ },
219
+ },
220
+ };
221
+ }
222
+ if (query.includes("mutateResourceRecords")) {
223
+ return {
224
+ data: {
225
+ data: {
226
+ mutateResourceRecords: {
227
+ createdResourceRecords: [
228
+ { id: "SERVER-APP", kind: "application", parentId: null, settings: {} },
229
+ { id: "SERVER-FORM", kind: "form", parentId: "SERVER-APP", settings: {} },
230
+ ],
231
+ updatedResourceRecords: [],
232
+ },
233
+ },
234
+ },
235
+ };
236
+ }
237
+ return { data: {} };
238
+ });
239
+ });
240
+
241
+ it("should return plan only in dry-run mode without calling mutation", async () => {
242
+ const res = await mutateResources({ sourceData, dryRun: true });
243
+
244
+ expect(res.dryRun).toBe(true);
245
+ expect(res.plan.createResourceRecords).toHaveLength(2);
246
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1); // 只拉取了远端
247
+ });
248
+
249
+ it("should throw when token missing and there are writes", async () => {
250
+ await expect(mutateResources({ sourceData })).rejects.toThrow(/token/i);
251
+ });
252
+
253
+ it("should throw requiring --yes when soft-deleting", async () => {
254
+ process.env.RRM_ACCESS_TOKEN = "ENV_TOKEN";
255
+ await expect(mutateResources({ sourceData })).rejects.toThrow(/--yes/);
256
+ delete process.env.RRM_ACCESS_TOKEN;
257
+ });
258
+
259
+ it("should mutate, write back ids and confirm deletion with --yes", async () => {
260
+ mockedFs.writeJson.mockResolvedValue(undefined as never);
261
+
262
+ const res = await mutateResources({ sourceData, token: "TOKEN", yes: true });
263
+
264
+ expect(res.idMap).toEqual({ "app-temp-1": "SERVER-APP", "form-temp-1": "SERVER-FORM" });
265
+ expect(res.writeBackPath).toBe("/tmp/resource-records.json");
266
+ expect(mockedFs.writeJson).toHaveBeenCalledWith(
267
+ "/tmp/resource-records.json",
268
+ [
269
+ {
270
+ id: "SERVER-APP",
271
+ kind: "application",
272
+ parentId: null,
273
+ settings: { slug: "my-app", title: "My App", description: "" },
274
+ },
275
+ {
276
+ id: "SERVER-FORM",
277
+ kind: "form",
278
+ parentId: "SERVER-APP",
279
+ settings: { slug: "login", title: "Login", description: "" },
280
+ },
281
+ {
282
+ id: "existing-app",
283
+ kind: "application",
284
+ parentId: null,
285
+ settings: {
286
+ slug: "existing-application",
287
+ title: "Existing App",
288
+ description: "",
289
+ extra: true,
290
+ },
291
+ },
292
+ ],
293
+ { spaces: 2 },
294
+ );
295
+ });
296
+
297
+ it("should skip write-back when --no-write-back", async () => {
298
+ const res = await mutateResources({
299
+ sourceData,
300
+ token: "TOKEN",
301
+ yes: true,
302
+ writeBack: false,
303
+ });
304
+ expect(res.writeBackPath).toBeUndefined();
305
+ expect(mockedFs.writeJson).not.toHaveBeenCalled();
306
+ });
307
+ });
308
+
309
+ describe("mutateRecordsDirect (explicit mode)", () => {
310
+ it("should POST exactly the given records without fetching remote", async () => {
311
+ mockedAxios.post.mockResolvedValue({
312
+ data: {
313
+ data: {
314
+ mutateResourceRecords: {
315
+ createdResourceRecords: [{ id: "server-1" }],
316
+ updatedResourceRecords: [],
317
+ },
318
+ },
319
+ },
320
+ });
321
+
322
+ const res = await mutateRecordsDirect({
323
+ endpoint: DEFAULT_RRM_GRAPHQL_ENDPOINT,
324
+ token: "TOKEN",
325
+ createResourceRecords: [{ id: "temp-1", kind: "table", parentId: null, settings: {} }],
326
+ updateResourceRecords: [{ id: "u1", kind: "table", parentId: null, settings: {} }],
327
+ });
328
+
329
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
330
+ const [, body] = mockedAxios.post.mock.calls[0];
331
+ expect((body as any).variables.createResourceRecords).toEqual([
332
+ { id: "temp-1", kind: "table", parentId: null, settings: {} },
333
+ ]);
334
+ expect((body as any).variables.updateResourceRecords).toEqual([
335
+ { id: "u1", kind: "table", parentId: null, settings: {} },
336
+ ]);
337
+ expect(res.createCount).toBe(1);
338
+ expect(res.updateCount).toBe(1);
339
+ });
340
+
341
+ it("should not POST in dry-run mode", async () => {
342
+ const res = await mutateRecordsDirect({
343
+ endpoint: DEFAULT_RRM_GRAPHQL_ENDPOINT,
344
+ token: "TOKEN",
345
+ createResourceRecords: [{ id: "a", kind: "k", parentId: null, settings: {} }],
346
+ updateResourceRecords: [],
347
+ dryRun: true,
348
+ });
349
+
350
+ expect(res.dryRun).toBe(true);
351
+ expect(mockedAxios.post).not.toHaveBeenCalled();
352
+ });
353
+
354
+ it("should throw when token missing", async () => {
355
+ await expect(
356
+ mutateRecordsDirect({
357
+ endpoint: DEFAULT_RRM_GRAPHQL_ENDPOINT,
358
+ createResourceRecords: [{ id: "a", kind: "k", parentId: null, settings: {} }],
359
+ updateResourceRecords: [],
360
+ }),
361
+ ).rejects.toThrow(/token/i);
362
+ });
363
+ });
364
+
365
+ describe("pullRemoteResourceRecords", () => {
366
+ it("should fetch remote records and write to output file", async () => {
367
+ mockedAxios.post.mockResolvedValue({
368
+ data: {
369
+ data: {
370
+ resourceRecordConnection: {
371
+ pageInfo: { hasNextPage: false },
372
+ edges: remoteRecords.map((r) => ({ node: r })),
373
+ },
374
+ },
375
+ },
376
+ });
377
+ mockedFs.writeJson.mockResolvedValue(undefined as never);
378
+
379
+ const res = await pullRemoteResourceRecords({
380
+ endpoint: DEFAULT_RRM_GRAPHQL_ENDPOINT,
381
+ output: "/tmp/pulled.json",
382
+ });
383
+
384
+ expect(res.count).toBe(2);
385
+ expect(res.outputPath).toBe("/tmp/pulled.json");
386
+ expect(mockedFs.writeJson).toHaveBeenCalledWith("/tmp/pulled.json", remoteRecords, {
387
+ spaces: 2,
388
+ });
389
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
390
+ });
391
+ });
392
+ });
package/src/cli.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { convertApaasResourceRecordsToResourceRecords } from "@ldsg/apaas-to-runtime";
5
4
  import { AppData } from "@ldsg/types";
6
5
  import {
7
6
  extendAppData,
@@ -28,8 +27,13 @@ import { updateResource } from "./commands/resource/update";
28
27
  import { deleteResource } from "./commands/resource/delete";
29
28
  import { listResources, getTree } from "./commands/resource/list";
30
29
  import { getResource } from "./commands/resource/get";
31
- import { convertResource } from "./commands/resource/convert";
32
30
  import { diffResource } from "./commands/resource/diff";
31
+ import {
32
+ mutateRecordsDirect,
33
+ mutateResources,
34
+ pullRemoteResourceRecords,
35
+ purifyResourceRecord,
36
+ } from "./commands/resource/mutate";
33
37
 
34
38
  const { version } = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
35
39
 
@@ -42,12 +46,16 @@ program
42
46
  .aliases(["g", "gen"])
43
47
  .description("Generate standalone application from resource records")
44
48
  .option("--app-data-uri <uri>", "runtime app data URI")
45
- .option("--apaas-app-data-uri <uri>", "aPaaS app data URI (GraphQL)")
46
49
  .option(
47
- "--apaas-app-data-expression <expression>",
48
- "JSONata expression for aPaaS data extraction",
50
+ "--app-data-expression <expression>",
51
+ "runtime app data expression (jsonata), used to convert source data to standard ldsg resource records",
49
52
  )
50
53
  .option("--using-workspace-ldsg-module", "use workspace ldsg module mode")
54
+ .option(
55
+ "--layout <layout>",
56
+ `app layout: nested or flat (default: flat for workspace mode, nested otherwise)`,
57
+ )
58
+ .option("--app-package-name <name>", "app package name (default: @ldsg/app)")
51
59
  .option(
52
60
  "-o, --output <path>",
53
61
  `output directory for prepared app code (default: ${DEFAULT_OUTPUT_PATH})`,
@@ -57,27 +65,32 @@ program
57
65
 
58
66
  const usingWorkspaceLdsgModule = options.usingWorkspaceLdsgModule ?? false;
59
67
 
60
- const { resourceRecords, sourceType } = await loadAppResourceRecords({
68
+ const layout =
69
+ options.layout === "nested"
70
+ ? ZipAppDataLayout.NESTED
71
+ : options.layout === "flat"
72
+ ? ZipAppDataLayout.FLAT
73
+ : undefined;
74
+ if (options.layout && layout === undefined) {
75
+ throw new Error(`invalid layout: ${options.layout} (expected nested or flat)`);
76
+ }
77
+
78
+ const { resourceRecords } = await loadAppResourceRecords({
61
79
  appDataUri: options.appDataUri,
62
- apaasAppDataUri: options.apaasAppDataUri,
63
- apaasAppDataExpression: options.apaasAppDataExpression,
80
+ appDataExpression: options.appDataExpression,
64
81
  });
65
82
 
66
- let appData: AppData;
67
-
68
- if (sourceType === "apaas") {
69
- const { targetResourceRecords } = convertApaasResourceRecordsToResourceRecords({
70
- resourceRecords,
71
- });
72
- appData = { resourceRecords: targetResourceRecords };
73
- } else {
74
- appData = { resourceRecords };
75
- }
83
+ const appData: AppData = { resourceRecords };
76
84
 
77
85
  let extendAppDataParams: ExtendAppDataParams;
78
86
 
79
87
  if (usingWorkspaceLdsgModule) {
80
- extendAppDataParams = await getExtendAppDataParamsUsingWorkspaceLdsgModule({ appData });
88
+ extendAppDataParams = await getExtendAppDataParamsUsingWorkspaceLdsgModule({
89
+ appData,
90
+ ...(layout ? { layout } : {}),
91
+ ...(options.appPackageName ? { appPackageName: options.appPackageName } : {}),
92
+ outputPath,
93
+ });
81
94
  } else {
82
95
  extendAppDataParams = await getExtendAppDataParamsUsingNpmLdsgModule({ appData });
83
96
  }
@@ -87,7 +100,7 @@ program
87
100
  await prepare({
88
101
  ...extendAppDataRes,
89
102
  outputPath,
90
- ...(usingWorkspaceLdsgModule ? { layout: ZipAppDataLayout.FLAT } : {}),
103
+ ...(layout ? { layout } : usingWorkspaceLdsgModule ? { layout: ZipAppDataLayout.FLAT } : {}),
91
104
  });
92
105
  });
93
106
 
@@ -324,40 +337,6 @@ resource
324
337
  }
325
338
  });
326
339
 
327
- resource
328
- .command("convert")
329
- .description("Convert resource records between aPaaS and Runtime formats")
330
- .option("--from <format>", "source format (apaas or runtime)")
331
- .option("--to <format>", "target format (apaas or runtime)")
332
- .option("--input <path>", "input file path")
333
- .option("--output <path>", "output file path (omit for stdout)")
334
- .action(async (options) => {
335
- try {
336
- if (!options.from || !options.to || !options.input) {
337
- console.error(
338
- formatJson(formatError("MISSING_ARGS", "Required options: --from, --to, --input")),
339
- );
340
- process.exit(1);
341
- }
342
-
343
- const result = await convertResource({
344
- from: options.from,
345
- to: options.to,
346
- input: options.input,
347
- output: options.output,
348
- });
349
-
350
- if (result.outputPath) {
351
- console.log(formatJson(formatSuccess({ outputPath: result.outputPath })));
352
- } else {
353
- console.log(formatJson(formatSuccess({ data: result.data })));
354
- }
355
- } catch (error) {
356
- console.error(formatJson(formatError("CONVERT_ERROR", (error as Error).message)));
357
- process.exit(1);
358
- }
359
- });
360
-
361
340
  resource
362
341
  .command("diff <id>")
363
342
  .description("Diff a resource between two sources")
@@ -395,4 +374,150 @@ resource
395
374
  }
396
375
  });
397
376
 
377
+ resource
378
+ .command("pull")
379
+ .description("Pull resource records from RRM to a local file (read-only)")
380
+ .option("--endpoint <url>", "RRM GraphQL endpoint")
381
+ .option("--path <regex>", "scope remote records to a path regex (e.g. application id)")
382
+ .option("-o, --output <path>", `output file (default: ${DEFAULT_SOURCE_PATH})`)
383
+ .action(async (options) => {
384
+ try {
385
+ const result = await pullRemoteResourceRecords({
386
+ endpoint: options.endpoint,
387
+ path: options.path,
388
+ output: options.output,
389
+ });
390
+
391
+ console.log(formatJson(formatSuccess(result)));
392
+ } catch (error) {
393
+ console.error(formatJson(formatError("PULL_ERROR", (error as Error).message)));
394
+ process.exit(1);
395
+ }
396
+ });
397
+
398
+ async function parseExplicitRecords(
399
+ value?: string,
400
+ file?: string,
401
+ ): Promise<
402
+ | { id: string; kind: string; parentId: string | null; settings: Record<string, unknown> }[]
403
+ | undefined
404
+ > {
405
+ if (value && file) {
406
+ throw new Error("Cannot use both an inline value and a file for the same record list");
407
+ }
408
+
409
+ let data: unknown;
410
+
411
+ if (file) {
412
+ const fs = await import("fs-extra");
413
+ data = await fs.readJson(file);
414
+ } else if (value) {
415
+ data = JSON.parse(value);
416
+ } else {
417
+ return undefined;
418
+ }
419
+
420
+ const list = Array.isArray(data) ? data : [data];
421
+ return list.map((record) => purifyResourceRecord(record as never));
422
+ }
423
+
424
+ resource
425
+ .command("mutate")
426
+ .description("Mutate local resource records on RRM via mutateResourceRecords")
427
+ .option("--source <path>", `data source (default: ${DEFAULT_SOURCE_PATH})`)
428
+ .option("--endpoint <url>", "RRM GraphQL endpoint")
429
+ .option("--token <token>", "RRM Bearer token (or RRM_ACCESS_TOKEN env)")
430
+ .option(
431
+ "--path <regex>",
432
+ "scope remote records to a path regex (e.g. application id) before splitting",
433
+ )
434
+ .option(
435
+ "--no-delete-missing",
436
+ "do not soft-delete remote-only records (default: soft-delete like console)",
437
+ )
438
+ .option("--no-write-back", "do not write server-assigned ids back to the local source file")
439
+ .option("--yes", "confirm soft-deletion without prompting")
440
+ .option("--dry-run", "preview the plan without sending")
441
+ .option("--create <json>", "explicit create records as JSON (skip auto split)")
442
+ .option("--update <json>", "explicit update records as JSON (skip auto split)")
443
+ .option("--create-file <path>", "explicit create records from JSON file (skip auto split)")
444
+ .option("--update-file <path>", "explicit update records from JSON file (skip auto split)")
445
+ .action(async (options) => {
446
+ try {
447
+ const explicitCreate = await parseExplicitRecords(options.create, options.createFile);
448
+ const explicitUpdate = await parseExplicitRecords(options.update, options.updateFile);
449
+
450
+ if (explicitCreate !== undefined || explicitUpdate !== undefined) {
451
+ const result = await mutateRecordsDirect({
452
+ endpoint: options.endpoint,
453
+ token: options.token,
454
+ createResourceRecords: explicitCreate ?? [],
455
+ updateResourceRecords: explicitUpdate ?? [],
456
+ dryRun: options.dryRun,
457
+ });
458
+
459
+ console.log(
460
+ formatJson(
461
+ formatSuccess({
462
+ mode: "explicit",
463
+ dryRun: result.dryRun,
464
+ createCount: result.createCount,
465
+ updateCount: result.updateCount,
466
+ createdResourceRecords: result.createdResourceRecords,
467
+ updatedResourceRecords: result.updatedResourceRecords,
468
+ }),
469
+ ),
470
+ );
471
+ return;
472
+ }
473
+
474
+ if (isStdinSource(options.source)) {
475
+ console.error(
476
+ formatJson(
477
+ formatError(
478
+ "STDIN_NOT_SUPPORTED",
479
+ "stdin source is not supported for mutate; use a local JSON file",
480
+ ),
481
+ ),
482
+ );
483
+ process.exit(1);
484
+ }
485
+
486
+ const sourceData = await resolveSource(options.source);
487
+
488
+ const result = await mutateResources({
489
+ sourceData,
490
+ endpoint: options.endpoint,
491
+ token: options.token,
492
+ path: options.path,
493
+ deleteMissing: options.deleteMissing,
494
+ writeBack: options.writeBack,
495
+ yes: options.yes,
496
+ dryRun: options.dryRun,
497
+ });
498
+
499
+ const { plan } = result;
500
+
501
+ console.log(
502
+ formatJson(
503
+ formatSuccess({
504
+ mode: "auto",
505
+ dryRun: result.dryRun,
506
+ source: getSourceDescription(options.source),
507
+ remoteCount: plan.remoteCount,
508
+ localCount: plan.localCount,
509
+ createCount: plan.createResourceRecords.length,
510
+ updateCount: plan.updateResourceRecords.length,
511
+ deleteCount: plan.deleteResourceRecords.length,
512
+ idMap: result.idMap,
513
+ writeBackPath: result.writeBackPath,
514
+ }),
515
+ ),
516
+ );
517
+ } catch (error) {
518
+ console.error(formatJson(formatError("MUTATE_ERROR", (error as Error).message)));
519
+ process.exit(1);
520
+ }
521
+ });
522
+
398
523
  program.parse(process.argv);
@@ -1,4 +1,4 @@
1
- import { ResourceRecord } from "@ldsg/types";
1
+ import { ResourceRecord, ResourceSettings } from "@ldsg/types";
2
2
  import { SourceData } from "../../types/resource-record";
3
3
  import { appendRecord, generateId } from "../../utils/json-file-mutator";
4
4
 
@@ -26,7 +26,7 @@ export async function createResource(options: CreateOptions): Promise<{ record:
26
26
  id: partial.id || generateId(),
27
27
  kind: partial.kind,
28
28
  parentId: partial.parentId ?? sourceData.resourceRecords[0]?.parentId ?? null,
29
- settings: (partial.settings || {}) as Record<string, unknown>,
29
+ settings: (partial.settings || {}) as ResourceSettings,
30
30
  };
31
31
  } else {
32
32
  if (!options.kind) {
@@ -40,7 +40,7 @@ export async function createResource(options: CreateOptions): Promise<{ record:
40
40
  id: options.id || generateId(),
41
41
  kind: options.kind,
42
42
  parentId: options.parentId ?? null,
43
- settings: options.settings || {},
43
+ settings: (options.settings || {}) as ResourceSettings,
44
44
  };
45
45
  }
46
46