@carthooks/arcubase-cli 0.1.20 → 0.1.22

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 (60) hide show
  1. package/bundle/arcubase-admin.mjs +1669 -163
  2. package/bundle/arcubase.mjs +1669 -163
  3. package/dist/generated/command_registry.generated.d.ts +300 -0
  4. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  5. package/dist/generated/command_registry.generated.js +416 -0
  6. package/dist/generated/help_examples.generated.d.ts +191 -0
  7. package/dist/generated/help_examples.generated.d.ts.map +1 -1
  8. package/dist/generated/help_examples.generated.js +277 -0
  9. package/dist/generated/type_index.generated.d.ts +44 -1
  10. package/dist/generated/type_index.generated.d.ts.map +1 -1
  11. package/dist/generated/type_index.generated.js +46 -1
  12. package/dist/generated/zod_registry.generated.d.ts +32 -1
  13. package/dist/generated/zod_registry.generated.d.ts.map +1 -1
  14. package/dist/generated/zod_registry.generated.js +41 -0
  15. package/dist/runtime/dev_sdk_gen.d.ts +9 -0
  16. package/dist/runtime/dev_sdk_gen.d.ts.map +1 -0
  17. package/dist/runtime/dev_sdk_gen.js +319 -0
  18. package/dist/runtime/execute.d.ts.map +1 -1
  19. package/dist/runtime/execute.js +384 -32
  20. package/dist/runtime/zod_registry.d.ts.map +1 -1
  21. package/dist/runtime/zod_registry.js +66 -0
  22. package/package.json +1 -1
  23. package/sdk-dist/api/admin/index.ts +1 -0
  24. package/sdk-dist/api/admin/ingress.ts +11 -0
  25. package/sdk-dist/api/admin/public-link.ts +48 -0
  26. package/sdk-dist/api/shared-link/form.ts +42 -1
  27. package/sdk-dist/api/user/index.ts +0 -1
  28. package/sdk-dist/docs/runtime-reference/access-rule.md +19 -0
  29. package/sdk-dist/docs/runtime-reference/dashboard.md +15 -0
  30. package/sdk-dist/docs/runtime-reference/dev-sdk-gen.md +41 -0
  31. package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/bulk-apply.json +105 -0
  32. package/sdk-dist/docs/runtime-reference/help-examples/admin/dashboard/update-layout.json +16 -0
  33. package/sdk-dist/docs/runtime-reference/help-examples/admin/widget/create.json +33 -0
  34. package/sdk-dist/docs/runtime-reference/help-examples/admin/widget/preview-data.json +42 -0
  35. package/sdk-dist/docs/runtime-reference/widgets.md +40 -0
  36. package/sdk-dist/generated/command_registry.generated.ts +416 -0
  37. package/sdk-dist/generated/help_examples.generated.ts +277 -0
  38. package/sdk-dist/generated/type_index.generated.ts +46 -1
  39. package/sdk-dist/generated/zod_registry.generated.ts +62 -0
  40. package/sdk-dist/types/app.ts +37 -1
  41. package/sdk-dist/types/common.ts +77 -35
  42. package/sdk-dist/types/index.ts +1 -1
  43. package/sdk-dist/types/ingress.ts +31 -0
  44. package/sdk-dist/types/public-link.ts +10 -0
  45. package/sdk-dist/types/shared-link.ts +16 -1
  46. package/sdk-dist/types/user-action.ts +1 -43
  47. package/src/generated/command_registry.generated.ts +416 -0
  48. package/src/generated/help_examples.generated.ts +277 -0
  49. package/src/generated/type_index.generated.ts +46 -1
  50. package/src/generated/zod_registry.generated.ts +62 -0
  51. package/src/runtime/dev_sdk_gen.ts +360 -0
  52. package/src/runtime/execute.ts +484 -52
  53. package/src/runtime/zod_registry.ts +66 -0
  54. package/src/tests/command_registry.test.ts +21 -2
  55. package/src/tests/dev_sdk_gen.test.ts +226 -0
  56. package/src/tests/docs_readability.test.ts +15 -0
  57. package/src/tests/execute_validation.test.ts +215 -0
  58. package/src/tests/help.test.ts +83 -0
  59. package/sdk-dist/api/user/copilot.ts +0 -20
  60. package/sdk-dist/types/copilot.ts +0 -34
@@ -11,6 +11,7 @@ import { resolveArcubaseSDKPath } from './paths.js'
11
11
  import { compileCSVImportMapping, compileExcelImportMapping, type CompiledEntityImportConfig } from './entity_import_mapping.js'
12
12
  import { uploadLocalFileWithToken } from './local_upload.js'
13
13
  import { renderCommandHelpExamples } from './help_examples.js'
14
+ import { generateArcubaseProjectSDK } from './dev_sdk_gen.js'
14
15
 
15
16
  export type CommandExecutionSummary = {
16
17
  scope: CommandScope
@@ -24,7 +25,8 @@ export type CommandExecutionSummary = {
24
25
 
25
26
  export function renderRootHelp(scope: CommandScope): string {
26
27
  const binary = scope === 'admin' ? 'arcubase-admin' : 'arcubase'
27
- const items = listModules(scope).map((item) => ` - ${item}`)
28
+ const modules = scope === 'admin' ? [...listModules(scope), 'dev'].sort() : listModules(scope)
29
+ const items = modules.map((item) => ` - ${item}`)
28
30
  return [
29
31
  `${binary} <command> [subcommand] [flags]`,
30
32
  '',
@@ -38,6 +40,17 @@ export function renderRootHelp(scope: CommandScope): string {
38
40
  ].join('\n')
39
41
  }
40
42
 
43
+ function renderDevModuleHelp(): string {
44
+ return [
45
+ 'arcubase-admin dev <command> [flags]',
46
+ '',
47
+ 'developer commands for local project tooling',
48
+ '',
49
+ 'commands:',
50
+ ' - sdk-gen',
51
+ ].join('\n')
52
+ }
53
+
41
54
  export function renderModuleHelp(scope: CommandScope, moduleName: string, env: EnvInput = process.env): string {
42
55
  const items = listModuleCommands(scope, moduleName)
43
56
  if (items.length === 0) {
@@ -251,6 +264,44 @@ function getFixedValue(value: object): string | undefined {
251
264
  return typeof fixedValue === 'string' ? fixedValue : undefined
252
265
  }
253
266
 
267
+ function isDevSDKGenCommand(scope: CommandScope, moduleName: string | undefined, commandName: string | undefined): boolean {
268
+ return scope === 'admin' && moduleName === 'dev' && commandName === 'sdk-gen'
269
+ }
270
+
271
+ function validateDevSDKGenFlags(flags: Record<string, string | boolean>) {
272
+ const allowed = new Set(['help', 'app-id', 'out'])
273
+ for (const flag of Object.keys(flags)) {
274
+ if (!allowed.has(flag)) {
275
+ throw new CLIError('UNKNOWN_FLAG', `unknown flag --${flag} for dev sdk-gen`, 2, {
276
+ operation: 'dev sdk-gen',
277
+ hint: 'run dev sdk-gen --help for the supported flags',
278
+ })
279
+ }
280
+ }
281
+ }
282
+
283
+ function renderDevSDKGenHelp(): string {
284
+ return [
285
+ 'arcubase-admin dev sdk-gen',
286
+ 'method: local generator',
287
+ 'endpoint: /apps/:app_id',
288
+ 'body: none',
289
+ 'path flags: --app-id',
290
+ 'flags: --out',
291
+ '',
292
+ 'generates typed TypeScript source for the current Web project',
293
+ '',
294
+ 'example:',
295
+ ' - arcubase-admin dev sdk-gen --app-id <app_id> --out src/arcubase',
296
+ '',
297
+ 'requirements:',
298
+ ' - missing field.key values are initialized through admin auth as field<id>',
299
+ ' - existing field.key values must be stable unique TypeScript identifiers',
300
+ ' - entity property names use entity.key when available, otherwise a stable entity<id> fallback',
301
+ ' - generated code accepts createArcubaseSdk({ baseURL, accessToken, refreshToken })',
302
+ ].join('\n')
303
+ }
304
+
254
305
  function resolveEndpoint(command: NonNullable<ReturnType<typeof findCommand>>, flags: Record<string, string | boolean>): string {
255
306
  let resolved: string = command.endpoint
256
307
  for (const mapping of command.pathParams) {
@@ -637,6 +688,10 @@ function isAssignUsersCommand(scope: CommandScope, command: NonNullable<ReturnTy
637
688
  return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
638
689
  }
639
690
 
691
+ function isAccessRuleBulkApplyCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
692
+ return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'bulk-apply'
693
+ }
694
+
640
695
  function isTableCreateCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
641
696
  return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create'
642
697
  }
@@ -653,6 +708,14 @@ function isAccessRuleCommand(scope: CommandScope, command: NonNullable<ReturnTyp
653
708
  return scope === 'admin' && command.commandPath[0] === 'access-rule'
654
709
  }
655
710
 
711
+ function isWidgetPreviewDataCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
712
+ return scope === 'admin' && command.commandPath[0] === 'widget' && command.commandPath[1] === 'preview-data'
713
+ }
714
+
715
+ function isWidgetUpdateCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
716
+ return scope === 'admin' && command.commandPath[0] === 'widget' && command.commandPath[1] === 'update'
717
+ }
718
+
656
719
  function isAccessRuleUpdateCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
657
720
  return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update'
658
721
  }
@@ -962,6 +1025,45 @@ function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'retryToo
962
1025
  ],
963
1026
  }
964
1027
  }
1028
+ if (requestType === 'AppPreviewWidgetsDataReqVO') {
1029
+ return {
1030
+ shapeHint: {
1031
+ dataSource: 2188891001,
1032
+ dataSourceType: 'entity',
1033
+ dimensions: [{ IsField: true, FieldID: 1004, Name: 'Source' }],
1034
+ measures: [{ FieldID: 1005, Aggregate: 'sum', Name: 'Total Amount' }],
1035
+ },
1036
+ commonMistakes: [
1037
+ 'widget preview-data body is the chart query itself',
1038
+ 'do not send a saved widget object with id, entity_id, or options',
1039
+ 'field dimensions with FieldID must set IsField:true',
1040
+ 'unsupported aggregates are invalid; use sum, avg, max, min, count, or distinct_count',
1041
+ ],
1042
+ suggestions: [
1043
+ 'retry with: arcubase-admin widget preview-data --app-id <app_id> --body-json \'{"dataSource":<table_id>,"dataSourceType":"entity","dimensions":[{"IsField":true,"FieldID":<field_id>,"Name":"Source"}],"measures":[{"FieldID":<number_field_id>,"Aggregate":"sum","Name":"Total Amount"}]}\'',
1044
+ ],
1045
+ }
1046
+ }
1047
+ if (requestType === 'AppUpdateWidgetsReqVO') {
1048
+ return {
1049
+ shapeHint: {
1050
+ entity_id: 2188891001,
1051
+ options: {
1052
+ charts: {
1053
+ entity_id: 2188891001,
1054
+ dimensions: [{ IsField: true, FieldID: 1004, Name: 'Source' }],
1055
+ measures: [{ FieldID: 1005, Aggregate: 'sum', Name: 'Total Amount' }],
1056
+ type: 'bar',
1057
+ },
1058
+ },
1059
+ },
1060
+ commonMistakes: [
1061
+ 'dashboard chart config belongs in body.options.charts',
1062
+ 'field dimensions with FieldID must set IsField:true',
1063
+ 'verify persistence with widget get or widget list after update',
1064
+ ],
1065
+ }
1066
+ }
965
1067
  return {}
966
1068
  }
967
1069
 
@@ -1085,33 +1187,124 @@ function normalizeNumericAccessRuleUserScopeIDs(
1085
1187
  command: NonNullable<ReturnType<typeof findCommand>>,
1086
1188
  body: unknown,
1087
1189
  ): unknown {
1088
- if (!isAccessRuleCommand(scope, command) || !isRecord(body) || !isRecord(body.options) || !Array.isArray(body.options.user_scope)) {
1190
+ if (!isAccessRuleCommand(scope, command) || !isRecord(body)) {
1089
1191
  return body
1090
1192
  }
1091
1193
 
1092
- let changed = false
1093
- const userScope = body.options.user_scope.map((item) => {
1094
- if (!isRecord(item) || typeof item.id !== 'string' || !/^\d+$/.test(item.id)) {
1095
- return item
1096
- }
1097
- const id = Number(item.id)
1098
- if (!Number.isFinite(id) || !Number.isInteger(id)) {
1099
- return item
1100
- }
1101
- changed = true
1102
- return { ...item, id }
1103
- })
1194
+ const normalizeUserScope = (userScope: unknown): { value: unknown; changed: boolean } => {
1195
+ if (!Array.isArray(userScope)) return { value: userScope, changed: false }
1196
+ let changed = false
1197
+ const value = userScope.map((item) => {
1198
+ if (!isRecord(item) || typeof item.id !== 'string' || !/^\d+$/.test(item.id)) {
1199
+ return item
1200
+ }
1201
+ const id = Number(item.id)
1202
+ if (!Number.isFinite(id) || !Number.isInteger(id)) {
1203
+ return item
1204
+ }
1205
+ changed = true
1206
+ return { ...item, id }
1207
+ })
1208
+ return { value, changed }
1209
+ }
1210
+
1211
+ const normalizeNumericID = (value: unknown): { value: unknown; changed: boolean } => {
1212
+ if (typeof value !== 'string' || !/^\d+$/.test(value)) return { value, changed: false }
1213
+ const id = Number(value)
1214
+ if (!Number.isFinite(id) || !Number.isInteger(id)) return { value, changed: false }
1215
+ return { value: id, changed: true }
1216
+ }
1217
+
1218
+ if (isAccessRuleBulkApplyCommand(scope, command) && Array.isArray(body.items)) {
1219
+ let changed = false
1220
+ const items = body.items.map((item) => {
1221
+ if (!isRecord(item)) return item
1222
+ let nextItem: Record<string, unknown> = item
1223
+ for (const key of ['table_id', 'rule_id']) {
1224
+ const normalized = normalizeNumericID(nextItem[key])
1225
+ if (normalized.changed) {
1226
+ nextItem = { ...nextItem, [key]: normalized.value }
1227
+ changed = true
1228
+ }
1229
+ }
1230
+ if (isRecord(nextItem.options)) {
1231
+ const normalizedUserScope = normalizeUserScope(nextItem.options.user_scope)
1232
+ if (normalizedUserScope.changed) {
1233
+ nextItem = {
1234
+ ...nextItem,
1235
+ options: {
1236
+ ...nextItem.options,
1237
+ user_scope: normalizedUserScope.value,
1238
+ },
1239
+ }
1240
+ changed = true
1241
+ }
1242
+ }
1243
+ return nextItem
1244
+ })
1245
+ return changed ? { ...body, items } : body
1246
+ }
1104
1247
 
1105
- if (!changed) return body
1248
+ if (!isRecord(body.options)) {
1249
+ return body
1250
+ }
1251
+ const normalizedUserScope = normalizeUserScope(body.options.user_scope)
1252
+ if (!normalizedUserScope.changed) return body
1106
1253
  return {
1107
1254
  ...body,
1108
1255
  options: {
1109
1256
  ...body.options,
1110
- user_scope: userScope,
1257
+ user_scope: normalizedUserScope.value,
1111
1258
  },
1112
1259
  }
1113
1260
  }
1114
1261
 
1262
+ function validateAccessRulePolicyShape(
1263
+ scope: CommandScope,
1264
+ command: NonNullable<ReturnType<typeof findCommand>>,
1265
+ requestType: string,
1266
+ policy: Record<string, unknown> | undefined,
1267
+ env: EnvInput,
1268
+ pathPrefix: string,
1269
+ ) {
1270
+ const actions = policy && Array.isArray(policy.actions) ? policy.actions : []
1271
+ if (actions.includes('read') || actions.includes('write')) {
1272
+ throwBodyValidationFailure(
1273
+ 'access-rule policy.actions must use Arcubase action codes: use "view" for read and "add","edit" for write; do not use "read", "write", or "insert"',
1274
+ requestType,
1275
+ `${pathPrefix}.actions`,
1276
+ scope,
1277
+ command,
1278
+ env,
1279
+ )
1280
+ }
1281
+ const fields = policy && Array.isArray(policy.fields) ? policy.fields : []
1282
+ const allowedSystemFieldKeys = new Set(['created', 'updated', 'creator'])
1283
+ for (const [index, field] of fields.entries()) {
1284
+ if (!isRecord(field)) continue
1285
+ if (typeof field.key === 'number' || (typeof field.key === 'string' && /^\d+$/.test(field.key))) {
1286
+ throwBodyValidationFailure(
1287
+ 'access-rule policy.fields[].key must be a string prefixed with colon, for example ":1002"; do not use raw numbers',
1288
+ requestType,
1289
+ `${pathPrefix}.fields.${index}.key`,
1290
+ scope,
1291
+ command,
1292
+ env,
1293
+ )
1294
+ }
1295
+ if (typeof field.key === 'string' && !field.key.startsWith(':') && !allowedSystemFieldKeys.has(field.key)) {
1296
+ throwBodyValidationFailure(
1297
+ 'access-rule policy.fields[].key must use Arcubase permission keys such as ":1002"; do not use FieldVO.key values such as "vote_name"',
1298
+ requestType,
1299
+ `${pathPrefix}.fields.${index}.key`,
1300
+ scope,
1301
+ command,
1302
+ env,
1303
+ )
1304
+ }
1305
+ }
1306
+ }
1307
+
1115
1308
  function requireUpdateContains(
1116
1309
  scope: CommandScope,
1117
1310
  command: NonNullable<ReturnType<typeof findCommand>>,
@@ -1133,6 +1326,32 @@ function requireUpdateContains(
1133
1326
  }
1134
1327
  }
1135
1328
 
1329
+ function validateChartFieldDimensions(
1330
+ scope: CommandScope,
1331
+ command: NonNullable<ReturnType<typeof findCommand>>,
1332
+ requestType: string,
1333
+ dimensions: unknown,
1334
+ pathPrefix: string,
1335
+ env: EnvInput,
1336
+ ) {
1337
+ if (!Array.isArray(dimensions)) return
1338
+ for (const [index, dimension] of dimensions.entries()) {
1339
+ if (!isRecord(dimension) || typeof dimension.FieldID !== 'number') {
1340
+ continue
1341
+ }
1342
+ if (dimension.IsField !== true) {
1343
+ throwBodyValidationFailure(
1344
+ 'widget chart field dimensions require IsField:true when FieldID is present; otherwise grouped statistics collapse into total rows',
1345
+ requestType,
1346
+ `${pathPrefix}.${index}.IsField`,
1347
+ scope,
1348
+ command,
1349
+ env,
1350
+ )
1351
+ }
1352
+ }
1353
+ }
1354
+
1136
1355
  function validateActionSpecificRawBody(
1137
1356
  scope: CommandScope,
1138
1357
  command: NonNullable<ReturnType<typeof findCommand>>,
@@ -1172,6 +1391,54 @@ function validateActionSpecificRawBody(
1172
1391
  }
1173
1392
 
1174
1393
  if (isAccessRuleCommand(scope, command)) {
1394
+ if (isAccessRuleBulkApplyCommand(scope, command)) {
1395
+ if (!Array.isArray(body.items) || body.items.length === 0) {
1396
+ throwBodyValidationFailure(
1397
+ 'access-rule bulk-apply requires non-empty body.items; put every table/rule assignment in body.items',
1398
+ command.requestType,
1399
+ 'body.items',
1400
+ scope,
1401
+ command,
1402
+ env,
1403
+ )
1404
+ }
1405
+ if (body.mode !== undefined && body.mode !== 'update_existing' && body.mode !== 'upsert_by_name') {
1406
+ throwBodyValidationFailure(
1407
+ 'access-rule bulk-apply body.mode must be "update_existing" or "upsert_by_name"',
1408
+ command.requestType,
1409
+ 'body.mode',
1410
+ scope,
1411
+ command,
1412
+ env,
1413
+ )
1414
+ }
1415
+ for (const [index, item] of body.items.entries()) {
1416
+ if (!isRecord(item)) {
1417
+ throwBodyValidationFailure(
1418
+ 'access-rule bulk-apply body.items[] must be an object',
1419
+ command.requestType,
1420
+ `body.items.${index}`,
1421
+ scope,
1422
+ command,
1423
+ env,
1424
+ )
1425
+ }
1426
+ const options = isRecord(item.options) ? item.options : undefined
1427
+ if (!options) {
1428
+ throwBodyValidationFailure(
1429
+ 'access-rule bulk-apply body.items[].options is required and must include the full canonical options object',
1430
+ command.requestType,
1431
+ `body.items.${index}.options`,
1432
+ scope,
1433
+ command,
1434
+ env,
1435
+ )
1436
+ }
1437
+ validateAccessRuleUserScope(scope, command, command.requestType, options.user_scope, env, `body.items.${index}.options.user_scope`)
1438
+ validateAccessRulePolicyShape(scope, command, command.requestType, isRecord(options.policy) ? options.policy : undefined, env, `body.items.${index}.options.policy`)
1439
+ }
1440
+ return
1441
+ }
1175
1442
  const update = stringArray(body.update)
1176
1443
  if (isAccessRuleUpdateCommand(scope, command) && update.some((item) => item.startsWith('options.'))) {
1177
1444
  throwBodyValidationFailure(
@@ -1216,42 +1483,17 @@ function validateActionSpecificRawBody(
1216
1483
  )
1217
1484
  }
1218
1485
  validateAccessRuleUserScope(scope, command, command.requestType, options?.user_scope, env)
1219
- const actions = policy && Array.isArray(policy.actions) ? policy.actions : []
1220
- if (actions.includes('read') || actions.includes('write')) {
1221
- throwBodyValidationFailure(
1222
- 'access-rule policy.actions must use Arcubase action codes: use "view" for read and "add","edit" for write; do not use "read", "write", or "insert"',
1223
- command.requestType,
1224
- 'body.options.policy.actions',
1225
- scope,
1226
- command,
1227
- env,
1228
- )
1229
- }
1230
- const fields = policy && Array.isArray(policy.fields) ? policy.fields : []
1231
- const allowedSystemFieldKeys = new Set(['created', 'updated', 'creator'])
1232
- for (const [index, field] of fields.entries()) {
1233
- if (!isRecord(field)) continue
1234
- if (typeof field.key === 'number' || (typeof field.key === 'string' && /^\d+$/.test(field.key))) {
1235
- throwBodyValidationFailure(
1236
- 'access-rule policy.fields[].key must be a string prefixed with colon, for example ":1002"; do not use raw numbers',
1237
- command.requestType,
1238
- `body.options.policy.fields.${index}.key`,
1239
- scope,
1240
- command,
1241
- env,
1242
- )
1243
- }
1244
- if (typeof field.key === 'string' && !field.key.startsWith(':') && !allowedSystemFieldKeys.has(field.key)) {
1245
- throwBodyValidationFailure(
1246
- 'access-rule policy.fields[].key must use Arcubase permission keys such as ":1002"; do not use FieldVO.key values such as "vote_name"',
1247
- command.requestType,
1248
- `body.options.policy.fields.${index}.key`,
1249
- scope,
1250
- command,
1251
- env,
1252
- )
1253
- }
1254
- }
1486
+ validateAccessRulePolicyShape(scope, command, command.requestType, policy, env, 'body.options.policy')
1487
+ }
1488
+
1489
+ if (isWidgetPreviewDataCommand(scope, command)) {
1490
+ validateChartFieldDimensions(scope, command, command.requestType, body.dimensions, 'body.dimensions', env)
1491
+ }
1492
+
1493
+ if (isWidgetUpdateCommand(scope, command)) {
1494
+ const options = isRecord(body.options) ? body.options : undefined
1495
+ const charts = options && isRecord(options.charts) ? options.charts : undefined
1496
+ validateChartFieldDimensions(scope, command, command.requestType, charts?.dimensions, 'body.options.charts.dimensions', env)
1255
1497
  }
1256
1498
  }
1257
1499
 
@@ -1710,6 +1952,186 @@ async function requestJSON(
1710
1952
  return { status: response.status, data: parsedResponse }
1711
1953
  }
1712
1954
 
1955
+ function unwrapResponseData(payload: unknown): unknown {
1956
+ return isRecord(payload) && 'data' in payload ? payload.data : payload
1957
+ }
1958
+
1959
+ type SDKGenEntityRef = {
1960
+ id: string
1961
+ }
1962
+
1963
+ type SDKGenInitializedFieldKeys = {
1964
+ entityId: number
1965
+ fields: string[]
1966
+ }
1967
+
1968
+ function extractSDKGenAppPayload(payload: unknown): Record<string, any> {
1969
+ const appPayload = unwrapResponseData(payload)
1970
+ if (!isRecord(appPayload)) {
1971
+ throw new CLIError('SDK_GEN_INVALID_SCHEMA', 'sdk-gen expected app detail response data', 2)
1972
+ }
1973
+ return appPayload
1974
+ }
1975
+
1976
+ function extractSDKGenEntityRefs(appPayload: Record<string, any>): SDKGenEntityRef[] {
1977
+ const sourceEntities = Array.isArray(appPayload.entities)
1978
+ ? appPayload.entities
1979
+ : Array.isArray(appPayload.tables)
1980
+ ? appPayload.tables
1981
+ : Array.isArray(appPayload.entitys)
1982
+ ? appPayload.entitys
1983
+ : []
1984
+ const refs = sourceEntities
1985
+ .filter((item): item is Record<string, unknown> => isRecord(item))
1986
+ .map((item) => item.id)
1987
+ .filter((id): id is string | number => typeof id === 'string' || typeof id === 'number')
1988
+ .map((id) => ({ id: String(id) }))
1989
+ return refs
1990
+ }
1991
+
1992
+ function resolveSDKGenAppId(flagAppId: string, appPayload: Record<string, any>): number {
1993
+ const fromPayload = typeof appPayload.id === 'number' ? appPayload.id : typeof appPayload.app_id === 'number' ? appPayload.app_id : undefined
1994
+ const fromFlag = Number(flagAppId)
1995
+ if (Number.isFinite(fromFlag)) return fromFlag
1996
+ if (fromPayload) return fromPayload
1997
+ throw new CLIError('SDK_GEN_APP_ID_REQUIRED', 'sdk-gen expected numeric app id', 2)
1998
+ }
1999
+
2000
+ function walkSDKGenFields(fields: unknown, visit: (field: Record<string, any>) => void) {
2001
+ if (!Array.isArray(fields)) {
2002
+ return
2003
+ }
2004
+ for (const field of fields) {
2005
+ if (!isRecord(field)) {
2006
+ continue
2007
+ }
2008
+ visit(field)
2009
+ walkSDKGenFields(field.children, visit)
2010
+ }
2011
+ }
2012
+
2013
+ function collectExistingSDKGenFieldKeys(entity: Record<string, any>): Set<string> {
2014
+ const keys = new Set<string>()
2015
+ walkSDKGenFields(entity.fields, (field) => {
2016
+ if (typeof field.key === 'string' && field.key.trim() !== '') {
2017
+ keys.add(field.key.trim())
2018
+ }
2019
+ })
2020
+ return keys
2021
+ }
2022
+
2023
+ function nextSDKGenFallbackFieldKey(fieldId: number, usedKeys: Set<string>): string {
2024
+ const base = `field${fieldId}`
2025
+ if (!usedKeys.has(base)) {
2026
+ usedKeys.add(base)
2027
+ return base
2028
+ }
2029
+ let index = 2
2030
+ while (usedKeys.has(`${base}_${index}`)) {
2031
+ index++
2032
+ }
2033
+ const key = `${base}_${index}`
2034
+ usedKeys.add(key)
2035
+ return key
2036
+ }
2037
+
2038
+ function initializeMissingSDKGenFieldKeys(entity: Record<string, any>): Array<{ id: number; key: string }> {
2039
+ const usedKeys = collectExistingSDKGenFieldKeys(entity)
2040
+ const updates: Array<{ id: number; key: string }> = []
2041
+ walkSDKGenFields(entity.fields, (field) => {
2042
+ if (typeof field.id !== 'number') {
2043
+ return
2044
+ }
2045
+ if (typeof field.key === 'string' && field.key.trim() !== '') {
2046
+ field.key = field.key.trim()
2047
+ return
2048
+ }
2049
+ const key = nextSDKGenFallbackFieldKey(field.id, usedKeys)
2050
+ field.key = key
2051
+ updates.push({ id: field.id, key })
2052
+ })
2053
+ return updates
2054
+ }
2055
+
2056
+ async function executeDevSDKGen(
2057
+ runtimeEnv: RuntimeEnv,
2058
+ flags: Record<string, string | boolean>,
2059
+ fetchImpl: typeof fetch,
2060
+ ): Promise<any> {
2061
+ validateDevSDKGenFlags(flags)
2062
+ const appId = flagValue(flags, 'app-id')
2063
+ const outDir = flagValue(flags, 'out')
2064
+ if (!appId) {
2065
+ throw new CLIError('MISSING_PATH_FLAG', 'missing required flag --app-id', 2)
2066
+ }
2067
+ if (!outDir) {
2068
+ throw new CLIError('MISSING_FLAG', 'missing required flag --out', 2, {
2069
+ operation: 'dev sdk-gen',
2070
+ hint: 'retry with --out src/arcubase',
2071
+ })
2072
+ }
2073
+
2074
+ const endpoint = `/api/apps/${encodeURIComponent(appId)}`
2075
+ const appDetail = await requestJSON(runtimeEnv, 'GET', endpoint, undefined, fetchImpl)
2076
+ const appPayload = extractSDKGenAppPayload(appDetail.data)
2077
+ let entityRefs = extractSDKGenEntityRefs(appPayload)
2078
+ if (entityRefs.length === 0) {
2079
+ const entitiesEndpoint = `/api/apps/${encodeURIComponent(appId)}/entities`
2080
+ const entityList = await requestJSON(runtimeEnv, 'GET', entitiesEndpoint, undefined, fetchImpl)
2081
+ const entityListPayload = unwrapResponseData(entityList.data)
2082
+ entityRefs = extractSDKGenEntityRefs({ entities: entityListPayload })
2083
+ }
2084
+ if (entityRefs.length === 0) {
2085
+ throw new CLIError('SDK_GEN_NO_ENTITIES', 'sdk-gen could not find entities in app detail response', 2)
2086
+ }
2087
+ const entities = []
2088
+ const initializedFieldKeys: SDKGenInitializedFieldKeys[] = []
2089
+ for (const entityRef of entityRefs) {
2090
+ const entityEndpoint = `/api/apps/${encodeURIComponent(appId)}/entity/${encodeURIComponent(entityRef.id)}`
2091
+ const entityDetail = await requestJSON(runtimeEnv, 'GET', entityEndpoint, undefined, fetchImpl)
2092
+ const entityPayload = unwrapResponseData(entityDetail.data)
2093
+ if (!isRecord(entityPayload)) {
2094
+ throw new CLIError('SDK_GEN_INVALID_SCHEMA', 'sdk-gen expected entity detail response data', 2)
2095
+ }
2096
+ const customKeyUpdates = initializeMissingSDKGenFieldKeys(entityPayload)
2097
+ if (customKeyUpdates.length > 0) {
2098
+ const customKeysEndpoint = `/api/apps/${encodeURIComponent(appId)}/entity/${encodeURIComponent(entityRef.id)}/custom-keys`
2099
+ await requestJSON(runtimeEnv, 'PUT', customKeysEndpoint, customKeyUpdates, fetchImpl)
2100
+ initializedFieldKeys.push({
2101
+ entityId: typeof entityPayload.id === 'number' ? entityPayload.id : Number(entityRef.id),
2102
+ fields: customKeyUpdates.map((item) => item.key),
2103
+ })
2104
+ }
2105
+ entities.push(entityPayload)
2106
+ }
2107
+ const generated = generateArcubaseProjectSDK({
2108
+ id: resolveSDKGenAppId(appId, appPayload),
2109
+ name: typeof appPayload.name === 'string' ? appPayload.name : undefined,
2110
+ entities,
2111
+ })
2112
+ const absoluteOutDir = path.resolve(process.cwd(), outDir)
2113
+
2114
+ for (const file of generated.files) {
2115
+ const targetPath = path.resolve(absoluteOutDir, file.path)
2116
+ if (!targetPath.startsWith(`${absoluteOutDir}${path.sep}`)) {
2117
+ throw new CLIError('SDK_GEN_INVALID_OUTPUT_PATH', `invalid generated file path: ${file.path}`, 2)
2118
+ }
2119
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true })
2120
+ fs.writeFileSync(targetPath, file.contents)
2121
+ }
2122
+
2123
+ return {
2124
+ kind: 'result',
2125
+ commandPath: ['dev', 'sdk-gen'],
2126
+ status: appDetail.status,
2127
+ data: {
2128
+ out: absoluteOutDir,
2129
+ files: generated.files.map((file) => file.path),
2130
+ initializedFieldKeys,
2131
+ },
2132
+ }
2133
+ }
2134
+
1713
2135
  function requiredStringFlag(flags: Record<string, string | boolean>, flag: string): string {
1714
2136
  const value = flagValue(flags, flag)
1715
2137
  if (!value) {
@@ -2078,6 +2500,16 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
2078
2500
  }
2079
2501
  return { kind: 'help', text: renderRootHelp(scope) }
2080
2502
  }
2503
+ if (isDevSDKGenCommand(scope, moduleName, commandName)) {
2504
+ if (hasFlag(parsed.flags, 'help')) {
2505
+ return { kind: 'help', text: renderDevSDKGenHelp() }
2506
+ }
2507
+ const runtimeEnv = env ?? loadRuntimeEnv(scope)
2508
+ return executeDevSDKGen(runtimeEnv, parsed.flags, fetchImpl)
2509
+ }
2510
+ if (scope === 'admin' && moduleName === 'dev' && !commandName) {
2511
+ return { kind: 'help', text: renderDevModuleHelp() }
2512
+ }
2081
2513
  const singleTokenCommand = !commandName ? findCommand(scope, moduleName) : null
2082
2514
  if (!commandName && !singleTokenCommand) {
2083
2515
  if (hasFlag(parsed.flags, 'help-examples')) {