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.
- package/README.md +76 -22
- package/SKILL.md +3 -1
- package/package.json +2 -1
- package/src/adapters/mcp/constants.js +17 -0
- package/src/adapters/mcp/descriptions.js +2 -1
- package/src/adapters/mcp/guidance.js +43 -0
- package/src/adapters/mcp/instructions.js +2 -2
- package/src/adapters/mcp/prompts.js +244 -0
- package/src/adapters/mcp/resources.js +69 -0
- package/src/adapters/mcp/result.js +48 -1
- package/src/adapters/mcp/routeOverrides.js +17 -0
- package/src/adapters/mcp/routes.js +346 -0
- package/src/adapters/mcp/server.js +55 -4
- package/src/adapters/mcp/session.js +30 -2
- package/src/adapters/mcp/throttle.js +131 -0
- package/src/adapters/mcp/toolNotes.js +2 -1
- package/src/commands/articles.js +72 -3
- package/src/commands/billing.js +12 -1
- package/src/commands/improvements.js +12 -1
- package/src/commands/keywords.js +11 -1
- package/src/commands/marketplace/buyer.js +22 -3
- package/src/commands/marketplace/notifications.js +2 -1
- package/src/commands/marketplace/seller.js +20 -2
- package/src/commands/products/core.js +88 -2
- package/src/commands/products/integrations.js +18 -6
- package/src/commands/registry.js +36 -1
- package/src/core/context.js +1 -1
- package/src/core/http.js +4 -6
- package/src/core/schema.js +73 -14
|
@@ -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 = ({
|
|
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(
|
|
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(
|
|
225
|
+
input: { provider: oneOf(VALIDATE_PROVIDERS).required() },
|
|
214
226
|
cli: { positionals: ['provider'] },
|
|
215
227
|
mcp: SENSITIVE_BODY,
|
|
216
228
|
body: BodyMode.JSON,
|
package/src/commands/registry.js
CHANGED
|
@@ -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) => {
|
package/src/core/context.js
CHANGED
|
@@ -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
|
};
|
package/src/core/schema.js
CHANGED
|
@@ -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
|
|
189
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
};
|