@carthooks/arcubase-cli 0.1.9 → 0.1.12

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 (105) hide show
  1. package/bundle/arcubase-admin.mjs +1407 -345
  2. package/bundle/arcubase.mjs +1407 -345
  3. package/dist/generated/command_registry.generated.d.ts +36 -35
  4. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  5. package/dist/generated/command_registry.generated.js +37 -36
  6. package/dist/generated/type_index.generated.d.ts +23 -23
  7. package/dist/generated/type_index.generated.js +23 -23
  8. package/dist/runtime/entity_save_schema.d.ts.map +1 -1
  9. package/dist/runtime/entity_save_schema.js +30 -0
  10. package/dist/runtime/env.d.ts +2 -1
  11. package/dist/runtime/env.d.ts.map +1 -1
  12. package/dist/runtime/env.js +10 -1
  13. package/dist/runtime/errors.d.ts +4 -0
  14. package/dist/runtime/errors.d.ts.map +1 -1
  15. package/dist/runtime/execute.d.ts +4 -2
  16. package/dist/runtime/execute.d.ts.map +1 -1
  17. package/dist/runtime/execute.js +1025 -106
  18. package/dist/runtime/paths.d.ts +1 -1
  19. package/dist/runtime/paths.d.ts.map +1 -1
  20. package/dist/runtime/paths.js +9 -2
  21. package/dist/runtime/upload.d.ts +1 -11
  22. package/dist/runtime/upload.d.ts.map +1 -1
  23. package/dist/runtime/upload.js +19 -133
  24. package/dist/runtime/zod_registry.d.ts +5 -3
  25. package/dist/runtime/zod_registry.d.ts.map +1 -1
  26. package/dist/runtime/zod_registry.js +108 -48
  27. package/dist/tests/bootstrap.test.js +12 -0
  28. package/dist/tests/command_registry.test.js +8 -0
  29. package/dist/tests/docs_readability.test.d.ts +2 -0
  30. package/dist/tests/docs_readability.test.d.ts.map +1 -0
  31. package/dist/tests/docs_readability.test.js +87 -0
  32. package/dist/tests/entity_save_schema.test.js +22 -0
  33. package/dist/tests/execute_validation.test.js +446 -32
  34. package/dist/tests/help.test.js +56 -4
  35. package/dist/tests/upload.test.js +30 -77
  36. package/package.json +1 -1
  37. package/sdk-dist/docs/runtime-reference/README.md +43 -29
  38. package/sdk-dist/docs/runtime-reference/access-rule.md +109 -0
  39. package/sdk-dist/docs/runtime-reference/condition.md +182 -0
  40. package/sdk-dist/docs/runtime-reference/entity-schema/README.md +6 -5
  41. package/sdk-dist/docs/runtime-reference/entity-schema/checkbox.md +1 -1
  42. package/sdk-dist/docs/runtime-reference/entity-schema/file.md +2 -2
  43. package/sdk-dist/docs/runtime-reference/entity-schema/image.md +2 -2
  44. package/sdk-dist/docs/runtime-reference/entity-schema/linkto.md +3 -3
  45. package/sdk-dist/docs/runtime-reference/entity-schema/number.md +1 -1
  46. package/sdk-dist/docs/runtime-reference/entity-schema/query.md +3 -3
  47. package/sdk-dist/docs/runtime-reference/entity-schema/relation.md +3 -3
  48. package/sdk-dist/docs/runtime-reference/entity-schema/relationfield.md +3 -3
  49. package/sdk-dist/docs/runtime-reference/entity-schema/status.md +1 -1
  50. package/sdk-dist/docs/runtime-reference/entity-schema/text.md +1 -1
  51. package/sdk-dist/docs/runtime-reference/entity-schema.md +28 -21
  52. package/sdk-dist/docs/runtime-reference/examples/README.md +1 -1
  53. package/sdk-dist/docs/runtime-reference/examples/crm-01/README.md +30 -11
  54. package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.create-entity.json +1 -0
  55. package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.schema.json +1 -0
  56. package/sdk-dist/docs/runtime-reference/examples/crm-01/create-app.json +1 -0
  57. package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.create-entity.json +1 -0
  58. package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.schema.json +1 -0
  59. package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.create-entity.json +1 -0
  60. package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.schema.json +1 -0
  61. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.bulk-update.json +1 -0
  62. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.create-entity.json +1 -0
  63. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -0
  64. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.row.insert.json +1 -0
  65. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.schema.json +1 -0
  66. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.selection.condition.json +1 -0
  67. package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +26 -26
  68. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.row.insert.json +1 -1
  69. package/sdk-dist/docs/runtime-reference/examples/wms-01/README.md +26 -10
  70. package/sdk-dist/docs/runtime-reference/examples/wms-01/create-app.json +1 -0
  71. package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.create-entity.json +1 -0
  72. package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.schema.json +1 -0
  73. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.bulk-update.json +1 -0
  74. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.create-entity.json +1 -0
  75. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -0
  76. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.schema.json +1 -0
  77. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.selection.condition.json +1 -0
  78. package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.create-entity.json +1 -0
  79. package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.schema.json +1 -0
  80. package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.create-entity.json +1 -0
  81. package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.schema.json +1 -0
  82. package/sdk-dist/docs/runtime-reference/row-crud.md +35 -8
  83. package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +31 -95
  84. package/sdk-dist/docs/runtime-reference/selection.md +128 -0
  85. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +113 -30
  86. package/sdk-dist/docs/runtime-reference/uploads.md +30 -59
  87. package/sdk-dist/docs/runtime-reference/workflow/README.md +129 -13
  88. package/sdk-dist/generated/command_registry.generated.ts +37 -36
  89. package/sdk-dist/generated/type_index.generated.ts +23 -23
  90. package/src/generated/command_registry.generated.ts +37 -36
  91. package/src/generated/type_index.generated.ts +23 -23
  92. package/src/runtime/entity_save_schema.ts +30 -0
  93. package/src/runtime/env.ts +14 -2
  94. package/src/runtime/errors.ts +4 -0
  95. package/src/runtime/execute.ts +1249 -129
  96. package/src/runtime/paths.ts +11 -2
  97. package/src/runtime/upload.ts +19 -178
  98. package/src/runtime/zod_registry.ts +113 -48
  99. package/src/tests/bootstrap.test.ts +16 -0
  100. package/src/tests/command_registry.test.ts +9 -0
  101. package/src/tests/docs_readability.test.ts +96 -0
  102. package/src/tests/entity_save_schema.test.ts +22 -0
  103. package/src/tests/execute_validation.test.ts +564 -45
  104. package/src/tests/help.test.ts +51 -7
  105. package/src/tests/upload.test.ts +34 -91
@@ -5,10 +5,7 @@ import { buildRequestHeaders, buildURL, normalizeExternalEndpoint } from './http
5
5
  import { listModules, listModuleCommands, findCommand, findCommandSuggestions } from './command_registry.js';
6
6
  import { loadBodyFromFlags, loadQueryFromFlags } from './body_loader.js';
7
7
  import { getBodySchema, getCommandDocHints, getDocHints, getTypeHints, getUnsupportedBodySchemaReason } from './zod_registry.js';
8
- import { renderUploadHelp, uploadLocalFile } from './upload.js';
9
- function summarizeUploadResult(value) {
10
- return value;
11
- }
8
+ import { resolveArcubaseSDKPath } from './paths.js';
12
9
  export function renderRootHelp(scope) {
13
10
  const binary = scope === 'admin' ? 'arcubase-admin' : 'arcubase';
14
11
  const items = listModules(scope).map((item) => ` - ${item}`);
@@ -18,19 +15,20 @@ export function renderRootHelp(scope) {
18
15
  ...(scope === 'admin'
19
16
  ? ['use arcubase-admin for app, table, access-rule, and workflow management', '']
20
17
  : ['use arcubase for entry, table, row, upload, profile, and workflow actions', '']),
18
+ 'prefer --body-json <json-string> for normal payloads, including table schema; use --body-file only when JSON is too large for one tool call',
19
+ '',
21
20
  'modules:',
22
21
  ...items,
23
- ...(scope === 'user' ? ['', 'special commands:', ' - upload <local-file>'] : []),
24
22
  ].join('\n');
25
23
  }
26
- export function renderModuleHelp(scope, moduleName) {
24
+ export function renderModuleHelp(scope, moduleName, env = process.env) {
27
25
  const items = listModuleCommands(scope, moduleName);
28
26
  if (items.length === 0) {
29
27
  const suggestions = findCommandSuggestions(moduleName, '').map((item) => `${item.binary} ${item.moduleName}${item.commandName ? ` ${item.commandName}` : ''}`);
30
28
  throw new CLIError('UNKNOWN_MODULE', `unknown module: ${moduleName}`, 2, suggestions.length > 0 ? { suggestions } : undefined);
31
29
  }
32
30
  if (items.length === 1 && items[0].commandPath.length === 1) {
33
- return renderCommandHelp(scope, moduleName);
31
+ return renderCommandHelp(scope, moduleName, undefined, env);
34
32
  }
35
33
  return [
36
34
  `${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${moduleName} <command> [flags]`,
@@ -38,6 +36,8 @@ export function renderModuleHelp(scope, moduleName) {
38
36
  ...(scope === 'admin'
39
37
  ? ['use arcubase-admin for app, table, access-rule, and workflow management', '']
40
38
  : ['use arcubase for entry, table, row, upload, profile, and workflow actions', '']),
39
+ 'prefer --body-json <json-string> for normal payloads, including table schema; use --body-file only when JSON is too large for one tool call',
40
+ '',
41
41
  'commands:',
42
42
  ...items.filter((item) => item.commandPath.length > 1).map((item) => ` - ${item.commandPath[1]}`),
43
43
  ].join('\n');
@@ -46,35 +46,90 @@ function buildUnknownCommandDetails(scope, moduleName, commandName) {
46
46
  const suggestions = findCommandSuggestions(moduleName, commandName).map((item) => `${item.binary} ${item.moduleName}${item.commandName ? ` ${item.commandName}` : ''}`);
47
47
  return suggestions.length > 0 ? { suggestions } : undefined;
48
48
  }
49
- export function renderCommandHelp(scope, moduleName, commandName) {
49
+ function sdkPath(file, env = process.env) {
50
+ return resolveArcubaseSDKPath(file, env);
51
+ }
52
+ export function renderCommandHelp(scope, moduleName, commandName, env = process.env) {
50
53
  const command = findCommand(scope, moduleName, commandName);
51
54
  if (!command) {
52
55
  throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName}${commandName ? ` ${commandName}` : ''}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName ?? ''));
53
56
  }
54
57
  const docHints = [
55
- ...getCommandDocHints(`${scope}.${command.module}.${command.functionName}`),
56
- ...(command.requestType ? getDocHints(command.requestType) : []),
58
+ ...getCommandDocHints(`${scope}.${command.module}.${command.functionName}`, env),
59
+ ...(command.requestType ? getDocHints(command.requestType, env) : []),
57
60
  ].filter((item, index, list) => list.findIndex((candidate) => candidate.file === item.file) === index);
61
+ const typeHints = command.requestType ? getTypeHints(command.requestType, env) : [];
58
62
  const pathFlags = command.pathParams.filter((item) => !getFixedValue(item)).map((item) => `--${item.flag}`);
59
- const queryFlags = command.queryParams.filter((item) => !getFixedValue(item)).map((item) => `--${item.flag}`);
63
+ const queryFlags = visibleQueryParams(command).map((item) => `--${item.flag}`);
64
+ const isTableCreate = scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create';
65
+ const bodyTypeText = isTableCreate ? 'TableCreateWithSchema' : command.requestType;
66
+ const tableCreateExampleBody = JSON.stringify(buildTableCreateExampleBody());
60
67
  return [
61
68
  `${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${command.commandPath.join(' ')}`,
62
69
  `method: ${command.method}`,
63
70
  `endpoint: ${normalizeExternalEndpoint(command.endpoint)}`,
64
- command.requestType ? `body: ${command.requestType} via --body-file or --body-json` : 'body: none',
71
+ command.requestType ? `body: ${bodyTypeText} via --body-json <json-string> | --body-file <file>.json` : 'body: none',
65
72
  queryFlags.length ? `query flags: ${queryFlags.join(', ')}, --query-file` : 'query flags: --query-file',
66
73
  pathFlags.length ? `path flags: ${pathFlags.join(', ')}` : 'path flags: none',
74
+ ...(isTableCreate
75
+ ? [
76
+ 'body-json example:',
77
+ ` - ${tableCreateExampleBody}`,
78
+ 'body rules:',
79
+ ' - fields must be non-empty; empty table shell creation is not allowed',
80
+ ' - field_id_seq must be greater than every field.id',
81
+ ' - use text, not string; use member, not user',
82
+ ' - for select/radio/checkbox/status, use options.options.items',
83
+ 'schema refs:',
84
+ ` - Table schema cheatsheet: ${sdkPath('docs/runtime-reference/entity-schema.md', env)}`,
85
+ ` - Field type index: ${sdkPath('docs/runtime-reference/entity-schema/README.md', env)}`,
86
+ ]
87
+ : []),
67
88
  ...(scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'update-schema'
68
89
  ? [
69
90
  'next step:',
91
+ ' - prefer --body-json for schema save to avoid file creation and JSON file corruption',
70
92
  ' - switch to arcubase for row operations',
71
- ' - row create: arcubase row create --app-id <app_id> --table-id <table_id> --body-file insert.json',
72
- ' - row query: arcubase row query --app-id <app_id> --table-id <table_id> --body-file query.json',
93
+ ' - row create: arcubase row create --app-id <app_id> --table-id <table_id> --body-json <json-string> | --body-file insert.json',
94
+ ' - row query: arcubase row query --app-id <app_id> --table-id <table_id> --body-json <json-string> | --body-file query.json',
95
+ ]
96
+ : []),
97
+ ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update'
98
+ ? [
99
+ 'body-json examples:',
100
+ ' - enable: {"update":["enabled"],"enabled":true}',
101
+ ' - rename: {"update":["name"],"name":"Sales read only"}',
102
+ ' - update policy: {"update":["options"],"options":{"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":true,"all_fields_write":true,"fields":[{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}',
103
+ ]
104
+ : []),
105
+ ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'create'
106
+ ? [
107
+ 'body-json example:',
108
+ ' - {"name":"Sales read write","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889901}],"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":false,"all_fields_write":false,"fields":[{"key":":1001","read":true,"write":true,"report":false},{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}',
109
+ 'success requires:',
110
+ ' - result.enabled must be true',
111
+ ' - options.user_scope[].type is "user" for tenant users',
112
+ ' - hidden fields use colon field keys such as ":1002"',
113
+ ]
114
+ : []),
115
+ ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
116
+ ? [
117
+ 'body-json example:',
118
+ ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
119
+ 'success requires:',
120
+ ' - result.enabled must be true',
121
+ ' - result.options.user_scope must contain the assigned numeric TenantUser.ID values',
122
+ ' - PermitUserScope.id must be numeric TenantUser.ID',
123
+ ' - Do not copy hash arcubaseTenantUserId into PermitUserScope.id',
124
+ ' - If result.enabled is false, enable the rule first and retry assign-users',
73
125
  ]
74
126
  : []),
75
127
  ...(docHints.length > 0
76
128
  ? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
77
129
  : []),
130
+ ...(typeHints.length > 0
131
+ ? ['types:', ...typeHints.map((item) => ` - ${item.symbol}: ${item.file}`)]
132
+ : []),
78
133
  ].join('\n');
79
134
  }
80
135
  function coerceScalar(value, typeText) {
@@ -118,7 +173,7 @@ function buildScalarQuery(command, flags) {
118
173
  return undefined;
119
174
  }
120
175
  const out = {};
121
- for (const scalar of command.queryParams) {
176
+ for (const scalar of effectiveQueryParams(command)) {
122
177
  const value = getFixedValue(scalar) ?? flagValue(flags, scalar.flag);
123
178
  if (value === undefined)
124
179
  continue;
@@ -126,9 +181,125 @@ function buildScalarQuery(command, flags) {
126
181
  }
127
182
  return Object.keys(out).length > 0 ? out : undefined;
128
183
  }
184
+ function validateKnownFlags(command, flags, env = process.env) {
185
+ const allowed = new Set(['help', 'body-file', 'body-json', 'query-file']);
186
+ for (const mapping of command.pathParams) {
187
+ if (!getFixedValue(mapping)) {
188
+ allowed.add(mapping.flag);
189
+ }
190
+ }
191
+ for (const query of visibleQueryParams(command)) {
192
+ if (!getFixedValue(query)) {
193
+ allowed.add(query.flag);
194
+ }
195
+ }
196
+ for (const flag of Object.keys(flags)) {
197
+ if (!allowed.has(flag)) {
198
+ throw new CLIError('UNKNOWN_FLAG', `unknown flag --${flag} for ${command.commandPath.join(' ')}`, 2, {
199
+ ...buildUnknownFlagDetails(command, flag, env),
200
+ });
201
+ }
202
+ }
203
+ }
204
+ function buildUnknownFlagDetails(command, flag, env = process.env) {
205
+ const operation = command.commandPath.join(' ');
206
+ const details = {
207
+ operation,
208
+ hint: `run ${operation} --help for the supported flags`,
209
+ };
210
+ if (command.requestType) {
211
+ details.requestType = command.requestType;
212
+ details.tsHints = getTypeHints(command.requestType, env);
213
+ details.docHints = getDocHints(command.requestType, env);
214
+ details.commonMistakes = [
215
+ `do not pass request body fields as --${flag}`,
216
+ 'put request body fields inside --body-json <json-string>',
217
+ ];
218
+ details.suggestions = [`retry with: ${command.commandPath.join(' ')} ${command.pathParams.filter((item) => !getFixedValue(item)).map((item) => `--${item.flag} <${item.flag.replace(/-/g, '_')}>`).join(' ')} --body-json '<json>'`.replace(/\s+/g, ' ').trim()];
219
+ }
220
+ if (command.commandPath[0] === 'row' && command.commandPath[1] === 'query' && ['limit', 'offset', 'search', 'sorts', 'view-mode'].includes(flag)) {
221
+ details.shapeHint = { limit: 20, offset: 0, search: { text_search: 'SO-1001' } };
222
+ details.suggestions = ['retry with: row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0}\''];
223
+ }
224
+ if (command.commandPath[0] === 'table' && command.commandPath[1] === 'dataset' && ['app-id', 'table-id'].includes(flag)) {
225
+ details.hint = 'table dataset uses --dataset-id and --node-id; use table get for app/table metadata';
226
+ details.suggestions = [
227
+ 'retry metadata read with: table get --app-id <app_id> --table-id <table_id>',
228
+ 'retry dataset read with: table dataset --dataset-id <dataset_id> --node-id <node_id>',
229
+ ];
230
+ }
231
+ return details;
232
+ }
233
+ function visibleQueryParams(command) {
234
+ if (!command)
235
+ return [];
236
+ return effectiveQueryParams(command).filter((item) => !getFixedValue(item));
237
+ }
238
+ function effectiveQueryParams(command) {
239
+ if (!command)
240
+ return [];
241
+ return command.queryParams.filter((item) => {
242
+ if (rowCommandNeedsPolicy(command) && item.key === 'policy_id') {
243
+ return false;
244
+ }
245
+ return true;
246
+ });
247
+ }
129
248
  function isRecord(value) {
130
249
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
131
250
  }
251
+ function stringifyUpstreamBody(value) {
252
+ if (typeof value === 'string')
253
+ return value;
254
+ if (value == null)
255
+ return '';
256
+ try {
257
+ return JSON.stringify(value);
258
+ }
259
+ catch {
260
+ return String(value);
261
+ }
262
+ }
263
+ function extractTraceID(value) {
264
+ if (!isRecord(value))
265
+ return undefined;
266
+ if (typeof value.trace_id === 'string')
267
+ return value.trace_id;
268
+ if (typeof value.request_id === 'string')
269
+ return value.request_id;
270
+ return undefined;
271
+ }
272
+ function upstreamErrorMessage(value) {
273
+ if (isRecord(value) && typeof value.message === 'string')
274
+ return value.message;
275
+ if (typeof value === 'string')
276
+ return value;
277
+ return stringifyUpstreamBody(value);
278
+ }
279
+ function throwUpstreamHTTPError(endpoint, method, response, parsedResponse) {
280
+ const upstreamError = isRecord(parsedResponse) && parsedResponse.error ? parsedResponse.error : undefined;
281
+ const body = stringifyUpstreamBody(parsedResponse);
282
+ throw new CLIError('UPSTREAM_REQUEST_FAILED', upstreamError ? upstreamErrorMessage(upstreamError) : body || `HTTP ${response.status}`, 1, {
283
+ endpoint,
284
+ method,
285
+ status: response.status,
286
+ traceId: extractTraceID(parsedResponse),
287
+ responseBody: body,
288
+ ...(upstreamError ? { upstreamError } : {}),
289
+ });
290
+ }
291
+ async function fetchUpstream(url, init, endpoint, method, fetchImpl) {
292
+ try {
293
+ return await fetchImpl(url, init);
294
+ }
295
+ catch (error) {
296
+ throw new CLIError('UPSTREAM_NETWORK_ERROR', error instanceof Error ? error.message : String(error), 1, {
297
+ endpoint,
298
+ method,
299
+ reason: 'network or DNS failure before Arcubase returned an HTTP response',
300
+ });
301
+ }
302
+ }
132
303
  function extractEntityFields(payload) {
133
304
  const candidate = isRecord(payload) && isRecord(payload.data) ? payload.data : payload;
134
305
  if (!isRecord(candidate) || !Array.isArray(candidate.fields)) {
@@ -144,10 +315,12 @@ function extractEntityFields(payload) {
144
315
  }));
145
316
  }
146
317
  async function fetchEntityFields(runtimeEnv, appId, entityId, fetchImpl) {
147
- const response = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, `/api/entity/${encodeURIComponent(appId)}/${encodeURIComponent(entityId)}`), {
318
+ const endpoint = `/api/entity/${appId}/${entityId}`;
319
+ const encodedEndpoint = `/api/entity/${encodeURIComponent(appId)}/${encodeURIComponent(entityId)}`;
320
+ const response = await fetchUpstream(buildURL(runtimeEnv.ARCUBASE_BASE_URL, encodedEndpoint), {
148
321
  method: 'GET',
149
322
  headers: buildRequestHeaders(runtimeEnv),
150
- });
323
+ }, endpoint, 'GET', fetchImpl);
151
324
  const raw = await response.text();
152
325
  let parsedResponse = raw;
153
326
  try {
@@ -157,18 +330,15 @@ async function fetchEntityFields(runtimeEnv, appId, entityId, fetchImpl) {
157
330
  parsedResponse = raw;
158
331
  }
159
332
  if (!response.ok) {
160
- throw new CLIError('UPSTREAM_REQUEST_FAILED', typeof parsedResponse === 'string' ? parsedResponse : JSON.stringify(parsedResponse), 1);
333
+ throwUpstreamHTTPError(endpoint, 'GET', response, parsedResponse);
161
334
  }
162
335
  if (isRecord(parsedResponse) && parsedResponse.error) {
163
336
  const upstreamError = parsedResponse.error;
164
- const upstreamMessage = isRecord(upstreamError) && typeof upstreamError.message === 'string'
165
- ? upstreamError.message
166
- : typeof upstreamError === 'string'
167
- ? upstreamError
168
- : JSON.stringify(upstreamError);
337
+ const upstreamMessage = upstreamErrorMessage(upstreamError);
169
338
  throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
170
- endpoint: `/api/entity/${appId}/${entityId}`,
339
+ endpoint,
171
340
  method: 'GET',
341
+ traceId: extractTraceID(parsedResponse),
172
342
  upstreamError,
173
343
  });
174
344
  }
@@ -217,76 +387,523 @@ async function validateFileFieldPayloads(scope, command, runtimeEnv, flags, body
217
387
  const fieldName = field.label ? `${field.label} (${field.type})` : `${field.type} field ${fieldId}`;
218
388
  if (typeof value === 'string') {
219
389
  const guidance = looksLikeLocalFilePath(value)
220
- ? `do not put a local file path directly into ${fieldName}; run "arcubase upload ${value}" first and use the returned data array as the field value`
221
- : `${fieldName} must be an array returned by "arcubase upload <local-file>", not a string`;
390
+ ? `do not put a local file path directly into ${fieldName}; use arcubase upload token flow and pass the returned upload value array`
391
+ : `${fieldName} must be an upload value array, not a string`;
222
392
  throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
223
393
  operation: `${scope}.${command.module}.${command.functionName}`,
224
394
  requestType: command.requestType ?? undefined,
225
395
  issues: [{ path: fieldPath, message: guidance }],
226
396
  docHints: [
227
- { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
228
- { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
397
+ { title: 'Uploads', file: sdkPath('docs/runtime-reference/uploads.md', runtimeEnv) },
398
+ { title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
229
399
  ],
230
400
  });
231
401
  }
232
402
  if (isRecord(value)) {
233
- const guidance = `${fieldName} must be an array. Run "arcubase upload <local-file>" and use the returned data array directly as the field value`;
403
+ const guidance = `${fieldName} must be an upload value array. Use the upload token flow and pass the returned array directly`;
234
404
  throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
235
405
  operation: `${scope}.${command.module}.${command.functionName}`,
236
406
  requestType: command.requestType ?? undefined,
237
407
  issues: [{ path: fieldPath, message: guidance }],
238
408
  docHints: [
239
- { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
240
- { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
409
+ { title: 'Uploads', file: sdkPath('docs/runtime-reference/uploads.md', runtimeEnv) },
410
+ { title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
241
411
  ],
242
412
  });
243
413
  }
244
414
  if (!Array.isArray(value)) {
245
- const guidance = `${fieldName} must be an array returned by "arcubase upload <local-file>"`;
415
+ const guidance = `${fieldName} must be an upload value array`;
246
416
  throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
247
417
  operation: `${scope}.${command.module}.${command.functionName}`,
248
418
  requestType: command.requestType ?? undefined,
249
419
  issues: [{ path: fieldPath, message: guidance }],
250
420
  docHints: [
251
- { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
252
- { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
421
+ { title: 'Uploads', file: sdkPath('docs/runtime-reference/uploads.md', runtimeEnv) },
422
+ { title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
253
423
  ],
254
424
  });
255
425
  }
256
426
  for (const [index, item] of value.entries()) {
257
427
  if (!isValidUploadEntry(item)) {
258
- const guidance = `${fieldName} entries must include upload_id or assets_id plus file and file_name. Use "arcubase upload <local-file>" and copy the returned array item as-is`;
428
+ const guidance = `${fieldName} entries must include upload_id or assets_id plus file and file_name. Use the upload token flow and copy the returned array item as-is`;
259
429
  throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
260
430
  operation: `${scope}.${command.module}.${command.functionName}`,
261
431
  requestType: command.requestType ?? undefined,
262
432
  issues: [{ path: `${fieldPath}.${index}`, message: guidance }],
263
433
  docHints: [
264
- { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
265
- { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
434
+ { title: 'Uploads', file: sdkPath('docs/runtime-reference/uploads.md', runtimeEnv) },
435
+ { title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
266
436
  ],
267
437
  });
268
438
  }
269
439
  }
270
440
  }
271
441
  }
272
- function throwBodyValidationFailure(message, requestType, path, scope, command) {
273
- throw new CLIError('BODY_VALIDATION_FAILED', message, 2, {
442
+ function buildTableCreateExampleBody() {
443
+ return {
444
+ name: '投票项目',
445
+ parent: 0,
446
+ schema_version: 0,
447
+ field_id_seq: 1003,
448
+ layout: [[1001], [1002]],
449
+ fields: [
450
+ {
451
+ id: 1001,
452
+ label: '投票名称',
453
+ type: 'text',
454
+ required: true,
455
+ unique: false,
456
+ editable: true,
457
+ visible: true,
458
+ show_label: true,
459
+ scannable: false,
460
+ default_value_mode: 0,
461
+ description: '',
462
+ value: null,
463
+ number_decimal: 0,
464
+ depends: [],
465
+ key: 'vote_name',
466
+ code_index: false,
467
+ options: { type: 'text', placeholder: '', lengthLimit: false, lengthMin: 0, lengthMax: 255 },
468
+ transfers: null,
469
+ children: null,
470
+ },
471
+ {
472
+ id: 1002,
473
+ label: '投票描述',
474
+ type: 'textarea',
475
+ required: false,
476
+ unique: false,
477
+ editable: true,
478
+ visible: true,
479
+ show_label: true,
480
+ scannable: false,
481
+ default_value_mode: 0,
482
+ description: '',
483
+ value: null,
484
+ number_decimal: 0,
485
+ depends: [],
486
+ key: 'vote_description',
487
+ code_index: false,
488
+ options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
489
+ transfers: null,
490
+ children: null,
491
+ },
492
+ ],
493
+ options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
494
+ workflow_enabled: false,
495
+ };
496
+ }
497
+ function isAssignUsersCommand(scope, command) {
498
+ return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users';
499
+ }
500
+ function isTableCreateCommand(scope, command) {
501
+ return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create';
502
+ }
503
+ function isTableSchemaCommand(scope, command) {
504
+ return scope === 'admin' && command.commandPath[0] === 'table' && (command.commandPath[1] === 'create' || command.commandPath[1] === 'update-schema');
505
+ }
506
+ function isAccessRuleCommand(scope, command) {
507
+ return scope === 'admin' && command.commandPath[0] === 'access-rule';
508
+ }
509
+ function isAccessRuleUpdateCommand(scope, command) {
510
+ return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update';
511
+ }
512
+ function buildAccessRuleShapeHint(userId = 2188889901) {
513
+ return {
514
+ name: 'Sales read write',
515
+ enabled: true,
516
+ type: 'form',
517
+ options: {
518
+ user_scope: [{ type: 'user', id: userId }],
519
+ policy: {
520
+ key: 'custom',
521
+ actions: ['view', 'add', 'edit'],
522
+ all_fields_read: true,
523
+ all_fields_write: true,
524
+ fields: [{ key: ':1002', read: false, write: false, report: false }],
525
+ },
526
+ list_options: {},
527
+ },
528
+ };
529
+ }
530
+ function buildAssignUsersValidationDetails(scope, command, requestType, path, message, env = process.env) {
531
+ return {
274
532
  operation: `${scope}.${command.module}.${command.functionName}`,
275
533
  requestType,
276
- issues: [
277
- {
278
- path,
279
- message,
534
+ issues: [{ path, message }],
535
+ tsHints: [
536
+ { symbol: 'AppIngressUpdateReqVO', file: sdkPath('types/ingress.ts', env) },
537
+ { symbol: 'EntityIngressOptions', file: sdkPath('types/ingress.ts', env) },
538
+ { symbol: 'PermitUserScope', file: sdkPath('types/common.ts', env) },
539
+ ],
540
+ docHints: getDocHints(requestType, env),
541
+ shapeHint: {
542
+ update: ['user_scope'],
543
+ options: {
544
+ user_scope: [{ type: 'user', id: 2188889901 }],
280
545
  },
546
+ },
547
+ commonMistakes: [
548
+ 'do not use user_scope at top level',
549
+ 'do not use users or allowedUsers',
550
+ 'do not use hash tenant user id as id',
551
+ 'id must be numeric TenantUser.ID',
281
552
  ],
282
- tsHints: getTypeHints(requestType),
283
- docHints: getDocHints(requestType),
284
- });
553
+ };
554
+ }
555
+ function buildBodyGuidance(requestType) {
556
+ if (requestType === 'EntitySaveReqVO' || requestType === 'AppCreateEntityReqVO') {
557
+ return {
558
+ shapeHint: {
559
+ id: 2188889845,
560
+ app_id: 2188889844,
561
+ name: '订单',
562
+ schema_version: 0,
563
+ field_id_seq: 1004,
564
+ layout: [[1001], [1002], [1003]],
565
+ fields: [
566
+ {
567
+ id: 1001,
568
+ label: '订单号',
569
+ type: 'text',
570
+ required: true,
571
+ unique: false,
572
+ editable: true,
573
+ visible: true,
574
+ show_label: true,
575
+ scannable: false,
576
+ default_value_mode: 0,
577
+ description: '',
578
+ value: null,
579
+ number_decimal: 0,
580
+ depends: [],
581
+ key: 'order_no',
582
+ code_index: false,
583
+ options: { type: 'text', placeholder: '', lengthLimit: false, lengthMin: 0, lengthMax: 255 },
584
+ transfers: null,
585
+ children: null,
586
+ },
587
+ {
588
+ id: 1002,
589
+ label: '描述',
590
+ type: 'textarea',
591
+ required: false,
592
+ unique: false,
593
+ editable: true,
594
+ visible: true,
595
+ show_label: true,
596
+ scannable: false,
597
+ default_value_mode: 0,
598
+ description: '',
599
+ value: null,
600
+ number_decimal: 0,
601
+ depends: [],
602
+ key: 'description',
603
+ code_index: false,
604
+ options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
605
+ transfers: null,
606
+ children: null,
607
+ },
608
+ {
609
+ id: 1003,
610
+ label: '状态',
611
+ type: 'select',
612
+ required: true,
613
+ unique: false,
614
+ editable: true,
615
+ visible: true,
616
+ show_label: true,
617
+ scannable: false,
618
+ default_value_mode: 1,
619
+ description: '',
620
+ value: 'draft',
621
+ number_decimal: 0,
622
+ depends: [],
623
+ key: 'status',
624
+ code_index: false,
625
+ options: {
626
+ horizontal: false,
627
+ numberMax: '',
628
+ numberMin: '',
629
+ numberRange: false,
630
+ repeatRemind: 'This value already exists and duplicate submission is not allowed',
631
+ options: {
632
+ mode: 'custom',
633
+ colorful: false,
634
+ items: [
635
+ { key: 1, value: 'draft' },
636
+ { key: 2, value: 'active' },
637
+ ],
638
+ },
639
+ },
640
+ transfers: null,
641
+ children: null,
642
+ },
643
+ ],
644
+ options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
645
+ workflow_enabled: false,
646
+ },
647
+ commonMistakes: [
648
+ 'table create must include fields and field_id_seq; do not create an empty table shell',
649
+ 'prefer --body-json for table create/update-schema; do not create a body file unless JSON is too large for one tool call',
650
+ 'fields must be an array of full FieldVO objects, not an object map',
651
+ 'get table shell first: arcubase-admin table get --app-id <app_id> --table-id <table_id>',
652
+ 'save the full returned schema after editing; do not submit only changed fields',
653
+ 'field_id_seq must be greater than every field.id',
654
+ 'layout must reference existing field ids',
655
+ 'use type "text", not "string"',
656
+ 'use type "member", not "user"',
657
+ 'for select/radio/checkbox/status, options must contain options.items',
658
+ 'for linkto/relation/query, options must contain relation.app_id, relation.entity_id, relation.type',
659
+ 'do not put per-user permissions in FieldVO; use access-rule for read/write and hidden fields',
660
+ ],
661
+ suggestions: [
662
+ `retry with: arcubase-admin table create --app-id <app_id> --body-json '${JSON.stringify(buildTableCreateExampleBody())}'`,
663
+ ],
664
+ };
665
+ }
666
+ if (requestType === 'AppIngressCreateReqVO' || requestType === 'AppIngressUpdateReqVO') {
667
+ return {
668
+ shapeHint: buildAccessRuleShapeHint(),
669
+ commonMistakes: [
670
+ 'use options.policy, not top-level permissions',
671
+ 'use options.policy.fields, not field_visibility',
672
+ 'actions must use Arcubase action codes: view, add, edit; do not use read, write, or insert',
673
+ 'field permission keys must be strings prefixed with colon, for example ":1002"; do not use FieldVO.key values such as "vote_name"',
674
+ 'for access-rule update policy changes, use {"update":["options"],"options":{"policy":...,"list_options":{}}}; do not use update:["options.policy"]',
675
+ 'use options.user_scope with type user/department/actor and numeric id; do not use FieldVO type "member" here',
676
+ 'use assign-users only for updating user_scope later',
677
+ 'assign-users preserves existing policy when body.update=["user_scope"]',
678
+ ],
679
+ };
680
+ }
681
+ if (requestType === 'InsertReqVO') {
682
+ return {
683
+ shapeHint: {
684
+ fields: {
685
+ '1001': 'SO-1001',
686
+ '1002': 199,
687
+ },
688
+ },
689
+ commonMistakes: [
690
+ 'row fields must use numeric field ids as JSON object keys',
691
+ 'do not use field labels such as "标题" or "投票标题" as keys',
692
+ 'run table get first when field ids are unclear',
693
+ 'do not add access policy fields; the CLI resolves access policy internally',
694
+ ],
695
+ suggestions: [
696
+ 'run: arcubase table get --app-id <app_id> --table-id <table_id>',
697
+ 'retry with: arcubase row create --app-id <app_id> --table-id <table_id> --body-json \'{"fields":{"1001":"今天吃什么"}}\'',
698
+ ],
699
+ };
700
+ }
701
+ if (requestType === 'EntityQueryReqVO') {
702
+ return {
703
+ shapeHint: {
704
+ limit: 20,
705
+ offset: 0,
706
+ search: { text_search: 'SO-1001' },
707
+ },
708
+ commonMistakes: [
709
+ 'limit, offset, search, sorts, and view_mode belong inside --body-json',
710
+ 'do not pass --limit or --offset flags',
711
+ 'do not add access policy fields; the CLI resolves access policy internally',
712
+ ],
713
+ suggestions: [
714
+ 'retry with: arcubase row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0}\'',
715
+ ],
716
+ };
717
+ }
718
+ if (requestType === 'EntityUpdateReqVO') {
719
+ return {
720
+ shapeHint: {
721
+ data: { '1002': 299 },
722
+ changed_fields: [1002],
723
+ },
724
+ commonMistakes: [
725
+ 'use data, not fields',
726
+ 'changed_fields is required and must match data keys',
727
+ 'do not add access policy fields; the CLI resolves access policy internally',
728
+ ],
729
+ };
730
+ }
731
+ if (requestType === 'EntityBulkUpdateReqVO') {
732
+ return {
733
+ shapeHint: {
734
+ selection: {
735
+ type: 'ids',
736
+ ids: [2188890411, 2188890412],
737
+ length: 2,
738
+ },
739
+ fields: [
740
+ {
741
+ id: 1003,
742
+ action: {
743
+ type: 'set',
744
+ value: 2,
745
+ },
746
+ },
747
+ ],
748
+ },
749
+ commonMistakes: [
750
+ 'bulk update fields[].action must be an object with type',
751
+ 'use {"type":"set","value":2} to assign a scalar value',
752
+ 'do not use value_set, setValue, or a top-level value field',
753
+ 'use {"type":"number_add","number_add":10} only for numeric increments',
754
+ 'selection.type=ids requires ids and length',
755
+ ],
756
+ suggestions: [
757
+ 'retry with: arcubase row bulk-update --app-id <app_id> --table-id <table_id> --body-json \'{"selection":{"type":"ids","ids":[2188890411],"length":1},"fields":[{"id":1003,"action":{"type":"set","value":2}}]}\'',
758
+ ],
759
+ };
760
+ }
761
+ return {};
762
+ }
763
+ function buildBodyValidationDetails(scope, command, requestType, issues, env = process.env) {
764
+ return {
765
+ operation: `${scope}.${command.module}.${command.functionName}`,
766
+ requestType,
767
+ issues,
768
+ tsHints: getTypeHints(requestType, env),
769
+ docHints: getDocHints(requestType, env),
770
+ ...buildBodyGuidance(requestType),
771
+ };
772
+ }
773
+ function throwBodyValidationFailure(message, requestType, path, scope, command, env = process.env) {
774
+ if (isAssignUsersCommand(scope, command)) {
775
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildAssignUsersValidationDetails(scope, command, requestType, path, message, env));
776
+ }
777
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildBodyValidationDetails(scope, command, requestType, [{ path, message }], env));
778
+ }
779
+ function requireTableCreateSchemaBody(scope, command, body, env) {
780
+ if (!isRecord(body)) {
781
+ throwBodyValidationFailure('table create requires a JSON object with name, fields, field_id_seq, layout, options, and workflow_enabled', 'AppCreateEntityReqVO', 'body', scope, command, env);
782
+ }
783
+ if (!Array.isArray(body.fields) || body.fields.length === 0) {
784
+ throwBodyValidationFailure('table create must include non-empty body.fields; empty table shell creation is not allowed', 'AppCreateEntityReqVO', 'body.fields', scope, command, env);
785
+ }
786
+ if (typeof body.field_id_seq !== 'number') {
787
+ throwBodyValidationFailure('table create must include body.field_id_seq greater than every field.id', 'AppCreateEntityReqVO', 'body.field_id_seq', scope, command, env);
788
+ }
789
+ }
790
+ function stringArray(value) {
791
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
792
+ }
793
+ function validateAccessRuleUserScope(scope, command, requestType, userScope, env, pathPrefix = 'body.options.user_scope') {
794
+ if (userScope === undefined)
795
+ return;
796
+ if (!Array.isArray(userScope)) {
797
+ throwBodyValidationFailure('access-rule user_scope must be an array of {"type":"user","id":2188889901}', requestType, pathPrefix, scope, command, env);
798
+ }
799
+ const allowedTypes = new Set(['user', 'department', 'actor']);
800
+ for (const [index, item] of userScope.entries()) {
801
+ if (!isRecord(item)) {
802
+ throwBodyValidationFailure('access-rule user_scope item must be an object', requestType, `${pathPrefix}.${index}`, scope, command, env);
803
+ }
804
+ if (typeof item.type !== 'string' || !allowedTypes.has(item.type)) {
805
+ throwBodyValidationFailure('access-rule user_scope[].type must be "user", "department", or "actor"; do not use FieldVO type "member" here', requestType, `${pathPrefix}.${index}.type`, scope, command, env);
806
+ }
807
+ if (typeof item.id !== 'number' || !Number.isInteger(item.id) || item.id <= 0) {
808
+ throwBodyValidationFailure('access-rule user_scope[].id must be numeric TenantUser.ID, department ID, or actor ID', requestType, `${pathPrefix}.${index}.id`, scope, command, env);
809
+ }
810
+ }
811
+ }
812
+ function requireUpdateContains(scope, command, body, field, env) {
813
+ if (!(field in body))
814
+ return;
815
+ const update = stringArray(body.update);
816
+ if (!update.includes(field)) {
817
+ throwBodyValidationFailure(`body.update must include "${field}" when body.${field} is present. Example: {"update":["${field}"],"${field}":${field === 'enabled' ? 'true' : '"value"'}}`, command.requestType ?? 'AppIngressUpdateReqVO', 'body.update', scope, command, env);
818
+ }
819
+ }
820
+ function validateActionSpecificRawBody(scope, command, body, env) {
821
+ if (!isRecord(body) || !command.requestType) {
822
+ return;
823
+ }
824
+ if (isAssignUsersCommand(scope, command)) {
825
+ if ('users' in body || 'allowedUsers' in body || 'user_scope' in body) {
826
+ throwBodyValidationFailure('access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}', command.requestType, 'body.options.user_scope', scope, command, env);
827
+ }
828
+ }
829
+ if (isTableSchemaCommand(scope, command)) {
830
+ const fields = Array.isArray(body.fields) ? body.fields : [];
831
+ const hasPermissionPatch = fields.some((field) => isRecord(field) && ('permission' in field || 'permissions' in field || 'field_visibility' in field));
832
+ if (hasPermissionPatch) {
833
+ throwBodyValidationFailure('table schema cannot configure per-user permissions. Use access-rule create/update with options.policy.fields for hidden fields', command.requestType, 'body.fields', scope, command, env);
834
+ }
835
+ }
836
+ if (isAccessRuleCommand(scope, command)) {
837
+ const update = stringArray(body.update);
838
+ if (isAccessRuleUpdateCommand(scope, command) && update.some((item) => item.startsWith('options.'))) {
839
+ throwBodyValidationFailure('access-rule update cannot patch nested options paths. Use {"update":["options"],"options":{"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":true,"all_fields_write":true,"fields":[{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}', command.requestType, 'body.update', scope, command, env);
840
+ }
841
+ if ('permissions' in body || 'field_visibility' in body || 'description' in body) {
842
+ throwBodyValidationFailure('access-rule body only supports name, enabled, type, and options; use options.policy.actions and options.policy.fields', command.requestType, 'body', scope, command, env);
843
+ }
844
+ const options = isRecord(body.options) ? body.options : undefined;
845
+ if (options && ('permissions' in options || 'field_visibility' in options)) {
846
+ throwBodyValidationFailure('access-rule options only supports user_scope, policy, list_options, i18n_name; use options.policy.actions and options.policy.fields', command.requestType, 'body.options', scope, command, env);
847
+ }
848
+ const policy = options && isRecord(options.policy) ? options.policy : undefined;
849
+ if (isAccessRuleUpdateCommand(scope, command) && update.includes('options') && options?.user_scope !== undefined && !policy) {
850
+ throwBodyValidationFailure('access-rule update with update:["options"] cannot change only options.user_scope because it overwrites policy. Use access-rule assign-users for user assignment, or include the full options.policy and options.list_options.', command.requestType, 'body.options.policy', scope, command, env);
851
+ }
852
+ validateAccessRuleUserScope(scope, command, command.requestType, options?.user_scope, env);
853
+ const actions = policy && Array.isArray(policy.actions) ? policy.actions : [];
854
+ if (actions.includes('read') || actions.includes('write')) {
855
+ throwBodyValidationFailure('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"', command.requestType, 'body.options.policy.actions', scope, command, env);
856
+ }
857
+ const fields = policy && Array.isArray(policy.fields) ? policy.fields : [];
858
+ const allowedSystemFieldKeys = new Set(['created', 'updated', 'creator']);
859
+ for (const [index, field] of fields.entries()) {
860
+ if (!isRecord(field))
861
+ continue;
862
+ if (typeof field.key === 'number' || (typeof field.key === 'string' && /^\d+$/.test(field.key))) {
863
+ throwBodyValidationFailure('access-rule policy.fields[].key must be a string prefixed with colon, for example ":1002"; do not use raw numbers', command.requestType, `body.options.policy.fields.${index}.key`, scope, command, env);
864
+ }
865
+ if (typeof field.key === 'string' && !field.key.startsWith(':') && !allowedSystemFieldKeys.has(field.key)) {
866
+ throwBodyValidationFailure('access-rule policy.fields[].key must use Arcubase permission keys such as ":1002"; do not use FieldVO.key values such as "vote_name"', command.requestType, `body.options.policy.fields.${index}.key`, scope, command, env);
867
+ }
868
+ }
869
+ }
285
870
  }
286
- function validateActionSpecificBody(scope, command, flags, body) {
871
+ function validateActionSpecificBody(scope, command, flags, body, env) {
287
872
  if (!isRecord(body) || !command.requestType) {
288
873
  return;
289
874
  }
875
+ if (scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update') {
876
+ requireUpdateContains(scope, command, body, 'enabled', env);
877
+ requireUpdateContains(scope, command, body, 'name', env);
878
+ requireUpdateContains(scope, command, body, 'options', env);
879
+ }
880
+ if (isAssignUsersCommand(scope, command)) {
881
+ const update = stringArray(body.update);
882
+ const options = isRecord(body.options) ? body.options : undefined;
883
+ const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : undefined;
884
+ if ('users' in body || 'allowedUsers' in body) {
885
+ throwBodyValidationFailure('access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}', command.requestType, 'body.options.user_scope', scope, command, env);
886
+ }
887
+ if (!update.includes('user_scope')) {
888
+ throwBodyValidationFailure('access-rule assign-users requires body.update to include "user_scope"', command.requestType, 'body.update', scope, command, env);
889
+ }
890
+ if (!userScope || userScope.length === 0) {
891
+ throwBodyValidationFailure('access-rule assign-users requires non-empty body.options.user_scope', command.requestType, 'body.options.user_scope', scope, command, env);
892
+ }
893
+ validateAccessRuleUserScope(scope, command, command.requestType, userScope, env);
894
+ const assignedUserScope = userScope;
895
+ for (const [index, item] of assignedUserScope.entries()) {
896
+ if (!isRecord(item)) {
897
+ throwBodyValidationFailure('access-rule assign-users requires each body.options.user_scope item to be an object', command.requestType, `body.options.user_scope.${index}`, scope, command, env);
898
+ }
899
+ if (item.type !== 'user') {
900
+ throwBodyValidationFailure('access-rule assign-users requires body.options.user_scope[].type to be "user"', command.requestType, `body.options.user_scope.${index}.type`, scope, command, env);
901
+ }
902
+ if (typeof item.id !== 'number' || !Number.isInteger(item.id) || item.id <= 0) {
903
+ throwBodyValidationFailure('access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID', command.requestType, `body.options.user_scope.${index}.id`, scope, command, env);
904
+ }
905
+ }
906
+ }
290
907
  if (command.commandPath[0] === 'row' && command.commandPath[1] === 'selection-action') {
291
908
  const action = flagValue(flags, 'action');
292
909
  const selection = body.selection;
@@ -298,18 +915,315 @@ function validateActionSpecificBody(scope, command, flags, body) {
298
915
  const conditionKeys = condition ? Object.keys(condition).filter((key) => key !== 'selectAll') : [];
299
916
  if (action === 'query') {
300
917
  if (type === 'condition') {
301
- throwBodyValidationFailure('selection.type=condition is not supported for action=query; use selection.type=ids or selection.type=all, or use row query for search conditions', command.requestType, 'body.selection.type', scope, command);
918
+ throwBodyValidationFailure('selection.type=condition is not supported for action=query; use selection.type=ids or selection.type=all, or use row query for search conditions', command.requestType, 'body.selection.type', scope, command, env);
302
919
  }
303
920
  return;
304
921
  }
305
922
  if ((action === 'archive' || action === 'delete' || action === 'batch_print') && type === 'all') {
306
- throwBodyValidationFailure(`selection.type=all is not supported for action=${action}; use ids or condition`, command.requestType, 'body.selection.type', scope, command);
923
+ throwBodyValidationFailure(`selection.type=all is not supported for action=${action}; use ids or condition`, command.requestType, 'body.selection.type', scope, command, env);
307
924
  }
308
925
  if ((action === 'archive' || action === 'delete') && type === 'condition' && conditionKeys.length === 0) {
309
- throwBodyValidationFailure(`selection.condition for action=${action} requires at least one q, tab, or filter_* key in addition to selectAll`, command.requestType, 'body.selection.condition', scope, command);
926
+ throwBodyValidationFailure(`selection.condition for action=${action} requires at least one q, tab, or filter_* key in addition to selectAll`, command.requestType, 'body.selection.condition', scope, command, env);
310
927
  }
311
928
  }
312
929
  }
930
+ function unwrapData(value) {
931
+ let current = value;
932
+ for (let i = 0; i < 3; i += 1) {
933
+ if (isRecord(current) && isRecord(current.data)) {
934
+ current = current.data;
935
+ continue;
936
+ }
937
+ break;
938
+ }
939
+ return current;
940
+ }
941
+ function requiredRowAction(command, flags) {
942
+ if (command.commandPath[0] !== 'row')
943
+ return undefined;
944
+ switch (command.commandPath[1]) {
945
+ case 'query':
946
+ case 'get':
947
+ return 'view';
948
+ case 'create':
949
+ return 'add';
950
+ case 'update':
951
+ return 'edit';
952
+ case 'delete':
953
+ return 'delete';
954
+ case 'bulk-update':
955
+ return 'bulk_update';
956
+ case 'selection-action':
957
+ return flagValue(flags, 'action') || undefined;
958
+ default:
959
+ return undefined;
960
+ }
961
+ }
962
+ function rowCommandNeedsPolicy(command) {
963
+ return command.scope === 'user' && command.commandPath[0] === 'row';
964
+ }
965
+ function extractIngressItems(appDetail) {
966
+ const appData = unwrapData(appDetail);
967
+ const ingress = isRecord(appData) ? appData.ingress : undefined;
968
+ const groups = isRecord(ingress) && Array.isArray(ingress.groups) ? ingress.groups : [];
969
+ const items = [];
970
+ for (const group of groups) {
971
+ const groupItems = isRecord(group) && Array.isArray(group.items) ? group.items : [];
972
+ for (const item of groupItems) {
973
+ if (!isRecord(item))
974
+ continue;
975
+ const id = typeof item.id === 'string' ? item.id : typeof item.ID === 'string' ? item.ID : '';
976
+ if (id) {
977
+ items.push({ id });
978
+ }
979
+ }
980
+ }
981
+ return items;
982
+ }
983
+ function ingressMatchesTableAndAction(ingressDetail, tableId, action) {
984
+ const ingressData = unwrapData(ingressDetail);
985
+ if (!isRecord(ingressData))
986
+ return undefined;
987
+ const id = typeof ingressData.id === 'number' || typeof ingressData.id === 'string'
988
+ ? ingressData.id
989
+ : typeof ingressData.ID === 'number' || typeof ingressData.ID === 'string'
990
+ ? ingressData.ID
991
+ : undefined;
992
+ if (String(id ?? '') !== tableId)
993
+ return undefined;
994
+ const policyID = typeof ingressData.hash_id === 'string'
995
+ ? ingressData.hash_id
996
+ : typeof ingressData.hashId === 'string'
997
+ ? ingressData.hashId
998
+ : undefined;
999
+ const policy = isRecord(ingressData.policy) ? ingressData.policy : undefined;
1000
+ if (!action)
1001
+ return { policyID: policyID || '', policy };
1002
+ const actions = policy && Array.isArray(policy.actions) ? policy.actions.filter((item) => typeof item === 'string') : [];
1003
+ if (!actions.includes(action))
1004
+ return undefined;
1005
+ return { policyID: policyID || '', policy };
1006
+ }
1007
+ function writtenRowFieldIds(command, body) {
1008
+ if (!isRecord(body))
1009
+ return [];
1010
+ const source = command.commandPath[1] === 'create'
1011
+ ? body.fields
1012
+ : command.commandPath[1] === 'update'
1013
+ ? body.data
1014
+ : undefined;
1015
+ if (!isRecord(source))
1016
+ return [];
1017
+ return Object.keys(source)
1018
+ .filter((key) => /^\d+$/.test(key))
1019
+ .map((key) => Number(key));
1020
+ }
1021
+ function fieldWriteAllowedByPolicy(policy, fieldId) {
1022
+ if (!policy)
1023
+ return true;
1024
+ const fields = Array.isArray(policy.fields) ? policy.fields : [];
1025
+ const explicit = fields.find((item) => isRecord(item) && item.key === `:${fieldId}`);
1026
+ if (isRecord(explicit) && explicit.write === false)
1027
+ return false;
1028
+ if (isRecord(explicit) && explicit.write === true)
1029
+ return true;
1030
+ if (policy.all_fields_write === false)
1031
+ return false;
1032
+ return true;
1033
+ }
1034
+ function assertWritableFieldsForRowCommand(command, runtimeEnv, body, policy) {
1035
+ if (command.commandPath[1] !== 'create' && command.commandPath[1] !== 'update') {
1036
+ return;
1037
+ }
1038
+ const denied = writtenRowFieldIds(command, body).filter((fieldId) => !fieldWriteAllowedByPolicy(policy, fieldId));
1039
+ if (denied.length === 0) {
1040
+ return;
1041
+ }
1042
+ const payloadKey = command.commandPath[1] === 'create' ? 'fields' : 'data';
1043
+ throw new CLIError('FIELD_PERMISSION_REQUIRED', `row ${command.commandPath[1]} includes field ids without write permission: ${denied.join(', ')}`, 2, {
1044
+ operation: `user.${command.module}.${command.functionName}`,
1045
+ requestType: command.requestType ?? undefined,
1046
+ issues: denied.map((fieldId) => ({
1047
+ path: `body.${payloadKey}.${fieldId}`,
1048
+ message: 'field is not writable for the resolved Arcubase access rule',
1049
+ })),
1050
+ docHints: [
1051
+ { title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
1052
+ { title: 'Runtime reference', file: sdkPath('docs/runtime-reference/README.md', runtimeEnv) },
1053
+ ],
1054
+ suggestions: [
1055
+ 'remove non-writable field ids from the row body and retry',
1056
+ 'run arcubase table get to map visible business fields to numeric field ids',
1057
+ 'ask an admin to update the access-rule if this field should be writable',
1058
+ ],
1059
+ });
1060
+ }
1061
+ async function resolvePolicyIDForRowCommand(command, runtimeEnv, flags, fetchImpl) {
1062
+ const appId = flagValue(flags, 'app-id');
1063
+ const tableId = flagValue(flags, 'table-id');
1064
+ const action = requiredRowAction(command, flags);
1065
+ if (!appId || !tableId) {
1066
+ throw new CLIError('POLICY_ID_RESOLUTION_FAILED', 'row command requires --app-id and --table-id to resolve Arcubase policy_id', 2, {
1067
+ operation: `user.${command.module}.${command.functionName}`,
1068
+ docHints: getDocHints(command.requestType ?? '', runtimeEnv),
1069
+ });
1070
+ }
1071
+ const appDetail = await requestJSON(runtimeEnv, 'GET', `/api/apps/${encodeURIComponent(appId)}`, undefined, fetchImpl);
1072
+ const ingressItems = extractIngressItems(appDetail.data);
1073
+ for (const item of ingressItems) {
1074
+ const ingressDetail = await requestJSON(runtimeEnv, 'GET', `/api/ingress/${encodeURIComponent(appId)}/${encodeURIComponent(item.id)}`, undefined, fetchImpl);
1075
+ const match = ingressMatchesTableAndAction(ingressDetail.data, tableId, action);
1076
+ if (match) {
1077
+ return { ...match, policyID: match.policyID || item.id };
1078
+ }
1079
+ }
1080
+ throw new CLIError('POLICY_ID_RESOLUTION_FAILED', `no accessible ingress policy found for app ${appId}, table ${tableId}${action ? `, action ${action}` : ''}`, 2, {
1081
+ operation: `user.${command.module}.${command.functionName}`,
1082
+ requestType: command.requestType ?? undefined,
1083
+ issues: [{ path: 'policy_id', message: 'CLI could not resolve policy_id from accessible ingress' }],
1084
+ docHints: [
1085
+ { title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
1086
+ { title: 'Runtime reference', file: sdkPath('docs/runtime-reference/README.md', runtimeEnv) },
1087
+ ],
1088
+ suggestions: [
1089
+ 'run arcubase entry and confirm the app/table is visible',
1090
+ 'ask an admin to create an enabled access-rule with the required action for this table',
1091
+ ],
1092
+ });
1093
+ }
1094
+ async function injectResolvedPolicyID(command, runtimeEnv, flags, query, body, fetchImpl) {
1095
+ if (!rowCommandNeedsPolicy(command)) {
1096
+ return { query, body };
1097
+ }
1098
+ if (typeof query.policy_id === 'string' && query.policy_id) {
1099
+ return { query, body };
1100
+ }
1101
+ if (isRecord(body) && typeof body.policy_id === 'string' && body.policy_id) {
1102
+ return { query, body };
1103
+ }
1104
+ const resolvedPolicy = await resolvePolicyIDForRowCommand(command, runtimeEnv, flags, fetchImpl);
1105
+ const policyID = resolvedPolicy.policyID;
1106
+ assertWritableFieldsForRowCommand(command, runtimeEnv, body, resolvedPolicy.policy);
1107
+ if (command.commandPath[1] === 'get') {
1108
+ return { query: { ...query, policy_id: policyID }, body };
1109
+ }
1110
+ if (isRecord(body)) {
1111
+ return { query, body: { ...body, policy_id: policyID } };
1112
+ }
1113
+ return { query, body };
1114
+ }
1115
+ function assertNoUserPolicyIDInput(command, runtimeEnv, query, body) {
1116
+ if (!rowCommandNeedsPolicy(command)) {
1117
+ return;
1118
+ }
1119
+ if ('policy_id' in query || (isRecord(body) && 'policy_id' in body)) {
1120
+ throw new CLIError('INTERNAL_POLICY_ID_NOT_ALLOWED', 'policy_id is an internal Arcubase ingress parameter; use app/table/row ids only', 2, {
1121
+ operation: `user.${command.module}.${command.functionName}`,
1122
+ issues: [{ path: 'policy_id', message: 'do not provide policy_id in flags, query file, or body JSON' }],
1123
+ docHints: [
1124
+ { title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
1125
+ { title: 'Runtime reference', file: sdkPath('docs/runtime-reference/README.md', runtimeEnv) },
1126
+ ],
1127
+ suggestions: ['run arcubase entry to find the app/table ids, then retry without policy_id'],
1128
+ });
1129
+ }
1130
+ }
1131
+ async function requestJSON(runtimeEnv, method, endpoint, body, fetchImpl) {
1132
+ const response = await fetchUpstream(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint), {
1133
+ method,
1134
+ headers: {
1135
+ ...buildRequestHeaders(runtimeEnv),
1136
+ 'Content-Type': 'application/json',
1137
+ },
1138
+ body: body === undefined || method === 'GET' ? undefined : JSON.stringify(body),
1139
+ }, endpoint, method, fetchImpl);
1140
+ const raw = await response.text();
1141
+ let parsedResponse = raw;
1142
+ try {
1143
+ parsedResponse = raw ? JSON.parse(raw) : null;
1144
+ }
1145
+ catch {
1146
+ // keep raw text
1147
+ }
1148
+ if (!response.ok) {
1149
+ throwUpstreamHTTPError(endpoint, method, response, parsedResponse);
1150
+ }
1151
+ if (parsedResponse && typeof parsedResponse === 'object' && 'error' in parsedResponse && parsedResponse.error) {
1152
+ const upstreamError = parsedResponse.error;
1153
+ const upstreamMessage = upstreamErrorMessage(upstreamError);
1154
+ throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, { upstreamError });
1155
+ }
1156
+ return { status: response.status, data: parsedResponse };
1157
+ }
1158
+ function extractCreatedTableID(value) {
1159
+ const candidates = [
1160
+ value,
1161
+ isRecord(value) ? value.data : undefined,
1162
+ isRecord(value) && isRecord(value.data) ? value.data.data : undefined,
1163
+ ];
1164
+ for (const candidate of candidates) {
1165
+ if (isRecord(candidate) && typeof candidate.id === 'number') {
1166
+ return candidate.id;
1167
+ }
1168
+ }
1169
+ throw new CLIError('UPSTREAM_RESPONSE_INVALID', 'table create response did not include numeric table id', 1);
1170
+ }
1171
+ function normalizeTableCreateSchemaBody(body, appId, tableId) {
1172
+ const { parent: _parent, ...schemaBody } = body;
1173
+ return {
1174
+ ...schemaBody,
1175
+ id: tableId,
1176
+ app_id: Number.isFinite(Number(appId)) ? Number(appId) : body.app_id,
1177
+ schema_version: typeof body.schema_version === 'number' ? body.schema_version : 0,
1178
+ layout: Array.isArray(body.layout) ? body.layout : [],
1179
+ options: isRecord(body.options)
1180
+ ? body.options
1181
+ : { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
1182
+ workflow_enabled: typeof body.workflow_enabled === 'boolean' ? body.workflow_enabled : false,
1183
+ };
1184
+ }
1185
+ async function executeTableCreateWithSchema(command, runtimeEnv, flags, body, fetchImpl) {
1186
+ const appId = flagValue(flags, 'app-id');
1187
+ if (!appId) {
1188
+ throw new CLIError('MISSING_PATH_FLAG', 'missing required flag --app-id', 2);
1189
+ }
1190
+ if (!isRecord(body)) {
1191
+ throw new CLIError('BODY_VALIDATION_FAILED', 'table create body must be an object', 2);
1192
+ }
1193
+ const createBody = {
1194
+ name: typeof body.name === 'string' ? body.name : undefined,
1195
+ parent: typeof body.parent === 'number' ? body.parent : 0,
1196
+ };
1197
+ if (!createBody.name) {
1198
+ throw new CLIError('BODY_VALIDATION_FAILED', 'table create requires body.name', 2);
1199
+ }
1200
+ const createResult = await requestJSON(runtimeEnv, command.method, resolveEndpoint(command, flags), createBody, fetchImpl);
1201
+ const tableId = extractCreatedTableID(createResult.data);
1202
+ const saveBody = normalizeTableCreateSchemaBody(body, appId, tableId);
1203
+ const saveSchema = getBodySchema('EntitySaveReqVO');
1204
+ if (!saveSchema) {
1205
+ throw new CLIError('BODY_VALIDATION_FAILED', 'EntitySaveReqVO schema is not available', 2);
1206
+ }
1207
+ const parsedSaveBody = saveSchema.safeParse(saveBody);
1208
+ if (!parsedSaveBody.success) {
1209
+ const message = parsedSaveBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
1210
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildBodyValidationDetails('admin', command, 'EntitySaveReqVO', parsedSaveBody.error.issues.map((issue) => ({
1211
+ path: issue.path.length > 0 ? `body.${issue.path.join('.')}` : 'body',
1212
+ message: issue.message,
1213
+ })), runtimeEnv));
1214
+ }
1215
+ const saveResult = await requestJSON(runtimeEnv, 'PUT', `/api/apps/${encodeURIComponent(appId)}/entity/${encodeURIComponent(String(tableId))}`, parsedSaveBody.data, fetchImpl);
1216
+ return {
1217
+ kind: 'result',
1218
+ commandPath: command.commandPath,
1219
+ status: saveResult.status,
1220
+ data: {
1221
+ id: tableId,
1222
+ created: createResult.data,
1223
+ schema: saveResult.data,
1224
+ },
1225
+ };
1226
+ }
313
1227
  export function summarizeCommand(scope, moduleName, commandName) {
314
1228
  const command = findCommand(scope, moduleName, commandName);
315
1229
  if (!command) {
@@ -322,31 +1236,12 @@ export function summarizeCommand(scope, moduleName, commandName) {
322
1236
  endpoint: command.endpoint,
323
1237
  requestType: command.requestType,
324
1238
  pathFlags: command.pathParams.filter((item) => !getFixedValue(item)).map((item) => item.flag),
325
- queryFlags: command.queryParams.filter((item) => !getFixedValue(item)).map((item) => item.flag),
1239
+ queryFlags: visibleQueryParams(command).map((item) => item.flag),
326
1240
  };
327
1241
  }
328
1242
  export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
329
1243
  const parsed = parseCLIArgs(argv);
330
- if (scope === 'user' && parsed.commandTokens[0] === 'upload') {
331
- if (hasFlag(parsed.flags, 'help') || parsed.commandTokens.length === 1) {
332
- return { kind: 'help', text: renderUploadHelp() };
333
- }
334
- const sourcePath = parsed.commandTokens[1];
335
- if (!sourcePath) {
336
- throw new CLIError('UPLOAD_SOURCE_REQUIRED', 'upload requires a local file path', 2);
337
- }
338
- const runtimeEnv = env ?? loadRuntimeEnv(scope);
339
- const data = await uploadLocalFile(runtimeEnv, sourcePath, {
340
- filename: flagValue(parsed.flags, 'filename'),
341
- global: hasFlag(parsed.flags, 'global'),
342
- }, fetchImpl);
343
- return {
344
- kind: 'result',
345
- commandPath: ['upload', sourcePath],
346
- status: 200,
347
- data,
348
- };
349
- }
1244
+ const envInput = env ?? process.env;
350
1245
  if (parsed.commandTokens.length === 0) {
351
1246
  return { kind: 'help', text: renderRootHelp(scope) };
352
1247
  }
@@ -356,46 +1251,74 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
356
1251
  }
357
1252
  const singleTokenCommand = !commandName ? findCommand(scope, moduleName) : null;
358
1253
  if (!commandName && !singleTokenCommand) {
359
- return { kind: 'help', text: renderModuleHelp(scope, moduleName) };
1254
+ return { kind: 'help', text: renderModuleHelp(scope, moduleName, envInput) };
360
1255
  }
361
1256
  if (hasFlag(parsed.flags, 'help')) {
362
- return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName) };
1257
+ return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName, envInput) };
363
1258
  }
364
- const runtimeEnv = env ?? loadRuntimeEnv(scope);
365
1259
  const command = findCommand(scope, moduleName, commandName);
366
1260
  if (!command) {
367
1261
  throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName}${commandName ? ` ${commandName}` : ''}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName ?? ''));
368
1262
  }
1263
+ validateKnownFlags(command, parsed.flags, envInput);
1264
+ const runtimeEnv = env ?? loadRuntimeEnv(scope);
369
1265
  const endpoint = resolveEndpoint(command, parsed.flags);
370
1266
  const scalarQuery = buildScalarQuery(command, parsed.flags);
371
1267
  const fileQuery = loadQueryFromFlags(parsed.flags);
372
- const query = { ...(scalarQuery ?? {}), ...(fileQuery ?? {}) };
373
- const body = loadBodyFromFlags(parsed.flags);
1268
+ let query = { ...(fileQuery ?? {}), ...(scalarQuery ?? {}) };
1269
+ let body;
1270
+ try {
1271
+ body = loadBodyFromFlags(parsed.flags);
1272
+ }
1273
+ catch (error) {
1274
+ if (error instanceof CLIError && error.code === 'INVALID_BODY_JSON' && command.requestType) {
1275
+ throw new CLIError('INVALID_BODY_JSON', `body json is invalid for ${scope}.${command.commandPath.join(' ')}; fix JSON syntax, then use the canonical ${command.requestType} shape from docs/types`, 2, buildBodyValidationDetails(scope, command, command.requestType, [{ path: 'body', message: 'invalid JSON syntax' }], runtimeEnv));
1276
+ }
1277
+ throw error;
1278
+ }
374
1279
  let validatedBody = body;
375
1280
  if (!command.requestType && body !== undefined) {
376
1281
  throw new CLIError('BODY_JSON_NOT_SUPPORTED', `command does not accept a request body: ${moduleName} ${commandName}`, 2);
377
1282
  }
378
1283
  if (command.requestType) {
379
1284
  if (body === undefined) {
380
- throw new CLIError('MISSING_BODY_FILE', `command requires --body-file or --body-json for ${command.requestType}`, 2);
1285
+ throw new CLIError('MISSING_BODY_FILE', `command requires --body-json or --body-file for ${command.requestType}; prefer --body-json to avoid file creation`, 2);
1286
+ }
1287
+ if (isTableCreateCommand(scope, command)) {
1288
+ requireTableCreateSchemaBody(scope, command, body, runtimeEnv);
1289
+ validateActionSpecificRawBody(scope, command, body, runtimeEnv);
1290
+ const { parent: _parent, ...schemaBody } = isRecord(body) ? body : {};
1291
+ const parsedBody = getBodySchema('EntitySaveReqVO')?.safeParse(schemaBody);
1292
+ if (!parsedBody?.success) {
1293
+ const message = parsedBody
1294
+ ? parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')
1295
+ : 'EntitySaveReqVO schema is not available';
1296
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildBodyValidationDetails(scope, command, 'EntitySaveReqVO', parsedBody && !parsedBody.success
1297
+ ? parsedBody.error.issues.map((issue) => ({
1298
+ path: issue.path.length > 0 ? `body.${issue.path.join('.')}` : 'body',
1299
+ message: issue.message,
1300
+ }))
1301
+ : [{ path: 'body', message }], runtimeEnv));
1302
+ }
1303
+ return executeTableCreateWithSchema(command, runtimeEnv, parsed.flags, parsedBody.data, fetchImpl);
381
1304
  }
1305
+ validateActionSpecificRawBody(scope, command, body, runtimeEnv);
382
1306
  const schema = getBodySchema(command.requestType);
383
1307
  if (schema) {
384
1308
  const parsedBody = schema.safeParse(body);
385
1309
  if (!parsedBody.success) {
386
- throw new CLIError('BODY_VALIDATION_FAILED', parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '), 2, {
387
- operation: `${scope}.${command.module}.${command.functionName}`,
388
- requestType: command.requestType,
389
- issues: parsedBody.error.issues.map((issue) => ({
390
- path: issue.path.length > 0 ? `body.${issue.path.join('.')}` : 'body',
391
- message: issue.message,
392
- })),
393
- tsHints: getTypeHints(command.requestType),
394
- docHints: getDocHints(command.requestType),
395
- });
1310
+ const message = parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
1311
+ if (isAssignUsersCommand(scope, command)) {
1312
+ const hasUserScopeIDIssue = parsedBody.error.issues.some((issue) => issue.path.join('.') === 'options.user_scope.0.id' || issue.path.join('.').startsWith('options.user_scope.'));
1313
+ throw new CLIError('BODY_VALIDATION_FAILED', hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message, 2, buildAssignUsersValidationDetails(scope, command, command.requestType, hasUserScopeIDIssue ? 'body.options.user_scope' : 'body', hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message, runtimeEnv));
1314
+ }
1315
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildBodyValidationDetails(scope, command, command.requestType, parsedBody.error.issues.map((issue) => ({
1316
+ path: issue.path.length > 0 ? `body.${issue.path.join('.')}` : 'body',
1317
+ message: issue.message,
1318
+ })), runtimeEnv));
396
1319
  }
397
1320
  validatedBody = parsedBody.data;
398
- validateActionSpecificBody(scope, command, parsed.flags, validatedBody);
1321
+ validateActionSpecificBody(scope, command, parsed.flags, validatedBody, runtimeEnv);
399
1322
  }
400
1323
  else {
401
1324
  const unsupportedReason = getUnsupportedBodySchemaReason(command.requestType);
@@ -404,23 +1327,27 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
404
1327
  operation: `${scope}.${command.module}.${command.functionName}`,
405
1328
  requestType: command.requestType,
406
1329
  reason: unsupportedReason,
407
- tsHints: getTypeHints(command.requestType),
408
- docHints: getDocHints(command.requestType),
1330
+ tsHints: getTypeHints(command.requestType, runtimeEnv),
1331
+ docHints: getDocHints(command.requestType, runtimeEnv),
409
1332
  });
410
1333
  }
411
1334
  }
412
1335
  }
1336
+ assertNoUserPolicyIDInput(command, runtimeEnv, query, validatedBody);
413
1337
  if (scope === 'user' && (command.functionName === 'insertEntity' || command.functionName === 'updateEntityRow')) {
414
1338
  await validateFileFieldPayloads(scope, command, runtimeEnv, parsed.flags, validatedBody, fetchImpl);
415
1339
  }
416
- const response = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint, Object.keys(query).length > 0 ? query : undefined), {
1340
+ const policyInjected = await injectResolvedPolicyID(command, runtimeEnv, parsed.flags, query, validatedBody, fetchImpl);
1341
+ query = policyInjected.query;
1342
+ validatedBody = policyInjected.body;
1343
+ const response = await fetchUpstream(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint, Object.keys(query).length > 0 ? query : undefined), {
417
1344
  method: command.method,
418
1345
  headers: {
419
1346
  ...buildRequestHeaders(runtimeEnv),
420
1347
  'Content-Type': 'application/json',
421
1348
  },
422
1349
  body: validatedBody === undefined || command.method === 'GET' ? undefined : JSON.stringify(validatedBody),
423
- });
1350
+ }, endpoint, command.method, fetchImpl);
424
1351
  const raw = await response.text();
425
1352
  let parsedResponse = raw;
426
1353
  try {
@@ -430,20 +1357,12 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
430
1357
  // keep raw text
431
1358
  }
432
1359
  if (!response.ok) {
433
- throw new CLIError('UPSTREAM_REQUEST_FAILED', typeof parsedResponse === 'string' ? parsedResponse : JSON.stringify(parsedResponse), 1);
1360
+ throwUpstreamHTTPError(endpoint, command.method, response, parsedResponse);
434
1361
  }
435
1362
  if (parsedResponse && typeof parsedResponse === 'object' && 'error' in parsedResponse && parsedResponse.error) {
436
1363
  const upstreamError = parsedResponse.error;
437
- const upstreamMessage = typeof upstreamError === 'object' && upstreamError && 'message' in upstreamError && typeof upstreamError.message === 'string'
438
- ? upstreamError.message
439
- : typeof upstreamError === 'string'
440
- ? upstreamError
441
- : JSON.stringify(upstreamError);
442
- const traceId = typeof parsedResponse.trace_id === 'string'
443
- ? parsedResponse.trace_id
444
- : typeof parsedResponse.request_id === 'string'
445
- ? parsedResponse.request_id
446
- : undefined;
1364
+ const upstreamMessage = upstreamErrorMessage(upstreamError);
1365
+ const traceId = extractTraceID(parsedResponse);
447
1366
  const uploadBindFailure = upstreamMessage.includes('failed to bind upload to assets');
448
1367
  throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
449
1368
  endpoint,
@@ -453,10 +1372,10 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
453
1372
  ...(uploadBindFailure
454
1373
  ? {
455
1374
  reason: 'the row payload used a file/image upload entry, but Arcubase could not bind that upload into an asset record',
456
- hint: 'verify the upload storage host is reachable from the Arcubase server, then rerun arcubase upload and retry the row insert/update',
1375
+ hint: 'verify the upload storage host is reachable from the Arcubase server, then use the upload token flow again and retry the row insert/update',
457
1376
  docHints: [
458
- { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
459
- { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
1377
+ { title: 'Uploads', file: 'docs/runtime-reference/uploads.md' },
1378
+ { title: 'Row CRUD', file: 'docs/runtime-reference/row-crud.md' },
460
1379
  ],
461
1380
  }
462
1381
  : {}),