outrank-cli 0.6.0 → 0.7.0

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.
@@ -1,10 +1,82 @@
1
1
  import { LIST_MAX_LIMIT, LIST_MIN_LIMIT } from '../../core/constants.js';
2
- import { int, string } from '../../core/schema.js';
2
+ import { bool, int, list, object, string } from '../../core/schema.js';
3
3
  import { BodyMode, CommandKind, Toolset, defineCommand, defineGroup } from '../registry.js';
4
4
  import { PRODUCT_CLI, productInput, productParams } from './shared.js';
5
5
 
6
6
  const TOOLSETS = [Toolset.CORE];
7
7
  const PRODUCT_PATH = '/products/{productId}';
8
+ const PRODUCT_NAME_HINT = '2-50 characters.';
9
+ const PRODUCT_DESCRIPTION_HINT = '50-8000 characters.';
10
+ const TARGET_AUDIENCES_HINT = '3-7 audience texts of at most 200 characters each; replaces the list.';
11
+ const HTTP_URL_HINT = 'http(s) URL.';
12
+ const SITEMAP_URL_HINT = 'http(s) URL ending in .xml.';
13
+ const EXAMPLE_ARTICLE_URLS_HINT = 'At most 3 http(s) URLs.';
14
+ const KEYWORD_REGION_HINT = 'Country code such as us, uk or global-us.';
15
+ const COMPETITORS_HINT = '3-7 competitors.';
16
+
17
+ const PRODUCT_CREATE_REQUIRED = {
18
+ guidance:
19
+ 'Creating a product needs every field: name, url, description, target_audiences (3-7), language, ' +
20
+ 'keyword_region and competitors (3-7 of { name, link }). Agree them with the user, then call again.',
21
+ };
22
+ const COMPETITOR_REQUIRED = { guidance: 'Each competitor needs a name and a link.' };
23
+
24
+ const competitorShape = () =>
25
+ object().shape({
26
+ name: string().required(COMPETITOR_REQUIRED),
27
+ link: string().required(COMPETITOR_REQUIRED).describe(HTTP_URL_HINT),
28
+ });
29
+
30
+ const PRODUCT_CREATE_BODY = object().shape({
31
+ name: string().required(PRODUCT_CREATE_REQUIRED).describe(PRODUCT_NAME_HINT),
32
+ url: string().required(PRODUCT_CREATE_REQUIRED).describe(HTTP_URL_HINT),
33
+ description: string().required(PRODUCT_CREATE_REQUIRED).describe(PRODUCT_DESCRIPTION_HINT),
34
+ target_audiences: list().required(PRODUCT_CREATE_REQUIRED).describe(TARGET_AUDIENCES_HINT),
35
+ language: string().required(PRODUCT_CREATE_REQUIRED),
36
+ keyword_region: string().required(PRODUCT_CREATE_REQUIRED).describe(KEYWORD_REGION_HINT),
37
+ competitors: list({ item: competitorShape() }).required(PRODUCT_CREATE_REQUIRED).describe(COMPETITORS_HINT),
38
+ });
39
+
40
+ const PRODUCT_UPDATE_BODY = object().shape({
41
+ name: string().describe(PRODUCT_NAME_HINT),
42
+ description: string().describe(PRODUCT_DESCRIPTION_HINT),
43
+ target_audiences: list().describe(TARGET_AUDIENCES_HINT),
44
+ sitemap_url: string().describe(SITEMAP_URL_HINT),
45
+ blog_root_url: string().describe(HTTP_URL_HINT),
46
+ example_article_urls: list().describe(EXAMPLE_ARTICLE_URLS_HINT),
47
+ auto_keyword_generation_disabled: bool(),
48
+ });
49
+
50
+ const OUTPUT_SETTINGS_OPTION_HINT = 'Allowed values are in products_output_settings_get data._meta.';
51
+ const OUTPUT_SETTINGS_INTERNAL_LINKS_MIN = 0;
52
+ const OUTPUT_SETTINGS_INTERNAL_LINKS_MAX = 5;
53
+ const BRAND_COLOR_HINT = '6-digit hex color such as #1A2B3C.';
54
+ const GLOBAL_ARTICLE_PROMPT_HINT = 'At most 10000 characters; null clears it.';
55
+ const GLOBAL_KEYWORD_PROMPT_HINT = 'At most 2000 characters; null clears it.';
56
+
57
+ const OUTPUT_SETTINGS_UPDATE_BODY = object().shape({
58
+ language: string(),
59
+ keyword_region: string().describe(KEYWORD_REGION_HINT),
60
+ tone_and_style: string().describe(OUTPUT_SETTINGS_OPTION_HINT),
61
+ image_style: string().describe(OUTPUT_SETTINGS_OPTION_HINT),
62
+ featured_image_style: string().describe(OUTPUT_SETTINGS_OPTION_HINT),
63
+ global_article_prompt: string().nullable().describe(GLOBAL_ARTICLE_PROMPT_HINT),
64
+ global_keyword_prompt: string().nullable().describe(GLOBAL_KEYWORD_PROMPT_HINT),
65
+ outline: string().nullable(),
66
+ outrank_prompt: string().nullable(),
67
+ not_included_words: string().nullable(),
68
+ internal_links: int({ min: OUTPUT_SETTINGS_INTERNAL_LINKS_MIN, max: OUTPUT_SETTINGS_INTERNAL_LINKS_MAX }),
69
+ brand_color: string().describe(BRAND_COLOR_HINT),
70
+ auto_publish: bool(),
71
+ table_of_contents: bool(),
72
+ is_cta_included: bool(),
73
+ is_video_included: bool(),
74
+ mention_similar_products: bool(),
75
+ is_infographics_enabled: bool(),
76
+ is_first_person_enabled: bool(),
77
+ are_in_article_emojis_disabled: bool(),
78
+ use_different_featured_style: bool(),
79
+ });
8
80
 
9
81
  export const productsList = defineCommand({
10
82
  id: 'products.list',
@@ -36,6 +108,7 @@ export const productsCreate = defineCommand({
36
108
  kind: CommandKind.WRITE,
37
109
  toolsets: TOOLSETS,
38
110
  body: BodyMode.JSON,
111
+ bodyField: PRODUCT_CREATE_BODY,
39
112
  run: (input, ctx) => ctx.http({ method: 'POST', path: '/products', body: input.body }),
40
113
  });
41
114
 
@@ -47,6 +120,7 @@ export const productsUpdate = defineCommand({
47
120
  input: productInput(),
48
121
  cli: PRODUCT_CLI,
49
122
  body: BodyMode.JSON,
123
+ bodyField: PRODUCT_UPDATE_BODY,
50
124
  run: (input, ctx) =>
51
125
  ctx.http({ method: 'PATCH', path: PRODUCT_PATH, pathParams: productParams(input), body: input.body }),
52
126
  });
@@ -72,7 +146,17 @@ export const productsResume = defineCommand({
72
146
  ctx.http({ method: 'POST', path: `${PRODUCT_PATH}/resume`, pathParams: productParams(input) }),
73
147
  });
74
148
 
75
- const settingsPair = ({ id, noun, resource, getSummary, replaceSummary, replaceMethod, replaceKind, guidance }) =>
149
+ const settingsPair = ({
150
+ id,
151
+ noun,
152
+ resource,
153
+ getSummary,
154
+ replaceSummary,
155
+ replaceMethod,
156
+ replaceKind,
157
+ replaceBody,
158
+ guidance,
159
+ }) =>
76
160
  defineGroup({
77
161
  id: `products.${id}`,
78
162
  help: {
@@ -104,6 +188,7 @@ const settingsPair = ({ id, noun, resource, getSummary, replaceSummary, replaceM
104
188
  input: productInput(),
105
189
  cli: PRODUCT_CLI,
106
190
  body: BodyMode.JSON,
191
+ ...(replaceBody ? { bodyField: replaceBody } : {}),
107
192
  run: (input, ctx) =>
108
193
  ctx.http({
109
194
  method: replaceMethod,
@@ -123,6 +208,7 @@ export const outputSettingsGroup = settingsPair({
123
208
  replaceSummary: 'Patch the article output settings of a product from a JSON body.',
124
209
  replaceMethod: 'PATCH',
125
210
  replaceKind: CommandKind.WRITE,
211
+ replaceBody: OUTPUT_SETTINGS_UPDATE_BODY,
126
212
  guidance: 'Use `get` to inspect current article settings and `update` to patch them.',
127
213
  });
128
214
 
@@ -63,7 +63,7 @@ const FRAMER_GUIDANCE =
63
63
  const TOOLSETS = [Toolset.INTEGRATIONS];
64
64
  const NOTION_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
65
65
  const NOTION_ID_GUIDANCE = 'Use a database id returned by the Notion databases command.';
66
- const PROVIDERS = Object.freeze([
66
+ export const PROVIDERS = Object.freeze([
67
67
  'wordpress-plugin',
68
68
  'wordpress-com',
69
69
  'notion',
@@ -72,8 +72,20 @@ const PROVIDERS = Object.freeze([
72
72
  'shopify',
73
73
  'wix',
74
74
  'webhook',
75
+ 'ghost',
75
76
  'nextjs-blog',
76
77
  ]);
78
+ export const PREPARE_PROVIDERS = Object.freeze(['wordpress-plugin']);
79
+ export const CONNECT_PROVIDERS = Object.freeze(['wordpress-com', 'notion']);
80
+ export const VALIDATE_PROVIDERS = Object.freeze([
81
+ 'webhook',
82
+ 'ghost',
83
+ 'shopify',
84
+ 'wix',
85
+ 'framer',
86
+ 'wordpress-plugin',
87
+ 'webflow',
88
+ ]);
77
89
  const WEBFLOW_RESOURCES = Object.freeze(['sites', 'collections', 'fields', 'items']);
78
90
  const FRAMER_RESOURCES = Object.freeze(['collections', 'fields']);
79
91
  const INTEGRATION_PATH = '/products/{productId}/integrations/{integrationId}';
@@ -88,7 +100,7 @@ const ATTEMPT_ID_REQUIRED = {
88
100
  };
89
101
  const SENSITIVE_BODY = { sensitiveBody: true };
90
102
 
91
- const providerInput = () => ({ provider: oneOf(PROVIDERS).required(), ...productInput() });
103
+ const providerInput = (providers) => ({ provider: oneOf(providers).required(), ...productInput() });
92
104
  const providerParams = (input) => ({ productId: input.productId, provider: input.provider });
93
105
  const integrationInput = () => ({ ...productInput(), integrationId: uuid().required() });
94
106
  const integrationParams = (input) => ({ productId: input.productId, integrationId: input.integrationId });
@@ -106,7 +118,7 @@ const prepare = defineCommand({
106
118
  summary: 'Prepare a provider integration for a product (e.g. generate the WordPress plugin token).',
107
119
  kind: CommandKind.WRITE,
108
120
  toolsets: TOOLSETS,
109
- input: providerInput(),
121
+ input: providerInput(PREPARE_PROVIDERS),
110
122
  cli: PROVIDER_PRODUCT_CLI,
111
123
  body: BodyMode.OPTIONAL_JSON,
112
124
  run: (input, ctx) =>
@@ -123,7 +135,7 @@ const connect = defineCommand({
123
135
  summary: 'Start an OAuth connect flow for a provider and return the connect URL.',
124
136
  kind: CommandKind.WRITE,
125
137
  toolsets: TOOLSETS,
126
- input: providerInput(),
138
+ input: providerInput(CONNECT_PROVIDERS),
127
139
  cli: PROVIDER_PRODUCT_CLI,
128
140
  body: BodyMode.OPTIONAL_JSON,
129
141
  run: (input, ctx) =>
@@ -135,7 +147,7 @@ const poll = defineCommand({
135
147
  summary: 'Poll an OAuth connect attempt until the handoff completes.',
136
148
  kind: CommandKind.READ,
137
149
  toolsets: TOOLSETS,
138
- input: { ...providerInput(), attemptId: string().required(ATTEMPT_ID_REQUIRED) },
150
+ input: { ...providerInput(CONNECT_PROVIDERS), attemptId: string().required(ATTEMPT_ID_REQUIRED) },
139
151
  cli: { ...PROVIDER_PRODUCT_CLI, placeholders: ATTEMPT_ID_PLACEHOLDERS },
140
152
  run: (input, ctx) =>
141
153
  ctx.http({
@@ -210,7 +222,7 @@ const validate = defineCommand({
210
222
  summary: 'Validate provider credentials from a JSON body without saving them.',
211
223
  kind: CommandKind.READ,
212
224
  toolsets: TOOLSETS,
213
- input: { provider: oneOf(PROVIDERS).required() },
225
+ input: { provider: oneOf(VALIDATE_PROVIDERS).required() },
214
226
  cli: { positionals: ['provider'] },
215
227
  mcp: SENSITIVE_BODY,
216
228
  body: BodyMode.JSON,
@@ -44,7 +44,9 @@ export const NodeType = Object.freeze({
44
44
  });
45
45
 
46
46
  export const BODY_INPUT_NAME = 'body';
47
- const MCP_FLAG_NAMES = Object.freeze(['sensitiveBody', 'untrustedOutput', 'redact']);
47
+ const MCP_FLAG_NAMES = Object.freeze(['sensitiveBody', 'untrustedOutput', 'untrustedFields', 'untrustedWhen', 'redact']);
48
+ const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
49
+ const FIELD_PATH_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?(?:\.[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?)*$/;
48
50
  const ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
49
51
  const NO_RESULT_GUIDANCE = 'This is a CLI bug: the command finished without producing a result. Report the command.';
50
52
 
@@ -124,6 +126,25 @@ export const defineGroup = ({ id, help, unknown, children, description, command
124
126
  ...(command ? { ...normalizeCommand({ ...command, id }), type: NodeType.GROUP, help, children } : {}),
125
127
  });
126
128
 
129
+ const assertUntrustedWhen = (node, paths) => {
130
+ const when = node.mcp.untrustedWhen;
131
+ if (when === undefined) return;
132
+ const shapeOk =
133
+ Boolean(when) &&
134
+ typeof when === 'object' &&
135
+ typeof when.record === 'string' &&
136
+ (when.record === '' || FIELD_PATH_PATTERN.test(when.record)) &&
137
+ typeof when.flag === 'string' &&
138
+ FIELD_NAME_PATTERN.test(when.flag);
139
+ if (!shapeOk) {
140
+ throw registryError(`${node.id}: mcp.untrustedWhen must be { record: '<path or empty>', flag: '<field name>' }`);
141
+ }
142
+ const prefix = when.record ? `${when.record}.` : '';
143
+ if (!paths.every((entry) => entry.startsWith(prefix) && entry.length > prefix.length)) {
144
+ throw registryError(`${node.id}: every mcp.untrustedFields path must sit inside the untrustedWhen record`);
145
+ }
146
+ };
147
+
127
148
  const assertMcpShape = (node) => {
128
149
  if (node.mcp === true || node.mcp === false) return;
129
150
  if (!node.mcp || typeof node.mcp !== 'object') throw registryError(`${node.id}: mcp must be a boolean or an object`);
@@ -133,6 +154,20 @@ const assertMcpShape = (node) => {
133
154
  if (node.mcp.redact !== undefined && !Array.isArray(node.mcp.redact)) {
134
155
  throw registryError(`${node.id}: mcp.redact must be an array of field names`);
135
156
  }
157
+ if (node.mcp.untrustedFields === undefined) {
158
+ if (node.mcp.untrustedWhen !== undefined) {
159
+ throw registryError(`${node.id}: mcp.untrustedWhen requires mcp.untrustedFields`);
160
+ }
161
+ return;
162
+ }
163
+ const paths = node.mcp.untrustedFields;
164
+ if (!Array.isArray(paths) || paths.length === 0 || !paths.every((entry) => FIELD_PATH_PATTERN.test(entry))) {
165
+ throw registryError(`${node.id}: mcp.untrustedFields must be a non-empty array of field paths`);
166
+ }
167
+ if (node.mcp.untrustedOutput !== true) {
168
+ throw registryError(`${node.id}: mcp.untrustedFields requires untrustedOutput: true`);
169
+ }
170
+ assertUntrustedWhen(node, paths);
136
171
  };
137
172
 
138
173
  const assertCliShape = (node) => {
@@ -268,7 +268,7 @@ const renderCliReference = (spec, args) => {
268
268
 
269
269
  const toSnakeCase = (name) => name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
270
270
 
271
- const renderMcpReference = (spec, args) => {
271
+ export const renderMcpReference = (spec, args) => {
272
272
  const toolName = spec.id.replaceAll('.', '_').replaceAll('-', '_');
273
273
  const entries = Object.entries(args).filter(([, value]) => value !== undefined);
274
274
  if (entries.length === 0) return toolName;
package/src/core/http.js CHANGED
@@ -21,6 +21,7 @@ const FORBIDDEN_PATH_SEGMENTS = new Set(['.', '..', '']);
21
21
  const TIMEOUT_ERROR_NAME = 'TimeoutError';
22
22
  const TIMEOUT_ABORT_MESSAGE = 'The request timed out.';
23
23
  const READ_METHOD = 'GET';
24
+ export const RECEIVED_RESPONSE_PROPERTY = 'receivedResponse';
24
25
  const RESPONSE_TIMEOUT_CODES = new Set(['UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT']);
25
26
  const NEVER_SENT_CODES = new Set([
26
27
  'ENOTFOUND',
@@ -376,18 +377,15 @@ export const requestAgentApi = async (
376
377
  throw failure(error);
377
378
  }
378
379
 
380
+ const received = { status: response.status, headers: Object.fromEntries(response.headers.entries()) };
379
381
  let rawBody;
380
382
  try {
381
383
  rawBody = await response.text();
382
384
  } catch (error) {
383
- throw failure(error);
385
+ throw Object.assign(failure(error), { [RECEIVED_RESPONSE_PROPERTY]: received });
384
386
  } finally {
385
387
  abort.release();
386
388
  }
387
389
 
388
- return {
389
- result: interpretResponse({ response, url, baseUrl: ctx.baseUrl, rawBody }),
390
- status: response.status,
391
- headers: Object.fromEntries(response.headers.entries()),
392
- };
390
+ return { result: interpretResponse({ response, url, baseUrl: ctx.baseUrl, rawBody }), ...received };
393
391
  };
@@ -4,6 +4,7 @@ export const FieldKind = Object.freeze({
4
4
  STRING: 'string',
5
5
  UUID: 'uuid',
6
6
  INT: 'int',
7
+ NUMBER: 'number',
7
8
  BOOL: 'bool',
8
9
  ENUM: 'enum',
9
10
  LIST: 'list',
@@ -46,17 +47,20 @@ const createField = (kind, options = {}) => {
46
47
  field.describe = (description) => derive({ description });
47
48
  field.default = (defaultValue) => derive({ defaultValue });
48
49
  field.guidance = (invalidGuidance) => derive({ invalidGuidance });
50
+ field.nullable = () => derive({ isNullable: true });
51
+ if (kind === FieldKind.OBJECT) field.shape = (properties) => derive({ properties: Object.freeze({ ...properties }) });
49
52
  return field;
50
53
  };
51
54
 
52
55
  export const string = () => createField(FieldKind.STRING);
53
56
  export const uuid = () => createField(FieldKind.UUID);
54
57
  export const int = ({ min, max } = {}) => createField(FieldKind.INT, { min, max });
58
+ export const number = ({ min, max } = {}) => createField(FieldKind.NUMBER, { min, max });
55
59
  export const bool = ({ serialize = BoolSerialize.OMIT_FALSE } = {}) => createField(FieldKind.BOOL, { serialize });
56
60
  export const oneOf = (values) => createField(FieldKind.ENUM, { values: Object.freeze([...values]) });
57
61
  export const list = ({ split = DEFAULT_LIST_SEPARATOR, serialize = ListSerialize.ARRAY, item = string() } = {}) =>
58
62
  createField(FieldKind.LIST, { split, serialize, item });
59
- export const object = () => createField(FieldKind.OBJECT);
63
+ export const object = () => createField(FieldKind.OBJECT, { properties: undefined });
60
64
  export const json = () => createField(FieldKind.JSON);
61
65
  export const image = () => createField(FieldKind.IMAGE);
62
66
 
@@ -92,6 +96,12 @@ const parseScalar = (field, value, label) => {
92
96
  if (field.max !== undefined && number > field.max) throw usageError(`${label} must be <= ${field.max}.`);
93
97
  return number;
94
98
  }
99
+ if (field.kind === FieldKind.NUMBER) {
100
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw usageError(`${label} must be a number.`);
101
+ if (field.min !== undefined && value < field.min) throw usageError(`${label} must be >= ${field.min}.`);
102
+ if (field.max !== undefined && value > field.max) throw usageError(`${label} must be <= ${field.max}.`);
103
+ return value;
104
+ }
95
105
  if (field.kind === FieldKind.BOOL) {
96
106
  if (typeof value === 'boolean') return value;
97
107
  if (value === TRUE_TEXT) return true;
@@ -104,7 +114,7 @@ const parseScalar = (field, value, label) => {
104
114
  }
105
115
  if (field.kind === FieldKind.OBJECT) {
106
116
  if (!isPlainObject(value)) throw usageError(`${label} must be a JSON object.`);
107
- return value;
117
+ return field.properties ? parseShapedObject(field, value, label) : value;
108
118
  }
109
119
  if (field.kind === FieldKind.JSON) {
110
120
  return value;
@@ -148,6 +158,37 @@ export const parseFieldValue = (field, value, label, { splitLists = true } = {})
148
158
  return parseScalar(field, value, label);
149
159
  });
150
160
 
161
+ const missingPropertyError = (property, label) => {
162
+ const guidance = property.requiredError?.guidance ?? property.invalidGuidance;
163
+ return usageError(property.requiredError?.message ?? `Missing required field: ${label}.`, guidance);
164
+ };
165
+
166
+ const parsePropertyValue = (property, value, label) =>
167
+ withFieldGuidance(property, () => {
168
+ if (property.kind !== FieldKind.LIST) return parseScalar(property, value, label);
169
+ if (!Array.isArray(value)) throw usageError(`${label} must be an array.`);
170
+ const items = parseList(property, value, label, false);
171
+ if (property.isRequired && items.length === 0) throw missingPropertyError(property, label);
172
+ return items;
173
+ });
174
+
175
+ const parseShapedObject = (field, value, label) => {
176
+ const parsed = { ...value };
177
+ for (const [key, property] of Object.entries(field.properties)) {
178
+ const propertyLabel = `${label}.${key}`;
179
+ if (value[key] === undefined || (value[key] === null && property.isNullable)) {
180
+ if (property.isRequired && value[key] === undefined) throw missingPropertyError(property, propertyLabel);
181
+ continue;
182
+ }
183
+ if (value[key] === null) {
184
+ if (property.isRequired) throw missingPropertyError(property, propertyLabel);
185
+ throw usageError(`${propertyLabel} must not be null.`);
186
+ }
187
+ parsed[key] = parsePropertyValue(property, value[key], propertyLabel);
188
+ }
189
+ return parsed;
190
+ };
191
+
151
192
  const missingFieldError = (field, name, missingMessage) => {
152
193
  const message = field.requiredError?.message ?? missingMessage(name);
153
194
  return usageError(message, field.requiredError?.guidance);
@@ -185,18 +226,30 @@ export const serializeList = (field, value) => {
185
226
  return value;
186
227
  };
187
228
 
188
- const zodScalar = (z, field, { jsonAsObject = false } = {}) => {
189
- if (field.kind === FieldKind.STRING) return z.string().min(1);
229
+ const boundedNumber = (schema, field) => {
230
+ let bounded = schema;
231
+ if (field.min !== undefined) bounded = bounded.min(field.min);
232
+ if (field.max !== undefined) bounded = bounded.max(field.max);
233
+ return bounded;
234
+ };
235
+
236
+ const zodScalar = (z, field, options = {}) => {
237
+ const { jsonAsObject = false, inBody = false } = options;
238
+ if (field.kind === FieldKind.STRING) return inBody ? z.string() : z.string().min(1);
190
239
  if (field.kind === FieldKind.UUID) return z.string().regex(UUID_PATTERN, UUID_MESSAGE);
191
- if (field.kind === FieldKind.INT) {
192
- let schema = z.number().int();
193
- if (field.min !== undefined) schema = schema.min(field.min);
194
- if (field.max !== undefined) schema = schema.max(field.max);
195
- return schema;
196
- }
240
+ if (field.kind === FieldKind.INT) return boundedNumber(z.number().int(), field);
241
+ if (field.kind === FieldKind.NUMBER) return boundedNumber(z.number(), field);
197
242
  if (field.kind === FieldKind.BOOL) return z.boolean();
198
243
  if (field.kind === FieldKind.ENUM) return z.enum(field.values);
199
- if (field.kind === FieldKind.OBJECT) return z.object({}).passthrough();
244
+ if (field.kind === FieldKind.OBJECT) {
245
+ if (!field.properties) return z.object({}).passthrough();
246
+ const propertyOptions = { ...options, inBody: true, jsonAsObject: false };
247
+ const entries = Object.entries(field.properties).map(([key, property]) => [
248
+ key,
249
+ zodField(z, property, propertyOptions),
250
+ ]);
251
+ return z.object(Object.fromEntries(entries)).passthrough();
252
+ }
200
253
  if (field.kind === FieldKind.JSON) return jsonAsObject ? z.object({}).passthrough() : z.unknown();
201
254
  if (field.kind === FieldKind.IMAGE) {
202
255
  return z.object({ file_base64: z.string().min(1), file_name: z.string().min(1), file_type: z.string().min(1) });
@@ -204,13 +257,19 @@ const zodScalar = (z, field, { jsonAsObject = false } = {}) => {
204
257
  throw new Error(`Unknown field kind: ${field.kind}`);
205
258
  };
206
259
 
207
- export const zodField = (z, field, { jsonAsObject = false, description = field.description } = {}) => {
260
+ const zodListItem = (z, item, options) =>
261
+ item.kind === FieldKind.STRING
262
+ ? z.string().trim().min(1)
263
+ : zodScalar(z, item, { ...options, inBody: false, jsonAsObject: false });
264
+
265
+ export const zodField = (z, field, { jsonAsObject = false, inBody = false, description = field.description } = {}) => {
208
266
  const minItems = field.isRequired ? 1 : 0;
209
- const options = { jsonAsObject };
267
+ const options = { jsonAsObject, inBody };
210
268
  let schema =
211
269
  field.kind === FieldKind.LIST
212
- ? z.array(zodScalar(z, field.item, options)).min(minItems)
270
+ ? z.array(zodListItem(z, field.item, options)).min(minItems)
213
271
  : zodScalar(z, field, options);
272
+ if (field.isNullable) schema = schema.nullable();
214
273
  if (description) schema = schema.describe(description);
215
274
  return field.isRequired ? schema : schema.optional();
216
275
  };