@carthooks/arcubase-cli 0.1.10 → 0.1.13

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