44reports-mcp 1.2.0 → 1.4.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/dist/api-client.d.ts +12 -3
- package/dist/api-client.js +29 -9
- package/dist/index.js +44 -4
- package/dist/tools/domains.d.ts +2 -0
- package/dist/tools/domains.js +1 -0
- package/dist/tools/products.d.ts +18 -6
- package/dist/tools/products.js +31 -5
- package/dist/tools/upload-directory.d.ts +91 -0
- package/dist/tools/upload-directory.js +191 -0
- package/dist/tools/upload-directory.test.d.ts +1 -0
- package/dist/tools/upload-directory.test.js +173 -0
- package/package.json +2 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -151,7 +151,7 @@ export declare class ApiClient {
|
|
|
151
151
|
}>;
|
|
152
152
|
total: number;
|
|
153
153
|
}>;
|
|
154
|
-
getProduct(slug: string, domain?: string): Promise<{
|
|
154
|
+
getProduct(slug: string, domain?: string | string[]): Promise<{
|
|
155
155
|
product: {
|
|
156
156
|
id: string;
|
|
157
157
|
slug: string;
|
|
@@ -173,11 +173,20 @@ export declare class ApiClient {
|
|
|
173
173
|
domain: string;
|
|
174
174
|
}>;
|
|
175
175
|
}>;
|
|
176
|
-
getProductFile(slug: string, filepath: string
|
|
176
|
+
getProductFile(slug: string, filepath: string, options?: {
|
|
177
|
+
format?: 'inline' | 'signed-url';
|
|
178
|
+
ttl?: number;
|
|
179
|
+
}): Promise<{
|
|
177
180
|
path: string;
|
|
178
181
|
content: unknown;
|
|
179
182
|
content_type: string;
|
|
180
183
|
size?: number;
|
|
184
|
+
} | {
|
|
185
|
+
path: string;
|
|
186
|
+
url: string;
|
|
187
|
+
expires_at: string;
|
|
188
|
+
content_type: string;
|
|
189
|
+
size: number;
|
|
181
190
|
}>;
|
|
182
191
|
updateProductFile(slug: string, filepath: string, body: {
|
|
183
192
|
content: unknown;
|
|
@@ -233,7 +242,7 @@ export declare class ApiClient {
|
|
|
233
242
|
deleteProductFile(slug: string, filepath: string): Promise<{
|
|
234
243
|
success: boolean;
|
|
235
244
|
}>;
|
|
236
|
-
getProductIndex(slug: string, domain?: string): Promise<{
|
|
245
|
+
getProductIndex(slug: string, domain?: string | string[]): Promise<{
|
|
237
246
|
product_slug: string;
|
|
238
247
|
updated_at: string;
|
|
239
248
|
files: Array<{
|
package/dist/api-client.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
function withQuery(path, params) {
|
|
2
|
+
const qs = params.toString();
|
|
3
|
+
return qs ? `${path}?${qs}` : path;
|
|
4
|
+
}
|
|
5
|
+
function appendDomains(params, domain) {
|
|
6
|
+
if (!domain)
|
|
7
|
+
return;
|
|
8
|
+
for (const d of Array.isArray(domain) ? domain : [domain])
|
|
9
|
+
params.append('domain', d);
|
|
10
|
+
}
|
|
1
11
|
export class ApiClient {
|
|
2
12
|
config;
|
|
3
13
|
constructor(config) {
|
|
@@ -16,7 +26,11 @@ export class ApiClient {
|
|
|
16
26
|
});
|
|
17
27
|
if (!response.ok) {
|
|
18
28
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
19
|
-
|
|
29
|
+
const message = error.error || `HTTP ${response.status}`;
|
|
30
|
+
if (response.status === 401) {
|
|
31
|
+
throw new Error(`${message}. Reporter token rejected. Generate or rotate yours at ${this.config.apiUrl.replace('reporter.44pixels.workers.dev', 'reporter-directory.pages.dev')} → Settings, then update REPORTER_API_KEY in your Claude config and restart Claude Code.`);
|
|
32
|
+
}
|
|
33
|
+
throw new Error(message);
|
|
20
34
|
}
|
|
21
35
|
return response.json();
|
|
22
36
|
}
|
|
@@ -98,15 +112,20 @@ export class ApiClient {
|
|
|
98
112
|
params.set(key, String(value));
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
|
-
|
|
102
|
-
return this.request(`/api/products${query ? `?${query}` : ''}`);
|
|
115
|
+
return this.request(withQuery('/api/products', params));
|
|
103
116
|
}
|
|
104
117
|
async getProduct(slug, domain) {
|
|
105
|
-
const
|
|
106
|
-
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
appendDomains(params, domain);
|
|
120
|
+
return this.request(withQuery(`/api/products/${slug}`, params));
|
|
107
121
|
}
|
|
108
|
-
async getProductFile(slug, filepath) {
|
|
109
|
-
|
|
122
|
+
async getProductFile(slug, filepath, options) {
|
|
123
|
+
const params = new URLSearchParams();
|
|
124
|
+
if (options?.format && options.format !== 'inline')
|
|
125
|
+
params.set('format', options.format);
|
|
126
|
+
if (options?.ttl !== undefined)
|
|
127
|
+
params.set('ttl', String(options.ttl));
|
|
128
|
+
return this.request(withQuery(`/api/products/${slug}/files/${filepath}`, params));
|
|
110
129
|
}
|
|
111
130
|
async updateProductFile(slug, filepath, body) {
|
|
112
131
|
return this.request(`/api/products/${slug}/files/${filepath}`, {
|
|
@@ -143,8 +162,9 @@ export class ApiClient {
|
|
|
143
162
|
});
|
|
144
163
|
}
|
|
145
164
|
async getProductIndex(slug, domain) {
|
|
146
|
-
const
|
|
147
|
-
|
|
165
|
+
const params = new URLSearchParams();
|
|
166
|
+
appendDomains(params, domain);
|
|
167
|
+
return this.request(withQuery(`/api/products/${slug}/index`, params));
|
|
148
168
|
}
|
|
149
169
|
async pullProductFiles(slug, options) {
|
|
150
170
|
return this.request(`/api/products/${slug}/pull`, {
|
package/dist/index.js
CHANGED
|
@@ -9,9 +9,17 @@ import { listReportsSchema, getReportSchema, deleteReportSchema, createListTool,
|
|
|
9
9
|
import { pullContextSchema, createPullTool, } from './tools/pull.js';
|
|
10
10
|
import { manageFoldersSchema, createManageFoldersTool, } from './tools/folders.js';
|
|
11
11
|
import { listProductsSchema, getProductSchema, readProductFileSchema, updateProductKnowledgeSchema, uploadProductFilesSchema, pullProductFilesSchema, createProductSchema, deleteProductSchema, deleteProductFileSchema, getProductHealthSchema, getProductIndexSchema, createListProductsTool, createGetProductTool, createReadProductFileTool, createUpdateProductKnowledgeTool, createUploadProductFilesTool, createPullProductFilesTool, createGetProductHealthTool, createGetProductIndexTool, createCreateProductTool, createDeleteProductTool, createDeleteProductFileTool, } from './tools/products.js';
|
|
12
|
+
import { createUploadProductDirectoryTool, uploadProductDirectorySchema, UPLOAD_DIRECTORY_DESCRIPTION, } from './tools/upload-directory.js';
|
|
12
13
|
import { addToBacklogSchema, listBacklogSchema, processBacklogSchema, createAddToBacklogTool, createListBacklogTool, createProcessBacklogTool, } from './tools/backlog.js';
|
|
14
|
+
import { DOMAIN_VALUES } from './tools/domains.js';
|
|
13
15
|
const config = loadConfig();
|
|
14
16
|
const client = new ApiClient(config);
|
|
17
|
+
const DOMAIN_FILTER_SCHEMA = {
|
|
18
|
+
oneOf: [
|
|
19
|
+
{ type: 'string', enum: DOMAIN_VALUES },
|
|
20
|
+
{ type: 'array', items: { type: 'string', enum: DOMAIN_VALUES } },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
15
23
|
function prop(type, description, extra = {}) {
|
|
16
24
|
return { type, description, ...extra };
|
|
17
25
|
}
|
|
@@ -157,7 +165,7 @@ const toolDefinitions = [
|
|
|
157
165
|
type: 'object',
|
|
158
166
|
properties: {
|
|
159
167
|
slug: prop('string', 'Product slug'),
|
|
160
|
-
domain:
|
|
168
|
+
domain: { ...DOMAIN_FILTER_SCHEMA, description: 'Filter files by domain. Single domain or array — pass an array (e.g. ["brand", "advertising"]) to combine domains in one request.' },
|
|
161
169
|
},
|
|
162
170
|
required: ['slug'],
|
|
163
171
|
},
|
|
@@ -166,12 +174,14 @@ const toolDefinitions = [
|
|
|
166
174
|
},
|
|
167
175
|
{
|
|
168
176
|
name: 'read_product_file',
|
|
169
|
-
description: 'Read a single file from a product by path. Use get_product to read all knowledge files at once (more efficient). Use read_product_file only when you need one specific file — works for markdown docs, JSON configs, and binary files (logos, PDFs
|
|
177
|
+
description: 'Read a single file from a product by path. Use get_product to read all knowledge files at once (more efficient). Use read_product_file only when you need one specific file — works for markdown docs, JSON configs, and binary files.\n\nFor binary files (logos, screenshots, PDFs): pass `format: "signed-url"` to get a short-lived download URL instead of inline base64. Base64 inflates LLM context by ~33% and round-trips through your prompt; signed URLs let you `curl -o <path> <url>` directly to disk.\n\nTypical workflow: get_product_index → find the file you need → read_product_file (use signed-url for binaries).',
|
|
170
178
|
inputSchema: {
|
|
171
179
|
type: 'object',
|
|
172
180
|
properties: {
|
|
173
181
|
slug: prop('string', 'Product slug'),
|
|
174
182
|
filepath: prop('string', 'File path within the product (e.g., "docs/research/user-research.md", "meta-ads.json", "brand-assets/logo.png")'),
|
|
183
|
+
format: { type: 'string', enum: ['inline', 'signed-url'], description: 'Response format. Default "inline" returns content directly. Use "signed-url" for binaries to avoid base64 context bloat — response will include a `url` field instead of `content`.' },
|
|
184
|
+
ttl: { type: 'number', description: 'Signed URL lifetime in seconds (default 600, max 3600). Ignored unless format="signed-url".' },
|
|
175
185
|
},
|
|
176
186
|
required: ['slug', 'filepath'],
|
|
177
187
|
},
|
|
@@ -196,7 +206,12 @@ const toolDefinitions = [
|
|
|
196
206
|
},
|
|
197
207
|
{
|
|
198
208
|
name: 'upload_product_files',
|
|
199
|
-
description: 'Upload files to a product
|
|
209
|
+
description: 'Upload one or more files to a product. For multiple files in a directory, prefer ' +
|
|
210
|
+
'upload_product_directory.\n\n' +
|
|
211
|
+
'Place files at media/<domain>/ for binaries, docs/<domain>/ for markdown:\n' +
|
|
212
|
+
' media/brand/, media/product-research/, media/copy/, media/advertising/, media/app-store/\n' +
|
|
213
|
+
' docs/brand/, docs/research/, docs/copy/, docs/advertising/, docs/app-store/\n\n' +
|
|
214
|
+
'Pass content as a LOCAL PATH (preferred), a base64 data URI, or plain text.',
|
|
200
215
|
inputSchema: {
|
|
201
216
|
type: 'object',
|
|
202
217
|
properties: {
|
|
@@ -218,6 +233,31 @@ const toolDefinitions = [
|
|
|
218
233
|
schema: uploadProductFilesSchema,
|
|
219
234
|
handler: createUploadProductFilesTool(client),
|
|
220
235
|
},
|
|
236
|
+
{
|
|
237
|
+
name: 'upload_product_directory',
|
|
238
|
+
description: UPLOAD_DIRECTORY_DESCRIPTION,
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: {
|
|
242
|
+
slug: prop('string', 'Product slug'),
|
|
243
|
+
local_dir: prop('string', 'Absolute or relative path to the local directory to upload'),
|
|
244
|
+
domain: {
|
|
245
|
+
type: 'string',
|
|
246
|
+
enum: ['product-research', 'brand', 'copy', 'advertising', 'app-store'],
|
|
247
|
+
description: 'Product domain — determines target folder (media/<domain>/)',
|
|
248
|
+
},
|
|
249
|
+
dest_prefix: prop('string', 'Subpath under media/<domain>/. Defaults to basename of local_dir.'),
|
|
250
|
+
glob: prop('string', 'Include filter, e.g. "**/*.{png,jpg,mp4}". Default: "**/*".'),
|
|
251
|
+
exclude: prop('array', 'Exclude patterns. Default: [".*", "**/.*", "node_modules/**"].', {
|
|
252
|
+
items: { type: 'string' },
|
|
253
|
+
}),
|
|
254
|
+
dry_run: prop('boolean', 'If true, returns the planned uploads without sending. Default: false.'),
|
|
255
|
+
},
|
|
256
|
+
required: ['slug', 'local_dir', 'domain'],
|
|
257
|
+
},
|
|
258
|
+
schema: uploadProductDirectorySchema,
|
|
259
|
+
handler: createUploadProductDirectoryTool(client),
|
|
260
|
+
},
|
|
221
261
|
{
|
|
222
262
|
name: 'pull_product_files',
|
|
223
263
|
description: 'Download files from a product to a local directory. By default pulls binary assets (logos, screenshots, videos). Set include_knowledge to also pull JSON config files. To pull markdown docs, specify their paths in the files array (e.g., ["docs/research/user-research.md"]).',
|
|
@@ -253,7 +293,7 @@ const toolDefinitions = [
|
|
|
253
293
|
type: 'object',
|
|
254
294
|
properties: {
|
|
255
295
|
slug: prop('string', 'Product slug'),
|
|
256
|
-
domain:
|
|
296
|
+
domain: { ...DOMAIN_FILTER_SCHEMA, description: 'Filter index entries by domain. Single domain or array.' },
|
|
257
297
|
},
|
|
258
298
|
required: ['slug'],
|
|
259
299
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DOMAIN_VALUES = ['product-research', 'brand', 'copy', 'advertising', 'app-store'];
|
package/dist/tools/products.d.ts
CHANGED
|
@@ -9,23 +9,29 @@ export declare const listProductsSchema: z.ZodObject<{
|
|
|
9
9
|
}>;
|
|
10
10
|
export declare const getProductSchema: z.ZodObject<{
|
|
11
11
|
slug: z.ZodString;
|
|
12
|
-
domain: z.ZodOptional<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>>;
|
|
12
|
+
domain: z.ZodOptional<z.ZodUnion<[z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>, z.ZodArray<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>, "many">]>>;
|
|
13
13
|
}, "strip", z.ZodTypeAny, {
|
|
14
14
|
slug: string;
|
|
15
|
-
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
15
|
+
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | ("product-research" | "brand" | "copy" | "advertising" | "app-store")[] | undefined;
|
|
16
16
|
}, {
|
|
17
17
|
slug: string;
|
|
18
|
-
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
18
|
+
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | ("product-research" | "brand" | "copy" | "advertising" | "app-store")[] | undefined;
|
|
19
19
|
}>;
|
|
20
20
|
export declare const readProductFileSchema: z.ZodObject<{
|
|
21
21
|
slug: z.ZodString;
|
|
22
22
|
filepath: z.ZodString;
|
|
23
|
+
format: z.ZodOptional<z.ZodEnum<["inline", "signed-url"]>>;
|
|
24
|
+
ttl: z.ZodOptional<z.ZodNumber>;
|
|
23
25
|
}, "strip", z.ZodTypeAny, {
|
|
24
26
|
slug: string;
|
|
25
27
|
filepath: string;
|
|
28
|
+
format?: "inline" | "signed-url" | undefined;
|
|
29
|
+
ttl?: number | undefined;
|
|
26
30
|
}, {
|
|
27
31
|
slug: string;
|
|
28
32
|
filepath: string;
|
|
33
|
+
format?: "inline" | "signed-url" | undefined;
|
|
34
|
+
ttl?: number | undefined;
|
|
29
35
|
}>;
|
|
30
36
|
export declare const updateProductKnowledgeSchema: z.ZodObject<{
|
|
31
37
|
slug: z.ZodString;
|
|
@@ -91,13 +97,13 @@ export declare const pullProductFilesSchema: z.ZodObject<{
|
|
|
91
97
|
}>;
|
|
92
98
|
export declare const getProductIndexSchema: z.ZodObject<{
|
|
93
99
|
slug: z.ZodString;
|
|
94
|
-
domain: z.ZodOptional<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>>;
|
|
100
|
+
domain: z.ZodOptional<z.ZodUnion<[z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>, z.ZodArray<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>, "many">]>>;
|
|
95
101
|
}, "strip", z.ZodTypeAny, {
|
|
96
102
|
slug: string;
|
|
97
|
-
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
103
|
+
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | ("product-research" | "brand" | "copy" | "advertising" | "app-store")[] | undefined;
|
|
98
104
|
}, {
|
|
99
105
|
slug: string;
|
|
100
|
-
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
106
|
+
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | ("product-research" | "brand" | "copy" | "advertising" | "app-store")[] | undefined;
|
|
101
107
|
}>;
|
|
102
108
|
export declare const getProductHealthSchema: z.ZodObject<{
|
|
103
109
|
slug: z.ZodOptional<z.ZodString>;
|
|
@@ -174,6 +180,12 @@ export declare function createReadProductFileTool(client: ApiClient): (input: z.
|
|
|
174
180
|
content: unknown;
|
|
175
181
|
content_type: string;
|
|
176
182
|
size?: number;
|
|
183
|
+
} | {
|
|
184
|
+
path: string;
|
|
185
|
+
url: string;
|
|
186
|
+
expires_at: string;
|
|
187
|
+
content_type: string;
|
|
188
|
+
size: number;
|
|
177
189
|
}>;
|
|
178
190
|
export declare function createUpdateProductKnowledgeTool(client: ApiClient): (input: z.infer<typeof updateProductKnowledgeSchema>) => Promise<{
|
|
179
191
|
success: boolean;
|
package/dist/tools/products.js
CHANGED
|
@@ -2,18 +2,32 @@ import { z } from 'zod';
|
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { getMimeType } from './deploy.js';
|
|
5
|
+
import { DOMAIN_VALUES } from './domains.js';
|
|
5
6
|
// --- Schemas ---
|
|
6
7
|
export const listProductsSchema = z.object({
|
|
7
8
|
search: z.string().optional().describe('Search term to filter products by name or description'),
|
|
8
9
|
});
|
|
9
|
-
const DOMAIN_VALUES = ['product-research', 'brand', 'copy', 'advertising', 'app-store'];
|
|
10
10
|
export const getProductSchema = z.object({
|
|
11
11
|
slug: z.string().describe('Product slug'),
|
|
12
|
-
domain: z
|
|
12
|
+
domain: z
|
|
13
|
+
.union([z.enum(DOMAIN_VALUES), z.array(z.enum(DOMAIN_VALUES))])
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('Filter files by domain. Pass a single domain ("brand") or an array (["brand", "advertising"]) to combine domains in one request — useful for creative pipelines that need brand assets plus advertising references without round-tripping operational config.'),
|
|
13
16
|
});
|
|
14
17
|
export const readProductFileSchema = z.object({
|
|
15
18
|
slug: z.string().describe('Product slug'),
|
|
16
19
|
filepath: z.string().describe('File path within the product (e.g., "docs/research/user-research.md", "meta-ads.json", "brand-assets/logo.png")'),
|
|
20
|
+
format: z
|
|
21
|
+
.enum(['inline', 'signed-url'])
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Response format. "inline" (default) returns the file content (text or base64 data URI for binary). "signed-url" returns a short-lived download URL — strongly preferred for binaries (logos, screenshots, PDFs) since base64 inflates LLM context by ~33%. Fetch with `curl -o <path> <url>`.'),
|
|
24
|
+
ttl: z
|
|
25
|
+
.number()
|
|
26
|
+
.int()
|
|
27
|
+
.positive()
|
|
28
|
+
.max(3600)
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Lifetime in seconds for the signed URL (default 600, max 3600). Ignored unless format="signed-url".'),
|
|
17
31
|
});
|
|
18
32
|
export const updateProductKnowledgeSchema = z.object({
|
|
19
33
|
slug: z.string().describe('Product slug'),
|
|
@@ -25,7 +39,13 @@ export const uploadProductFilesSchema = z.object({
|
|
|
25
39
|
slug: z.string().describe('Product slug'),
|
|
26
40
|
files: z.array(z.object({
|
|
27
41
|
path: z.string().describe('File path within the product (e.g., "docs/research/user-research.md", "brand-assets/logo.png")'),
|
|
28
|
-
content: z
|
|
42
|
+
content: z
|
|
43
|
+
.string()
|
|
44
|
+
.describe('File content. Three input modes:\n' +
|
|
45
|
+
' • LOCAL PATH (preferred): "/abs/path/to/logo.png" or "./relative/path"\n' +
|
|
46
|
+
' • Base64 data URI: "data:image/png;base64,..."\n' +
|
|
47
|
+
' • Plain text (for small text files)\n' +
|
|
48
|
+
'Use a local path whenever possible — base64 inline blows up your context for any non-tiny file.'),
|
|
29
49
|
content_type: z.string().optional().describe('MIME type (auto-detected if not provided)'),
|
|
30
50
|
})).describe('Files to upload'),
|
|
31
51
|
});
|
|
@@ -37,7 +57,10 @@ export const pullProductFilesSchema = z.object({
|
|
|
37
57
|
});
|
|
38
58
|
export const getProductIndexSchema = z.object({
|
|
39
59
|
slug: z.string().describe('Product slug'),
|
|
40
|
-
domain: z
|
|
60
|
+
domain: z
|
|
61
|
+
.union([z.enum(DOMAIN_VALUES), z.array(z.enum(DOMAIN_VALUES))])
|
|
62
|
+
.optional()
|
|
63
|
+
.describe('Filter index entries by domain. Pass a single domain or array of domains.'),
|
|
41
64
|
});
|
|
42
65
|
export const getProductHealthSchema = z.object({
|
|
43
66
|
slug: z.string().optional().describe('Product slug. If omitted, returns health for all products.'),
|
|
@@ -69,7 +92,10 @@ export function createGetProductTool(client) {
|
|
|
69
92
|
}
|
|
70
93
|
export function createReadProductFileTool(client) {
|
|
71
94
|
return async (input) => {
|
|
72
|
-
const result = await client.getProductFile(input.slug, input.filepath
|
|
95
|
+
const result = await client.getProductFile(input.slug, input.filepath, {
|
|
96
|
+
format: input.format,
|
|
97
|
+
ttl: input.ttl,
|
|
98
|
+
});
|
|
73
99
|
return result;
|
|
74
100
|
};
|
|
75
101
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ApiClient } from '../api-client.js';
|
|
3
|
+
export declare const uploadProductDirectorySchema: z.ZodObject<{
|
|
4
|
+
slug: z.ZodString;
|
|
5
|
+
local_dir: z.ZodString;
|
|
6
|
+
domain: z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>;
|
|
7
|
+
dest_prefix: z.ZodOptional<z.ZodString>;
|
|
8
|
+
glob: z.ZodOptional<z.ZodString>;
|
|
9
|
+
exclude: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
10
|
+
dry_run: z.ZodOptional<z.ZodBoolean>;
|
|
11
|
+
}, "strip", z.ZodTypeAny, {
|
|
12
|
+
domain: "product-research" | "brand" | "copy" | "advertising" | "app-store";
|
|
13
|
+
slug: string;
|
|
14
|
+
local_dir: string;
|
|
15
|
+
dry_run?: boolean | undefined;
|
|
16
|
+
dest_prefix?: string | undefined;
|
|
17
|
+
glob?: string | undefined;
|
|
18
|
+
exclude?: string[] | undefined;
|
|
19
|
+
}, {
|
|
20
|
+
domain: "product-research" | "brand" | "copy" | "advertising" | "app-store";
|
|
21
|
+
slug: string;
|
|
22
|
+
local_dir: string;
|
|
23
|
+
dry_run?: boolean | undefined;
|
|
24
|
+
dest_prefix?: string | undefined;
|
|
25
|
+
glob?: string | undefined;
|
|
26
|
+
exclude?: string[] | undefined;
|
|
27
|
+
}>;
|
|
28
|
+
export declare const UPLOAD_DIRECTORY_DESCRIPTION: string;
|
|
29
|
+
export interface FileEntry {
|
|
30
|
+
localPath: string;
|
|
31
|
+
relativePath: string;
|
|
32
|
+
size: number;
|
|
33
|
+
}
|
|
34
|
+
export interface DestinationEntry extends FileEntry {
|
|
35
|
+
destPath: string;
|
|
36
|
+
contentType: string;
|
|
37
|
+
}
|
|
38
|
+
export interface WalkOptions {
|
|
39
|
+
glob: string;
|
|
40
|
+
exclude: string[];
|
|
41
|
+
}
|
|
42
|
+
export declare const DEFAULT_EXCLUDES: string[];
|
|
43
|
+
export declare function walkDirectory(absDir: string, options: WalkOptions): Promise<FileEntry[]>;
|
|
44
|
+
export interface DestinationOptions {
|
|
45
|
+
domain: string;
|
|
46
|
+
destPrefix: string;
|
|
47
|
+
}
|
|
48
|
+
export declare function computeDestinations(entries: FileEntry[], options: DestinationOptions): DestinationEntry[];
|
|
49
|
+
export interface Sized {
|
|
50
|
+
size: number;
|
|
51
|
+
}
|
|
52
|
+
export interface BatchLimits {
|
|
53
|
+
maxFiles: number;
|
|
54
|
+
maxBytes: number;
|
|
55
|
+
}
|
|
56
|
+
export declare function batchByLimits<T extends Sized>(items: T[], limits: BatchLimits): T[][];
|
|
57
|
+
export declare function createUploadProductDirectoryTool(client: ApiClient): (input: z.infer<typeof uploadProductDirectorySchema>) => Promise<{
|
|
58
|
+
dry_run: boolean;
|
|
59
|
+
total_files: number;
|
|
60
|
+
total_bytes: number;
|
|
61
|
+
files: {
|
|
62
|
+
local_path: string;
|
|
63
|
+
dest_path: string;
|
|
64
|
+
size: number;
|
|
65
|
+
content_type: string;
|
|
66
|
+
}[];
|
|
67
|
+
skipped_too_large: {
|
|
68
|
+
local_path: string;
|
|
69
|
+
size: number;
|
|
70
|
+
limit: number;
|
|
71
|
+
}[];
|
|
72
|
+
uploaded?: undefined;
|
|
73
|
+
failed?: undefined;
|
|
74
|
+
} | {
|
|
75
|
+
dry_run: boolean;
|
|
76
|
+
total_files: number;
|
|
77
|
+
total_bytes: number;
|
|
78
|
+
uploaded: {
|
|
79
|
+
local_path: string;
|
|
80
|
+
dest_path: string;
|
|
81
|
+
size: number;
|
|
82
|
+
content_type: string;
|
|
83
|
+
}[];
|
|
84
|
+
failed: {
|
|
85
|
+
local_path: string;
|
|
86
|
+
dest_path: string;
|
|
87
|
+
error: string;
|
|
88
|
+
}[];
|
|
89
|
+
files?: undefined;
|
|
90
|
+
skipped_too_large?: undefined;
|
|
91
|
+
}>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { minimatch } from 'minimatch';
|
|
6
|
+
import { getMimeType } from './deploy.js';
|
|
7
|
+
import { DOMAIN_VALUES } from './domains.js';
|
|
8
|
+
function expandHome(p) {
|
|
9
|
+
if (p === '~')
|
|
10
|
+
return os.homedir();
|
|
11
|
+
if (p.startsWith('~/'))
|
|
12
|
+
return path.join(os.homedir(), p.slice(2));
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
export const uploadProductDirectorySchema = z.object({
|
|
16
|
+
slug: z.string().describe('Product slug, e.g. "clara"'),
|
|
17
|
+
local_dir: z.string().describe('Absolute or relative path to the local directory to upload. ~/ is expanded to the home directory.'),
|
|
18
|
+
domain: z.enum(DOMAIN_VALUES).describe('Product domain — determines target folder (media/<domain>/)'),
|
|
19
|
+
dest_prefix: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe('Subpath under media/<domain>/. Defaults to the basename of local_dir.'),
|
|
23
|
+
glob: z.string().optional().describe('Include filter, e.g. "**/*.{png,jpg,mp4}". Default: "**/*".'),
|
|
24
|
+
exclude: z
|
|
25
|
+
.array(z.string())
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('Exclude patterns. Default: [".*", "**/.*", "node_modules/**"].'),
|
|
28
|
+
dry_run: z.boolean().optional().describe('If true, return the planned uploads without sending. Default: false.'),
|
|
29
|
+
});
|
|
30
|
+
export const UPLOAD_DIRECTORY_DESCRIPTION = 'Upload an entire directory of files (images, videos, PDFs) into a product\'s media folder. ' +
|
|
31
|
+
'Preferred over upload_product_files when you have multiple files on disk.\n\n' +
|
|
32
|
+
'Files land at media/<domain>/<dest_prefix>/<relative-path>. dest_prefix defaults to the basename of local_dir.\n\n' +
|
|
33
|
+
'Example: upload_product_directory({slug:"clara", local_dir:"./screenshots", domain:"product-research", dest_prefix:"ux-screenshots"}) ' +
|
|
34
|
+
'→ files at clara/media/product-research/ux-screenshots/*\n\n' +
|
|
35
|
+
'Limits: 10MB per file (oversized files are reported as failed, not blocking). Auto-batches into chunks ≤90MB to stay under server limits. Total upload size is unbounded.';
|
|
36
|
+
export const DEFAULT_EXCLUDES = ['.*', '**/.*', 'node_modules/**'];
|
|
37
|
+
export async function walkDirectory(absDir, options) {
|
|
38
|
+
const entries = [];
|
|
39
|
+
async function recurse(currentAbs, currentRel) {
|
|
40
|
+
const items = await fs.readdir(currentAbs, { withFileTypes: true });
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
const itemAbs = path.join(currentAbs, item.name);
|
|
43
|
+
const itemRel = currentRel ? `${currentRel}/${item.name}` : item.name;
|
|
44
|
+
if (options.exclude.some((pattern) => minimatch(itemRel, pattern, { dot: true })))
|
|
45
|
+
continue;
|
|
46
|
+
if (item.isDirectory()) {
|
|
47
|
+
await recurse(itemAbs, itemRel);
|
|
48
|
+
}
|
|
49
|
+
else if (item.isFile()) {
|
|
50
|
+
if (!minimatch(itemRel, options.glob, { dot: true }))
|
|
51
|
+
continue;
|
|
52
|
+
const stat = await fs.stat(itemAbs);
|
|
53
|
+
entries.push({ localPath: itemAbs, relativePath: itemRel, size: stat.size });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await recurse(absDir, '');
|
|
58
|
+
return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
59
|
+
}
|
|
60
|
+
export function computeDestinations(entries, options) {
|
|
61
|
+
const prefix = options.destPrefix
|
|
62
|
+
? `media/${options.domain}/${options.destPrefix}`
|
|
63
|
+
: `media/${options.domain}`;
|
|
64
|
+
return entries.map((e) => {
|
|
65
|
+
if (e.relativePath.split('/').includes('..')) {
|
|
66
|
+
throw new Error(`refusing to compute destination for path containing "..": ${e.relativePath}`);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...e,
|
|
70
|
+
destPath: `${prefix}/${e.relativePath}`,
|
|
71
|
+
contentType: getMimeType(e.localPath),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function batchByLimits(items, limits) {
|
|
76
|
+
const batches = [];
|
|
77
|
+
let currentBatch = [];
|
|
78
|
+
let currentBytes = 0;
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
if (currentBatch.length >= limits.maxFiles ||
|
|
81
|
+
(currentBatch.length > 0 && currentBytes + item.size > limits.maxBytes)) {
|
|
82
|
+
batches.push(currentBatch);
|
|
83
|
+
currentBatch = [];
|
|
84
|
+
currentBytes = 0;
|
|
85
|
+
}
|
|
86
|
+
currentBatch.push(item);
|
|
87
|
+
currentBytes += item.size;
|
|
88
|
+
}
|
|
89
|
+
if (currentBatch.length > 0)
|
|
90
|
+
batches.push(currentBatch);
|
|
91
|
+
return batches;
|
|
92
|
+
}
|
|
93
|
+
const PER_FILE_LIMIT = 10 * 1024 * 1024;
|
|
94
|
+
const BATCH_BYTES = 90 * 1024 * 1024;
|
|
95
|
+
const BATCH_FILES = 95;
|
|
96
|
+
export function createUploadProductDirectoryTool(client) {
|
|
97
|
+
return async (input) => {
|
|
98
|
+
const absDir = path.resolve(expandHome(input.local_dir));
|
|
99
|
+
const stat = await fs.stat(absDir).catch(() => null);
|
|
100
|
+
if (!stat || !stat.isDirectory()) {
|
|
101
|
+
throw new Error(`local_dir not found or not a directory: ${absDir}`);
|
|
102
|
+
}
|
|
103
|
+
const destPrefix = input.dest_prefix ?? path.basename(absDir);
|
|
104
|
+
const glob = input.glob ?? '**/*';
|
|
105
|
+
const exclude = input.exclude ?? DEFAULT_EXCLUDES;
|
|
106
|
+
const entries = await walkDirectory(absDir, { glob, exclude });
|
|
107
|
+
const destinations = computeDestinations(entries, { domain: input.domain, destPrefix });
|
|
108
|
+
const oversized = destinations.filter((d) => d.size > PER_FILE_LIMIT);
|
|
109
|
+
const sized = destinations.filter((d) => d.size <= PER_FILE_LIMIT);
|
|
110
|
+
const totalBytes = sized.reduce((sum, d) => sum + d.size, 0);
|
|
111
|
+
if (input.dry_run) {
|
|
112
|
+
return {
|
|
113
|
+
dry_run: true,
|
|
114
|
+
total_files: destinations.length,
|
|
115
|
+
total_bytes: totalBytes,
|
|
116
|
+
files: sized.map((d) => ({
|
|
117
|
+
local_path: d.localPath,
|
|
118
|
+
dest_path: d.destPath,
|
|
119
|
+
size: d.size,
|
|
120
|
+
content_type: d.contentType,
|
|
121
|
+
})),
|
|
122
|
+
skipped_too_large: oversized.map((d) => ({
|
|
123
|
+
local_path: d.localPath,
|
|
124
|
+
size: d.size,
|
|
125
|
+
limit: PER_FILE_LIMIT,
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const batches = batchByLimits(sized, { maxFiles: BATCH_FILES, maxBytes: BATCH_BYTES });
|
|
130
|
+
const uploaded = [];
|
|
131
|
+
const failed = oversized.map((d) => ({
|
|
132
|
+
local_path: d.localPath,
|
|
133
|
+
dest_path: d.destPath,
|
|
134
|
+
error: `exceeds per-file limit (${(d.size / 1024 / 1024).toFixed(1)}MB > ${PER_FILE_LIMIT / 1024 / 1024}MB)`,
|
|
135
|
+
}));
|
|
136
|
+
for (const batch of batches) {
|
|
137
|
+
const reads = await Promise.all(batch.map(async (d) => {
|
|
138
|
+
try {
|
|
139
|
+
const base64 = await fs.readFile(d.localPath, { encoding: 'base64' });
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
dest: d,
|
|
143
|
+
file: {
|
|
144
|
+
path: d.destPath,
|
|
145
|
+
content: `data:${d.contentType};base64,${base64}`,
|
|
146
|
+
content_type: d.contentType,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
dest: d,
|
|
154
|
+
error: e instanceof Error ? e.message : String(e),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}));
|
|
158
|
+
for (const r of reads) {
|
|
159
|
+
if (!r.ok)
|
|
160
|
+
failed.push({ local_path: r.dest.localPath, dest_path: r.dest.destPath, error: r.error });
|
|
161
|
+
}
|
|
162
|
+
const successfulReads = reads.filter((r) => r.ok);
|
|
163
|
+
if (successfulReads.length === 0)
|
|
164
|
+
continue;
|
|
165
|
+
try {
|
|
166
|
+
await client.uploadProductFiles(input.slug, successfulReads.map((r) => r.file));
|
|
167
|
+
for (const r of successfulReads) {
|
|
168
|
+
uploaded.push({
|
|
169
|
+
local_path: r.dest.localPath,
|
|
170
|
+
dest_path: r.dest.destPath,
|
|
171
|
+
size: r.dest.size,
|
|
172
|
+
content_type: r.dest.contentType,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
178
|
+
for (const r of successfulReads) {
|
|
179
|
+
failed.push({ local_path: r.dest.localPath, dest_path: r.dest.destPath, error });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
dry_run: false,
|
|
185
|
+
total_files: destinations.length,
|
|
186
|
+
total_bytes: totalBytes,
|
|
187
|
+
uploaded,
|
|
188
|
+
failed,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import { walkDirectory, computeDestinations, batchByLimits, } from './upload-directory.js';
|
|
5
|
+
async function makeTempDir(structure) {
|
|
6
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'upload-test-'));
|
|
7
|
+
for (const [rel, content] of Object.entries(structure)) {
|
|
8
|
+
const abs = path.join(dir, rel);
|
|
9
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
10
|
+
await fs.writeFile(abs, content);
|
|
11
|
+
}
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
describe('walkDirectory', () => {
|
|
15
|
+
it('returns absolute paths and sizes for regular files', async () => {
|
|
16
|
+
const dir = await makeTempDir({
|
|
17
|
+
'a.txt': 'hello',
|
|
18
|
+
'sub/b.png': Buffer.alloc(10),
|
|
19
|
+
});
|
|
20
|
+
const entries = await walkDirectory(dir, { glob: '**/*', exclude: [] });
|
|
21
|
+
expect(entries).toHaveLength(2);
|
|
22
|
+
const a = entries.find((e) => e.localPath.endsWith('a.txt'));
|
|
23
|
+
expect(a.size).toBe(5);
|
|
24
|
+
expect(a.relativePath).toBe('a.txt');
|
|
25
|
+
const b = entries.find((e) => e.localPath.endsWith('b.png'));
|
|
26
|
+
expect(b.size).toBe(10);
|
|
27
|
+
expect(b.relativePath).toBe('sub/b.png');
|
|
28
|
+
});
|
|
29
|
+
it('applies glob filtering', async () => {
|
|
30
|
+
const dir = await makeTempDir({
|
|
31
|
+
'logo.png': Buffer.alloc(1),
|
|
32
|
+
'README.md': 'doc',
|
|
33
|
+
'sub/icon.svg': '<svg/>',
|
|
34
|
+
});
|
|
35
|
+
const entries = await walkDirectory(dir, { glob: '**/*.{png,svg}', exclude: [] });
|
|
36
|
+
expect(entries.map((e) => e.relativePath).sort()).toEqual(['logo.png', 'sub/icon.svg']);
|
|
37
|
+
});
|
|
38
|
+
it('applies excludes', async () => {
|
|
39
|
+
const dir = await makeTempDir({
|
|
40
|
+
'logo.png': Buffer.alloc(1),
|
|
41
|
+
'.DS_Store': Buffer.alloc(1),
|
|
42
|
+
'node_modules/x.js': '',
|
|
43
|
+
});
|
|
44
|
+
const entries = await walkDirectory(dir, {
|
|
45
|
+
glob: '**/*',
|
|
46
|
+
exclude: ['.*', '**/.*', 'node_modules/**'],
|
|
47
|
+
});
|
|
48
|
+
expect(entries.map((e) => e.relativePath)).toEqual(['logo.png']);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('computeDestinations', () => {
|
|
52
|
+
const entry = (relativePath, size = 1) => ({
|
|
53
|
+
localPath: '/tmp/x/' + relativePath,
|
|
54
|
+
relativePath,
|
|
55
|
+
size,
|
|
56
|
+
});
|
|
57
|
+
it('places files under media/<domain>/<destPrefix>/', () => {
|
|
58
|
+
const dest = computeDestinations([entry('logo.png'), entry('sub/icon.svg')], { domain: 'brand', destPrefix: 'logos' });
|
|
59
|
+
expect(dest.map((d) => d.destPath)).toEqual([
|
|
60
|
+
'media/brand/logos/logo.png',
|
|
61
|
+
'media/brand/logos/sub/icon.svg',
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
it('omits destPrefix when empty', () => {
|
|
65
|
+
const dest = computeDestinations([entry('logo.png')], { domain: 'brand', destPrefix: '' });
|
|
66
|
+
expect(dest[0].destPath).toBe('media/brand/logo.png');
|
|
67
|
+
});
|
|
68
|
+
it('attaches mime type via getMimeType', () => {
|
|
69
|
+
const dest = computeDestinations([entry('logo.png')], { domain: 'brand', destPrefix: '' });
|
|
70
|
+
expect(dest[0].contentType).toBe('image/png');
|
|
71
|
+
});
|
|
72
|
+
it('rejects relative paths containing ..', () => {
|
|
73
|
+
expect(() => computeDestinations([entry('../escape.png')], { domain: 'brand', destPrefix: '' })).toThrow(/containing ".."/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('batchByLimits', () => {
|
|
77
|
+
const item = (size) => ({ size, dummy: true });
|
|
78
|
+
it('groups items so each batch is <= maxFiles and <= maxBytes', () => {
|
|
79
|
+
const items = [item(40 * 1024 * 1024), item(40 * 1024 * 1024), item(20 * 1024 * 1024)];
|
|
80
|
+
const batches = batchByLimits(items, { maxFiles: 100, maxBytes: 90 * 1024 * 1024 });
|
|
81
|
+
expect(batches.length).toBe(2);
|
|
82
|
+
expect(batches[0].length).toBe(2);
|
|
83
|
+
expect(batches[1].length).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
it('starts a new batch when adding the next file would exceed maxBytes', () => {
|
|
86
|
+
const items = [item(80 * 1024 * 1024), item(20 * 1024 * 1024)];
|
|
87
|
+
const batches = batchByLimits(items, { maxFiles: 100, maxBytes: 90 * 1024 * 1024 });
|
|
88
|
+
expect(batches.length).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
it('respects maxFiles even with small files', () => {
|
|
91
|
+
const items = Array.from({ length: 200 }, () => item(1024));
|
|
92
|
+
const batches = batchByLimits(items, { maxFiles: 95, maxBytes: 90 * 1024 * 1024 });
|
|
93
|
+
expect(batches.length).toBe(3);
|
|
94
|
+
expect(batches[0].length).toBe(95);
|
|
95
|
+
expect(batches[1].length).toBe(95);
|
|
96
|
+
expect(batches[2].length).toBe(10);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
import { createUploadProductDirectoryTool } from './upload-directory.js';
|
|
100
|
+
function makeClient(shouldFail = false) {
|
|
101
|
+
const calls = [];
|
|
102
|
+
return {
|
|
103
|
+
calls,
|
|
104
|
+
shouldFail,
|
|
105
|
+
uploadProductFiles: async (slug, files) => {
|
|
106
|
+
calls.push({ slug, files: files.map((f) => ({ path: f.path, content_type: f.content_type })) });
|
|
107
|
+
if (shouldFail)
|
|
108
|
+
throw new Error('simulated server failure');
|
|
109
|
+
return { uploaded: files.map((f) => f.path) };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
describe('createUploadProductDirectoryTool — dry_run', () => {
|
|
114
|
+
it('returns plan without invoking the client', async () => {
|
|
115
|
+
const dir = await makeTempDir({ 'a.png': Buffer.alloc(100), 'sub/b.png': Buffer.alloc(50) });
|
|
116
|
+
const client = makeClient();
|
|
117
|
+
const tool = createUploadProductDirectoryTool(client);
|
|
118
|
+
const result = await tool({
|
|
119
|
+
slug: 'clara',
|
|
120
|
+
local_dir: dir,
|
|
121
|
+
domain: 'brand',
|
|
122
|
+
dest_prefix: 'logos',
|
|
123
|
+
dry_run: true,
|
|
124
|
+
});
|
|
125
|
+
expect(result.dry_run).toBe(true);
|
|
126
|
+
expect(result.total_files).toBe(2);
|
|
127
|
+
expect(client.calls).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('createUploadProductDirectoryTool — oversized', () => {
|
|
131
|
+
it('reports oversized files as failed and continues with the rest', async () => {
|
|
132
|
+
const dir = await makeTempDir({
|
|
133
|
+
'small.png': Buffer.alloc(100),
|
|
134
|
+
'huge.png': Buffer.alloc(11 * 1024 * 1024), // 11MB > 10MB limit
|
|
135
|
+
});
|
|
136
|
+
const client = makeClient();
|
|
137
|
+
const tool = createUploadProductDirectoryTool(client);
|
|
138
|
+
const result = await tool({
|
|
139
|
+
slug: 'clara',
|
|
140
|
+
local_dir: dir,
|
|
141
|
+
domain: 'brand',
|
|
142
|
+
dry_run: false,
|
|
143
|
+
});
|
|
144
|
+
expect(result.dry_run).toBe(false);
|
|
145
|
+
expect(result.uploaded.length).toBe(1);
|
|
146
|
+
expect(result.uploaded[0].dest_path).toMatch(/small\.png$/);
|
|
147
|
+
expect(result.failed.length).toBe(1);
|
|
148
|
+
expect(result.failed[0].error).toMatch(/exceeds per-file limit/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe('createUploadProductDirectoryTool — missing dir', () => {
|
|
152
|
+
it('throws a clear error when local_dir does not exist', async () => {
|
|
153
|
+
const client = makeClient();
|
|
154
|
+
const tool = createUploadProductDirectoryTool(client);
|
|
155
|
+
await expect(tool({ slug: 'clara', local_dir: '/nonexistent/path/zxqwrt', domain: 'brand' })).rejects.toThrow(/local_dir not found/);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('createUploadProductDirectoryTool — client failure', () => {
|
|
159
|
+
it('marks every file in the failing batch as failed', async () => {
|
|
160
|
+
const dir = await makeTempDir({ 'a.png': Buffer.alloc(100), 'b.png': Buffer.alloc(100) });
|
|
161
|
+
const client = makeClient(true);
|
|
162
|
+
const tool = createUploadProductDirectoryTool(client);
|
|
163
|
+
const result = await tool({
|
|
164
|
+
slug: 'clara',
|
|
165
|
+
local_dir: dir,
|
|
166
|
+
domain: 'brand',
|
|
167
|
+
dry_run: false,
|
|
168
|
+
});
|
|
169
|
+
expect(result.uploaded.length).toBe(0);
|
|
170
|
+
expect(result.failed.length).toBe(2);
|
|
171
|
+
expect(result.failed.every((f) => f.error.includes('simulated server failure'))).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "44reports-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server for deploying and pulling context files (code, docs, data) to/from 44reports",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
36
|
+
"minimatch": "^10.2.5",
|
|
36
37
|
"zod": "^3.23.8"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|