@carthooks/arcubase-cli 0.1.25 → 0.1.26

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.
@@ -295,10 +295,10 @@ function renderDevSDKGenHelp(): string {
295
295
  ' - arcubase-admin dev sdk-gen --app-id <app_id> --out src/arcubase',
296
296
  '',
297
297
  'requirements:',
298
+ ' - every generated ingress must have a configured ingress.key; sdk-gen fails fast when keys are missing',
298
299
  ' - every generated field must have a configured field.key; sdk-gen fails fast when keys are missing',
299
- ' - existing field.key values must be stable unique TypeScript identifiers',
300
- ' - entity property names use entity.key when available, otherwise a stable entity<id> fallback',
301
- ' - generated code accepts createArcubaseSdk({ baseURL, accessToken, refreshToken })',
300
+ ' - ingress.key and field.key values must be stable unique TypeScript identifiers',
301
+ ' - generated code accepts createArcubaseClient({ baseURL, accessToken, refreshToken }).ingress("<IngressKey>")',
302
302
  ].join('\n')
303
303
  }
304
304
 
@@ -2006,6 +2006,8 @@ type SDKGenEntityRef = {
2006
2006
  id: string
2007
2007
  }
2008
2008
 
2009
+ type SDKGenIngressRef = Record<string, any>
2010
+
2009
2011
  function extractSDKGenAppPayload(payload: unknown): Record<string, any> {
2010
2012
  const appPayload = unwrapResponseData(payload)
2011
2013
  if (!isRecord(appPayload)) {
@@ -2085,6 +2087,96 @@ function assertSDKGenFieldKeysPresent(entity: Record<string, any>) {
2085
2087
  })
2086
2088
  }
2087
2089
 
2090
+ function extractSDKGenIngressRefs(payload: unknown): SDKGenIngressRef[] {
2091
+ const data = unwrapResponseData(payload)
2092
+ const source = Array.isArray(data)
2093
+ ? data
2094
+ : isRecord(data) && Array.isArray(data.list)
2095
+ ? data.list
2096
+ : []
2097
+ return source.filter((item): item is SDKGenIngressRef => isRecord(item))
2098
+ }
2099
+
2100
+ function ingressKeyOf(ingress: Record<string, any>): string {
2101
+ return typeof ingress.key === 'string' ? ingress.key.trim() : ''
2102
+ }
2103
+
2104
+ function ingressHashIDOf(ingress: Record<string, any>): string {
2105
+ if (typeof ingress.hash_id === 'string' && ingress.hash_id.trim()) {
2106
+ return ingress.hash_id.trim()
2107
+ }
2108
+ if (typeof ingress.hashId === 'string' && ingress.hashId.trim()) {
2109
+ return ingress.hashId.trim()
2110
+ }
2111
+ return ''
2112
+ }
2113
+
2114
+ function assertSDKGenIngressesReady(ingresses: SDKGenIngressRef[]) {
2115
+ if (ingresses.length === 0) {
2116
+ throw new CLIError('SDK_GEN_NO_INGRESSES', 'sdk-gen could not find ingress definitions for this app', 2, {
2117
+ operation: 'dev sdk-gen',
2118
+ suggestions: ['create at least one access rule with a stable ingress.key before running sdk-gen'],
2119
+ })
2120
+ }
2121
+
2122
+ const seen = new Set<string>()
2123
+ const missingKey: SDKGenIngressRef[] = []
2124
+ const missingHash: SDKGenIngressRef[] = []
2125
+ const invalidKey: string[] = []
2126
+ const duplicateKey: string[] = []
2127
+
2128
+ for (const ingress of ingresses) {
2129
+ const key = ingressKeyOf(ingress)
2130
+ if (!key) {
2131
+ missingKey.push(ingress)
2132
+ continue
2133
+ }
2134
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(key)) {
2135
+ invalidKey.push(key)
2136
+ }
2137
+ if (seen.has(key)) {
2138
+ duplicateKey.push(key)
2139
+ }
2140
+ seen.add(key)
2141
+ if (!ingressHashIDOf(ingress)) {
2142
+ missingHash.push(ingress)
2143
+ }
2144
+ }
2145
+
2146
+ if (missingKey.length > 0) {
2147
+ const preview = missingKey.slice(0, 8).map((ingress) => String(ingress.name ?? ingress.id ?? 'unnamed ingress'))
2148
+ const suffix = missingKey.length > preview.length ? `, and ${missingKey.length - preview.length} more` : ''
2149
+ throw new CLIError('SDK_GEN_INGRESS_KEY_REQUIRED', `missing ingress.key values: ${preview.join(', ')}${suffix}`, 2, {
2150
+ operation: 'dev sdk-gen',
2151
+ issues: missingKey.map((ingress) => ({
2152
+ path: `ingress.${String(ingress.id ?? 'unknown')}.key`,
2153
+ message: `ingress.key is required for ${String(ingress.name ?? ingress.id ?? 'unnamed ingress')}`,
2154
+ })),
2155
+ suggestions: [
2156
+ 'set stable ingress.key values in Arcubase admin before running sdk-gen',
2157
+ 'rerun arcubase-admin dev sdk-gen after ingress keys are complete',
2158
+ ],
2159
+ })
2160
+ }
2161
+ if (invalidKey.length > 0) {
2162
+ throw new CLIError('SDK_GEN_INVALID_INGRESS_KEY', `invalid ingress.key values: ${invalidKey.join(', ')}`, 2, {
2163
+ operation: 'dev sdk-gen',
2164
+ suggestions: ['use TypeScript identifier keys such as publicEntry or customerPortal'],
2165
+ })
2166
+ }
2167
+ if (duplicateKey.length > 0) {
2168
+ throw new CLIError('SDK_GEN_DUPLICATE_INGRESS_KEY', `duplicate ingress.key values: ${duplicateKey.join(', ')}`, 2, {
2169
+ operation: 'dev sdk-gen',
2170
+ })
2171
+ }
2172
+ if (missingHash.length > 0) {
2173
+ throw new CLIError('SDK_GEN_INGRESS_HASH_ID_REQUIRED', 'sdk-gen expected hash_id for every ingress definition', 2, {
2174
+ operation: 'dev sdk-gen',
2175
+ suggestions: ['upgrade Arcubase server so admin access-rule list returns hash_id'],
2176
+ })
2177
+ }
2178
+ }
2179
+
2088
2180
  async function executeDevSDKGen(
2089
2181
  runtimeEnv: RuntimeEnv,
2090
2182
  flags: Record<string, string | boolean>,
@@ -2116,7 +2208,7 @@ async function executeDevSDKGen(
2116
2208
  if (entityRefs.length === 0) {
2117
2209
  throw new CLIError('SDK_GEN_NO_ENTITIES', 'sdk-gen could not find entities in app detail response', 2)
2118
2210
  }
2119
- const entities = []
2211
+ const ingresses: SDKGenIngressRef[] = []
2120
2212
  for (const entityRef of entityRefs) {
2121
2213
  const entityEndpoint = `/api/apps/${encodeURIComponent(appId)}/entity/${encodeURIComponent(entityRef.id)}`
2122
2214
  const entityDetail = await requestJSON(runtimeEnv, 'GET', entityEndpoint, undefined, fetchImpl)
@@ -2125,12 +2217,21 @@ async function executeDevSDKGen(
2125
2217
  throw new CLIError('SDK_GEN_INVALID_SCHEMA', 'sdk-gen expected entity detail response data', 2)
2126
2218
  }
2127
2219
  assertSDKGenFieldKeysPresent(entityPayload)
2128
- entities.push(entityPayload)
2220
+ const ingressEndpoint = `/api/apps/${encodeURIComponent(appId)}/entity/${encodeURIComponent(entityRef.id)}/ingress`
2221
+ const ingressList = await requestJSON(runtimeEnv, 'GET', ingressEndpoint, undefined, fetchImpl)
2222
+ const entityIngresses = extractSDKGenIngressRefs(ingressList.data).map((ingress) => ({
2223
+ ...ingress,
2224
+ hash_id: ingressHashIDOf(ingress),
2225
+ key: ingressKeyOf(ingress),
2226
+ entity: entityPayload,
2227
+ }))
2228
+ ingresses.push(...entityIngresses)
2129
2229
  }
2230
+ assertSDKGenIngressesReady(ingresses)
2130
2231
  const generated = generateArcubaseProjectSDK({
2131
2232
  id: resolveSDKGenAppId(appId, appPayload),
2132
2233
  name: typeof appPayload.name === 'string' ? appPayload.name : undefined,
2133
- entities,
2234
+ ingresses,
2134
2235
  })
2135
2236
  const absoluteOutDir = path.resolve(process.cwd(), outDir)
2136
2237
 
@@ -21,7 +21,7 @@ const env = {
21
21
  function appDetail() {
22
22
  return {
23
23
  id: 3882856425,
24
- name: '故事应用',
24
+ name: 'Demo App',
25
25
  entities: {},
26
26
  }
27
27
  }
@@ -30,7 +30,7 @@ function entityList() {
30
30
  return [
31
31
  {
32
32
  id: 3883547182,
33
- name: '编故事',
33
+ name: 'Records',
34
34
  },
35
35
  ]
36
36
  }
@@ -42,7 +42,7 @@ function appDetailWithEntityList() {
42
42
  entities: [
43
43
  {
44
44
  id: 3883547182,
45
- name: '编故事',
45
+ name: 'Records',
46
46
  },
47
47
  ],
48
48
  }
@@ -52,12 +52,11 @@ function entityDetail() {
52
52
  return {
53
53
  id: 3883547182,
54
54
  app_id: 3882856425,
55
- name: '编故事',
56
- key: 'story',
55
+ name: 'Records',
57
56
  fields: [
58
- { id: 1006, label: '时代', key: 'era', type: 'select', required: false, options: { options: { items: [{ key: 1, value: '现代' }] } } },
59
- { id: 1013, label: '你刚刚', key: 'recentEvent', type: 'text', required: false, options: {} },
60
- { id: 1011, label: '故事', key: 'story', type: 'textarea', required: true, options: {} },
57
+ { id: 1006, label: 'Title', key: 'title', type: 'text', required: true, options: {} },
58
+ { id: 1013, label: 'Status', key: 'status', type: 'select', required: false, options: { options: { items: [{ key: 1, value: 'Open' }] } } },
59
+ { id: 1011, label: 'Notes', key: 'notes', type: 'textarea', required: false, options: {} },
61
60
  ],
62
61
  }
63
62
  }
@@ -70,76 +69,125 @@ function entityDetailWithoutFieldKeys() {
70
69
 
71
70
  function sdkSchemaPayload() {
72
71
  return {
73
- ...appDetailWithEntityList(),
74
- entities: [
75
- {
76
- ...entityDetail(),
77
- key: 'story',
72
+ id: 3882856425,
73
+ name: 'Demo App',
74
+ ingresses: [ingressDefinition()],
75
+ }
76
+ }
77
+
78
+ function ingressDefinition(actions = ['view', 'add', 'edit', 'delete', 'bulk_update', 'export', 'batch_print', 'tag_manage', 'archive']) {
79
+ return {
80
+ id: 9001,
81
+ hash_id: '8WR5Kypbw9KQK20',
82
+ key: 'publicEntry',
83
+ name: 'Public entry',
84
+ entity: entityDetail(),
85
+ options: {
86
+ policy: {
87
+ actions,
88
+ all_fields_read: true,
89
+ all_fields_write: true,
90
+ fields: [],
78
91
  },
79
- ],
92
+ list_options: {},
93
+ },
80
94
  }
81
95
  }
82
96
 
83
- test('generateArcubaseProjectSDK emits typed key based entity sdk', () => {
97
+ test('generateArcubaseProjectSDK emits typed ingress sdk selected from client.ingress', () => {
84
98
  const result = generateArcubaseProjectSDK(sdkSchemaPayload())
85
99
 
86
100
  assert.deepEqual(result.files.map((file) => file.path).sort(), [
87
101
  'client.ts',
88
- 'entities/story.ts',
89
102
  'index.ts',
103
+ 'ingresses/publicEntry.ts',
90
104
  'runtime.ts',
91
105
  'schema.ts',
92
106
  ])
93
107
 
94
108
  const client = result.files.find((file) => file.path === 'client.ts')?.contents ?? ''
95
- assert.match(client, /createArcubaseSdk/)
96
- assert.match(client, /story: createStorySDK\(runtime\)/)
109
+ assert.match(client, /createArcubaseClient/)
110
+ assert.match(client, /ingress<K extends ArcubaseIngressKey>\(key: K\)/)
111
+ assert.match(client, /case ["']publicEntry["']/)
97
112
 
98
113
  const runtime = result.files.find((file) => file.path === 'runtime.ts')?.contents ?? ''
99
114
  assert.match(runtime, /accessToken/)
100
115
  assert.match(runtime, /refreshToken/)
101
116
 
102
- const story = result.files.find((file) => file.path === 'entities/story.ts')?.contents ?? ''
103
- assert.match(story, /export type StoryFields =/)
104
- assert.match(story, /era\?: number \| string/)
105
- assert.match(story, /recentEvent\?: string/)
106
- assert.match(story, /story: string/)
107
- assert.match(story, /create\(fields: StoryCreateInput\)/)
108
- assert.match(story, /update\(rowId: string \| number, fields: StoryUpdateInput\)/)
109
- assert.match(story, /toArcubaseFields\(fields\)/)
110
- assert.match(story, /changed_fields: Object.keys\(data\)\.map\(\(fieldId\) => Number\(fieldId\)\)/)
111
- assert.match(story, /\/entity\/3882856425\/\$\{entityId\}\/insert/)
117
+ const ingress = result.files.find((file) => file.path === 'ingresses/publicEntry.ts')?.contents ?? ''
118
+ assert.match(ingress, /export type PublicEntryFields =/)
119
+ assert.match(ingress, /title: string/)
120
+ assert.match(ingress, /status\?: number \| string/)
121
+ assert.match(ingress, /notes\?: string/)
122
+ assert.match(ingress, /create\(fields: PublicEntryCreateInput\)/)
123
+ assert.match(ingress, /update\(rowId: string \| number, fields: PublicEntryUpdateInput\)/)
124
+ assert.match(ingress, /query\(params: PublicEntryQueryParams = {}\)/)
125
+ assert.match(ingress, /delete\(selection: PublicEntrySelection\)/)
126
+ assert.match(ingress, /bulkUpdate\(selection: PublicEntrySelection, fields: PublicEntryBulkUpdateField\[\]\)/)
127
+ assert.match(ingress, /exportRows\(options: PublicEntryExportOptions = {}\)/)
128
+ assert.match(ingress, /batchPrint\(selection: PublicEntrySelection/)
129
+ assert.match(ingress, /archive\(selection: PublicEntrySelection\)/)
130
+ assert.match(ingress, /tags:/)
131
+ assert.match(ingress, /policy_id: ingressId/)
112
132
 
113
133
  const schema = result.files.find((file) => file.path === 'schema.ts')?.contents ?? ''
114
- assert.match(schema, /"story"/)
134
+ assert.match(schema, /"publicEntry"/)
115
135
  assert.match(schema, /"fieldId": 1011/)
116
136
  assert.match(schema, /"entityId": 3883547182/)
137
+ assert.match(schema, /"ingressId": "8WR5Kypbw9KQK20"/)
138
+ assert.doesNotMatch(schema, /entity3883547182/)
117
139
  })
118
140
 
119
141
  test('generateArcubaseProjectSDK rejects missing field keys', () => {
120
142
  const payload = sdkSchemaPayload()
121
- payload.entities[0].fields[1].key = ''
143
+ payload.ingresses[0].entity.fields[1].key = ''
122
144
 
123
145
  assert.throws(() => generateArcubaseProjectSDK(payload), (error: unknown) => {
124
146
  assert.ok(error instanceof CLIError)
125
147
  assert.equal(error.code, 'SDK_GEN_FIELD_KEY_REQUIRED')
126
- assert.match(error.message, /你刚刚/)
148
+ assert.match(error.message, /Status/)
127
149
  return true
128
150
  })
129
151
  })
130
152
 
131
153
  test('generateArcubaseProjectSDK rejects duplicate field keys within an entity', () => {
132
154
  const payload = sdkSchemaPayload()
133
- payload.entities[0].fields[1].key = 'story'
155
+ payload.ingresses[0].entity.fields[1].key = 'notes'
134
156
 
135
157
  assert.throws(() => generateArcubaseProjectSDK(payload), (error: unknown) => {
136
158
  assert.ok(error instanceof CLIError)
137
159
  assert.equal(error.code, 'SDK_GEN_DUPLICATE_FIELD_KEY')
138
- assert.match(error.message, /story/)
160
+ assert.match(error.message, /notes/)
161
+ return true
162
+ })
163
+ })
164
+
165
+ test('generateArcubaseProjectSDK rejects missing ingress keys', () => {
166
+ const payload = sdkSchemaPayload()
167
+ payload.ingresses[0].key = ''
168
+
169
+ assert.throws(() => generateArcubaseProjectSDK(payload), (error: unknown) => {
170
+ assert.ok(error instanceof CLIError)
171
+ assert.equal(error.code, 'SDK_GEN_INGRESS_KEY_REQUIRED')
172
+ assert.match(error.message, /Public entry/)
139
173
  return true
140
174
  })
141
175
  })
142
176
 
177
+ test('generateArcubaseProjectSDK only emits methods allowed by ingress actions', () => {
178
+ const payload = {
179
+ id: 3882856425,
180
+ ingresses: [ingressDefinition(['view'])],
181
+ }
182
+
183
+ const result = generateArcubaseProjectSDK(payload)
184
+ const ingress = result.files.find((file) => file.path === 'ingresses/publicEntry.ts')?.contents ?? ''
185
+ assert.match(ingress, /query\(params: PublicEntryQueryParams = {}\)/)
186
+ assert.doesNotMatch(ingress, /create\(fields:/)
187
+ assert.doesNotMatch(ingress, /update\(rowId:/)
188
+ assert.doesNotMatch(ingress, /bulkUpdate\(/)
189
+ })
190
+
143
191
  test('arcubase-admin dev sdk-gen fetches app schema and writes typed sdk files', async () => {
144
192
  const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arcubase-sdk-gen-'))
145
193
  const calls: Array<{ url: string; method?: string }> = []
@@ -159,6 +207,9 @@ test('arcubase-admin dev sdk-gen fetches app schema and writes typed sdk files',
159
207
  if (String(url).endsWith('/api/apps/3882856425/entity/3883547182')) {
160
208
  return new Response(JSON.stringify({ data: entityDetail() }), { status: 200 })
161
209
  }
210
+ if (String(url).endsWith('/api/apps/3882856425/entity/3883547182/ingress')) {
211
+ return new Response(JSON.stringify({ data: { list: [ingressDefinition()] } }), { status: 200 })
212
+ }
162
213
  return new Response(JSON.stringify({ error: { message: `unexpected url ${url}` } }), { status: 404 })
163
214
  },
164
215
  )
@@ -171,10 +222,13 @@ test('arcubase-admin dev sdk-gen fetches app schema and writes typed sdk files',
171
222
  assert.match(calls[1].url, /\/api\/apps\/3882856425\/entities$/)
172
223
  assert.equal(calls[2].method, 'GET')
173
224
  assert.match(calls[2].url, /\/api\/apps\/3882856425\/entity\/3883547182$/)
225
+ assert.equal(calls[3].method, 'GET')
226
+ assert.match(calls[3].url, /\/api\/apps\/3882856425\/entity\/3883547182\/ingress$/)
174
227
  assert.ok(fs.existsSync(path.join(outDir, 'index.ts')))
175
228
  assert.ok(fs.existsSync(path.join(outDir, 'client.ts')))
176
- assert.ok(fs.existsSync(path.join(outDir, 'entities/story.ts')))
177
- assert.match(fs.readFileSync(path.join(outDir, 'client.ts'), 'utf8'), /createArcubaseSdk/)
229
+ assert.ok(fs.existsSync(path.join(outDir, 'ingresses/publicEntry.ts')))
230
+ assert.match(fs.readFileSync(path.join(outDir, 'client.ts'), 'utf8'), /createArcubaseClient/)
231
+ assert.notEqual(fs.existsSync(path.join(outDir, 'entities')), true)
178
232
  })
179
233
 
180
234
  test('arcubase-admin dev sdk-gen fails before generation when field keys are missing', async () => {
@@ -196,15 +250,50 @@ test('arcubase-admin dev sdk-gen fails before generation when field keys are mis
196
250
  if (String(url).endsWith('/api/apps/3882856425/entity/3883547182')) {
197
251
  return new Response(JSON.stringify({ data: entityDetailWithoutFieldKeys() }), { status: 200 })
198
252
  }
253
+ if (String(url).endsWith('/api/apps/3882856425/entity/3883547182/ingress')) {
254
+ return new Response(JSON.stringify({ data: { list: [ingressDefinition()] } }), { status: 200 })
255
+ }
199
256
  return new Response(JSON.stringify({ error: { message: `unexpected url ${url}` } }), { status: 404 })
200
257
  },
201
258
  ), (error: unknown) => {
202
259
  assert.ok(error instanceof CLIError)
203
260
  assert.equal(error.code, 'SDK_GEN_FIELD_KEY_REQUIRED')
204
- assert.match(error.message, /编故事\.时代 \(1006\)/)
261
+ assert.match(error.message, /Records\.Title \(1006\)/)
205
262
  return true
206
263
  })
207
264
 
208
265
  assert.equal(calls.some((call) => call.url.endsWith('/custom-keys')), false)
209
266
  assert.equal(fs.existsSync(path.join(outDir, 'index.ts')), false)
210
267
  })
268
+
269
+ test('arcubase-admin dev sdk-gen fails before generation when ingress keys are missing', async () => {
270
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arcubase-sdk-gen-'))
271
+
272
+ await assert.rejects(() => executeCLI(
273
+ 'admin',
274
+ ['dev', 'sdk-gen', '--app-id', '3882856425', '--out', outDir],
275
+ env as any,
276
+ async (url) => {
277
+ if (String(url).endsWith('/api/apps/3882856425')) {
278
+ return new Response(JSON.stringify({ data: appDetail() }), { status: 200 })
279
+ }
280
+ if (String(url).endsWith('/api/apps/3882856425/entities')) {
281
+ return new Response(JSON.stringify({ data: entityList() }), { status: 200 })
282
+ }
283
+ if (String(url).endsWith('/api/apps/3882856425/entity/3883547182')) {
284
+ return new Response(JSON.stringify({ data: entityDetail() }), { status: 200 })
285
+ }
286
+ if (String(url).endsWith('/api/apps/3882856425/entity/3883547182/ingress')) {
287
+ return new Response(JSON.stringify({ data: { list: [{ ...ingressDefinition(), key: '' }] } }), { status: 200 })
288
+ }
289
+ return new Response(JSON.stringify({ error: { message: `unexpected url ${url}` } }), { status: 404 })
290
+ },
291
+ ), (error: unknown) => {
292
+ assert.ok(error instanceof CLIError)
293
+ assert.equal(error.code, 'SDK_GEN_INGRESS_KEY_REQUIRED')
294
+ assert.match(error.message, /Public entry/)
295
+ return true
296
+ })
297
+
298
+ assert.equal(fs.existsSync(path.join(outDir, 'index.ts')), false)
299
+ })
@@ -36,8 +36,9 @@ test('admin dev sdk-gen help documents typed project sdk generation', async () =
36
36
  assert.match(commandHelp.text, /arcubase-admin dev sdk-gen/)
37
37
  assert.match(commandHelp.text, /--app-id/)
38
38
  assert.match(commandHelp.text, /--out/)
39
+ assert.match(commandHelp.text, /ingress\.key/)
39
40
  assert.match(commandHelp.text, /field\.key/)
40
- assert.match(commandHelp.text, /createArcubaseSdk\(\{ baseURL, accessToken, refreshToken \}\)/)
41
+ assert.match(commandHelp.text, /createArcubaseClient\(\{ baseURL, accessToken, refreshToken \}\)\.ingress\("<IngressKey>"\)/)
41
42
  })
42
43
 
43
44
  test('user root help exposes reset surface nouns only', async () => {
@@ -46,6 +46,7 @@ test('AppIngressCreateReqVO accepts nested ConditionSet with field EntityProp co
46
46
  assert.ok(schema)
47
47
 
48
48
  const result = schema.safeParse({
49
+ key: 'salesOwnOrWestRegion',
49
50
  name: 'Sales own or west region',
50
51
  enabled: true,
51
52
  type: 'form',