mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-04 04:23:00 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@productier/api-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://localhost:8080' }));
|
||||
@@ -0,0 +1,290 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||
import type { HttpMethod } from '../core/types.gen';
|
||||
import { getValidRequestBody } from '../core/utils.gen';
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from './utils.gen';
|
||||
|
||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
|
||||
export const createClient = (config: Config = {}): Client => {
|
||||
let _config = mergeConfigs(createConfig(), config);
|
||||
|
||||
const getConfig = (): Config => ({ ..._config });
|
||||
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
|
||||
const beforeRequest = async (options: RequestOptions) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined as string | undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === '') {
|
||||
opts.headers.delete('Content-Type');
|
||||
}
|
||||
|
||||
const url = buildUrl(opts);
|
||||
|
||||
return { opts, url };
|
||||
};
|
||||
|
||||
const request: Client['request'] = async (options) => {
|
||||
// @ts-expect-error
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: 'follow',
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
|
||||
let request = new Request(url, requestInit);
|
||||
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await _fetch(request);
|
||||
} catch (error) {
|
||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as unknown);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// Return error response
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response: undefined as any,
|
||||
};
|
||||
}
|
||||
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === 'auto'
|
||||
? getParseAs(response.headers.get('Content-Type'))
|
||||
: opts.parseAs) ?? 'json';
|
||||
|
||||
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'text':
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case 'formData':
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case 'stream':
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case 'json':
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === 'data'
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'formData':
|
||||
case 'text':
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case 'json': {
|
||||
// Some servers return 200 with no Content-Length and empty body.
|
||||
// response.json() would throw; read as text and parse if non-empty.
|
||||
const text = await response.text();
|
||||
data = text ? JSON.parse(text) : {};
|
||||
break;
|
||||
}
|
||||
case 'stream':
|
||||
return opts.responseStyle === 'data'
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseAs === 'json') {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
return opts.responseStyle === 'data'
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
const error = jsonError ?? textError;
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, response, request, opts)) as string;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as string);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||
request({ ...options, method });
|
||||
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
headers: opts.headers as unknown as Record<string, string>,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
|
||||
|
||||
return {
|
||||
buildUrl: _buildUrl,
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
getConfig,
|
||||
head: makeMethodFn('HEAD'),
|
||||
interceptors,
|
||||
options: makeMethodFn('OPTIONS'),
|
||||
patch: makeMethodFn('PATCH'),
|
||||
post: makeMethodFn('POST'),
|
||||
put: makeMethodFn('PUT'),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn('CONNECT'),
|
||||
delete: makeSseFn('DELETE'),
|
||||
get: makeSseFn('GET'),
|
||||
head: makeSseFn('HEAD'),
|
||||
options: makeSseFn('OPTIONS'),
|
||||
patch: makeSseFn('PATCH'),
|
||||
post: makeSseFn('POST'),
|
||||
put: makeSseFn('PUT'),
|
||||
trace: makeSseFn('TRACE'),
|
||||
},
|
||||
trace: makeMethodFn('TRACE'),
|
||||
} as Client;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { Auth } from '../core/auth.gen';
|
||||
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
export {
|
||||
formDataBodySerializer,
|
||||
jsonBodySerializer,
|
||||
urlSearchParamsBodySerializer,
|
||||
} from '../core/bodySerializer.gen';
|
||||
export { buildClientParams } from '../core/params.gen';
|
||||
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||
export { createClient } from './client.gen';
|
||||
export type {
|
||||
Client,
|
||||
ClientOptions,
|
||||
Config,
|
||||
CreateClientConfig,
|
||||
Options,
|
||||
RequestOptions,
|
||||
RequestResult,
|
||||
ResolvedRequestOptions,
|
||||
ResponseStyle,
|
||||
TDataShape,
|
||||
} from './types.gen';
|
||||
export { createConfig, mergeHeaders } from './utils.gen';
|
||||
@@ -0,0 +1,214 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from '../core/auth.gen';
|
||||
import type {
|
||||
ServerSentEventsOptions,
|
||||
ServerSentEventsResult,
|
||||
} from '../core/serverSentEvents.gen';
|
||||
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
|
||||
import type { Middleware } from './utils.gen';
|
||||
|
||||
export type ResponseStyle = 'data' | 'fields';
|
||||
|
||||
export interface Config<T extends ClientOptions = ClientOptions>
|
||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T['baseUrl'];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||
* options won't have any effect.
|
||||
*
|
||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||
*/
|
||||
next?: never;
|
||||
/**
|
||||
* Return the response data parsed in a specified format. By default, `auto`
|
||||
* will infer the appropriate method from the `Content-Type` response header.
|
||||
* You can override this behavior with any of the {@link Body} methods.
|
||||
* Select `stream` if you don't want to parse response data at all.
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T['throwOnError'];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>
|
||||
extends
|
||||
Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
| 'onRequest'
|
||||
| 'onSseError'
|
||||
| 'onSseEvent'
|
||||
| 'sseDefaultRetryDelay'
|
||||
| 'sseMaxRetryAttempts'
|
||||
| 'sseMaxRetryDelay'
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
type MethodFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export interface TDataShape {
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = OmitKeys<
|
||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
@@ -0,0 +1,316 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from '../core/auth.gen';
|
||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||
import {
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from '../core/pathSerializer.gen';
|
||||
import { getUrl } from '../core/utils.gen';
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({
|
||||
parameters = {},
|
||||
...args
|
||||
}: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = parameters[name] || args;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'form',
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === 'object') {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'deepObject',
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join('&');
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
const cleanContent = contentType.split(';')[0]?.trim();
|
||||
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (cleanContent === 'multipart/form-data') {
|
||||
return 'formData';
|
||||
}
|
||||
|
||||
if (
|
||||
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
|
||||
) {
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('text/')) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.headers.has(name) ||
|
||||
options.query?.[name] ||
|
||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, 'security'> &
|
||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = await getAuthToken(auth, options.auth);
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? 'Authorization';
|
||||
|
||||
switch (auth.in) {
|
||||
case 'query':
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case 'cookie':
|
||||
options.headers.append('Cookie', `${name}=${token}`);
|
||||
break;
|
||||
case 'header':
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === 'function'
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith('/')) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
};
|
||||
|
||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (
|
||||
...headers: Array<Required<Config>['headers'] | undefined>
|
||||
): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
||||
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e., their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
error: Err,
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
||||
|
||||
type ResInterceptor<Res, Req, Options> = (
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Res | Promise<Res>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === 'number') {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Middleware<Req, Res, Err, Options> {
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||
Req,
|
||||
Res,
|
||||
Err,
|
||||
Options
|
||||
> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
});
|
||||
|
||||
const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: 'form',
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: 'deepObject',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: 'auto',
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type AuthToken = string | undefined;
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: 'basic' | 'bearer';
|
||||
type: 'apiKey' | 'http';
|
||||
}
|
||||
|
||||
export const getAuthToken = async (
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
): Promise<string | undefined> => {
|
||||
const token = typeof callback === 'function' ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'bearer') {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'basic') {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
|
||||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
export type BodySerializer = (body: unknown) => unknown;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
};
|
||||
|
||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||
if (typeof value === 'string' || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
||||
if (typeof value === 'string') {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: (body: unknown): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: (body: unknown): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: (body: unknown): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data.toString();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||
|
||||
export type Field =
|
||||
| {
|
||||
in: Exclude<Slot, 'body'>;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, 'body'>;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
}
|
||||
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: 'body',
|
||||
$headers_: 'headers',
|
||||
$path_: 'path',
|
||||
$query_: 'query',
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
interface Params {
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const map = buildKeyMap(fields);
|
||||
|
||||
let config: FieldsConfig[number] | undefined;
|
||||
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripEmptySlots(params);
|
||||
|
||||
return params;
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SerializerOptions<T> {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||
export type ObjectStyle = 'form' | 'deepObject';
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return ',';
|
||||
case 'pipeDelimited':
|
||||
return '|';
|
||||
case 'spaceDelimited':
|
||||
return '%20';
|
||||
default:
|
||||
return ',';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeArrayParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (
|
||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||
).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
case 'simple':
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === 'label' || style === 'simple') {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({
|
||||
allowReserved,
|
||||
name,
|
||||
value,
|
||||
}: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||
);
|
||||
}
|
||||
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
};
|
||||
|
||||
export const serializeObjectParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
}) => {
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== 'deepObject' && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
});
|
||||
const joinedValues = values.join(',');
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return `${name}=${joinedValues}`;
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely stringifies a value and parses it back into a JsonValue.
|
||||
*/
|
||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Config } from './types.gen';
|
||||
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
|
||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit['body'];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface StreamEvent<TData = unknown> {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||
stream: AsyncGenerator<
|
||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||
TReturn,
|
||||
TNext
|
||||
>;
|
||||
};
|
||||
|
||||
export const createSseClient = <TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
attempt++;
|
||||
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: 'follow',
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
|
||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.body) throw new Error('No body in SSE response');
|
||||
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.replace(/^event:\s*/, '');
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.replace(/^id:\s*/, '');
|
||||
} else if (line.startsWith('retry:')) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createStream();
|
||||
|
||||
return { stream };
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from './auth.gen';
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
|
||||
|
||||
export type HttpMethod =
|
||||
| 'connect'
|
||||
| 'delete'
|
||||
| 'get'
|
||||
| 'head'
|
||||
| 'options'
|
||||
| 'patch'
|
||||
| 'post'
|
||||
| 'put'
|
||||
| 'trace';
|
||||
|
||||
export type Client<
|
||||
RequestFn = never,
|
||||
Config = unknown,
|
||||
MethodFn = never,
|
||||
BuildUrlFn = never,
|
||||
SseFn = never,
|
||||
> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit['headers']
|
||||
| Record<
|
||||
string,
|
||||
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
||||
>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g., converting ISO strings into Date objects.
|
||||
*/
|
||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function validating response data. This is useful if you want to ensure
|
||||
* the response conforms to the desired shape, so it can be safely passed to
|
||||
* the transformers and returned to the user.
|
||||
*/
|
||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
export type OmitNever<T extends Record<string, unknown>> = {
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||
import {
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||
|
||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = 'simple';
|
||||
|
||||
if (name.endsWith('*')) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith('.')) {
|
||||
name = name.substring(1);
|
||||
style = 'label';
|
||||
} else if (name.startsWith(';')) {
|
||||
name = name.substring(1);
|
||||
style = 'matrix';
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === 'matrix') {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(
|
||||
style === 'label' ? `.${value as string}` : (value as string),
|
||||
);
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getUrl = ({
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? '') + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : '';
|
||||
if (search.startsWith('?')) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function getValidRequestBody(options: {
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
}) {
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ('serializedBody' in options) {
|
||||
const hasSerializedBody =
|
||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
|
||||
// not all clients implement a serializedBody property (i.e., client-axios)
|
||||
return options.body !== '' ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
|
||||
// no body was provided
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export { acceptInvite, connectMailbox, createBoardGroup, createCalendarEvent, createFocusSession, createInvite, createLabel, createNote, createOutgoingMail, createTask, createTaskFromMail, deleteTaskAttachment, downloadTaskAttachment, getHealth, getInviteByToken, getMetrics, getMetricsPrometheus, listActivity, listBoardGroups, listCalendarEvents, listFocusSessions, listInvites, listLabels, listMailboxes, listMailMessages, listMembers, listNotes, listOutgoingMails, listTasks, listWorkspaces, type Options, revokeInvite, syncMailbox, updateBoardGroup, updateCalendarEvent, updateFocusSession, updateMember, updateNote, updateTask, uploadTaskAttachment } from './sdk.gen';
|
||||
export type { AcceptInviteData, AcceptInviteError, AcceptInviteErrors, AcceptInviteInput, AcceptInviteResponse, AcceptInviteResponses, ActivityEntry, Attachment, BoardGroup, CalendarEvent, ClientOptions, ConnectMailboxData, ConnectMailboxError, ConnectMailboxErrors, ConnectMailboxInput, ConnectMailboxResponse, ConnectMailboxResponses, CreateBoardGroupData, CreateBoardGroupInput, CreateBoardGroupResponse, CreateBoardGroupResponses, CreateCalendarEventData, CreateCalendarEventInput, CreateCalendarEventResponse, CreateCalendarEventResponses, CreateFocusSessionData, CreateFocusSessionInput, CreateFocusSessionResponse, CreateFocusSessionResponses, CreateInviteData, CreateInviteError, CreateInviteErrors, CreateInviteInput, CreateInviteResponse, CreateInviteResponses, CreateLabelData, CreateLabelInput, CreateLabelResponse, CreateLabelResponses, CreateNoteData, CreateNoteInput, CreateNoteResponse, CreateNoteResponses, CreateOutgoingMailData, CreateOutgoingMailError, CreateOutgoingMailErrors, CreateOutgoingMailInput, CreateOutgoingMailResponse, CreateOutgoingMailResponses, CreateTaskData, CreateTaskFromMailData, CreateTaskFromMailError, CreateTaskFromMailErrors, CreateTaskFromMailInput, CreateTaskFromMailResponse, CreateTaskFromMailResponses, CreateTaskInput, CreateTaskResponse, CreateTaskResponses, DeleteTaskAttachmentData, DeleteTaskAttachmentError, DeleteTaskAttachmentErrors, DeleteTaskAttachmentResponse, DeleteTaskAttachmentResponses, DownloadTaskAttachmentData, DownloadTaskAttachmentError, DownloadTaskAttachmentErrors, DownloadTaskAttachmentResponse, DownloadTaskAttachmentResponses, ErrorDetail, ErrorResponse, FocusSession, GetHealthData, GetHealthError, GetHealthErrors, GetHealthResponse, GetHealthResponses, GetInviteByTokenData, GetInviteByTokenError, GetInviteByTokenErrors, GetInviteByTokenResponse, GetInviteByTokenResponses, GetMetricsData, GetMetricsError, GetMetricsErrors, GetMetricsPrometheusData, GetMetricsPrometheusError, GetMetricsPrometheusErrors, GetMetricsPrometheusResponse, GetMetricsPrometheusResponses, GetMetricsResponse, GetMetricsResponses, HealthResponse, HealthStorageStatus, Invite, Label, ListActivityData, ListActivityError, ListActivityErrors, ListActivityResponse, ListActivityResponses, ListBoardGroupsData, ListBoardGroupsResponse, ListBoardGroupsResponses, ListCalendarEventsData, ListCalendarEventsResponse, ListCalendarEventsResponses, ListFocusSessionsData, ListFocusSessionsResponse, ListFocusSessionsResponses, ListInvitesData, ListInvitesResponse, ListInvitesResponses, ListLabelsData, ListLabelsResponse, ListLabelsResponses, ListMailboxesData, ListMailboxesResponse, ListMailboxesResponses, ListMailMessagesData, ListMailMessagesResponse, ListMailMessagesResponses, ListMembersData, ListMembersResponse, ListMembersResponses, ListNotesData, ListNotesResponse, ListNotesResponses, ListOutgoingMailsData, ListOutgoingMailsResponse, ListOutgoingMailsResponses, ListTasksData, ListTasksResponse, ListTasksResponses, ListWorkspacesData, ListWorkspacesResponse, ListWorkspacesResponses, MailAddress, Mailbox, MailMessage, Member, MetricsResponse, MetricsRouteStat, Note, OutgoingMail, RevokeInviteData, RevokeInviteError, RevokeInviteErrors, RevokeInviteResponse, RevokeInviteResponses, SyncMailboxData, SyncMailboxError, SyncMailboxErrors, SyncMailboxResponse, SyncMailboxResponses, Task, TaskComment, UpdateBoardGroupData, UpdateBoardGroupInput, UpdateBoardGroupResponse, UpdateBoardGroupResponses, UpdateCalendarEventData, UpdateCalendarEventInput, UpdateCalendarEventResponse, UpdateCalendarEventResponses, UpdateFocusSessionData, UpdateFocusSessionInput, UpdateFocusSessionResponse, UpdateFocusSessionResponses, UpdateMemberData, UpdateMemberError, UpdateMemberErrors, UpdateMemberInput, UpdateMemberResponse, UpdateMemberResponses, UpdateNoteData, UpdateNoteInput, UpdateNoteResponse, UpdateNoteResponses, UpdateTaskData, UpdateTaskInput, UpdateTaskResponse, UpdateTaskResponses, UploadTaskAttachmentData, UploadTaskAttachmentError, UploadTaskAttachmentErrors, UploadTaskAttachmentResponse, UploadTaskAttachmentResponses, Workspace } from './types.gen';
|
||||
@@ -0,0 +1,224 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { AcceptInviteData, AcceptInviteErrors, AcceptInviteResponses, ConnectMailboxData, ConnectMailboxErrors, ConnectMailboxResponses, CreateBoardGroupData, CreateBoardGroupResponses, CreateCalendarEventData, CreateCalendarEventResponses, CreateFocusSessionData, CreateFocusSessionResponses, CreateInviteData, CreateInviteErrors, CreateInviteResponses, CreateLabelData, CreateLabelResponses, CreateNoteData, CreateNoteResponses, CreateOutgoingMailData, CreateOutgoingMailErrors, CreateOutgoingMailResponses, CreateTaskData, CreateTaskFromMailData, CreateTaskFromMailErrors, CreateTaskFromMailResponses, CreateTaskResponses, DeleteTaskAttachmentData, DeleteTaskAttachmentErrors, DeleteTaskAttachmentResponses, DownloadTaskAttachmentData, DownloadTaskAttachmentErrors, DownloadTaskAttachmentResponses, GetHealthData, GetHealthErrors, GetHealthResponses, GetInviteByTokenData, GetInviteByTokenErrors, GetInviteByTokenResponses, GetMetricsData, GetMetricsErrors, GetMetricsPrometheusData, GetMetricsPrometheusErrors, GetMetricsPrometheusResponses, GetMetricsResponses, ListActivityData, ListActivityErrors, ListActivityResponses, ListBoardGroupsData, ListBoardGroupsResponses, ListCalendarEventsData, ListCalendarEventsResponses, ListFocusSessionsData, ListFocusSessionsResponses, ListInvitesData, ListInvitesResponses, ListLabelsData, ListLabelsResponses, ListMailboxesData, ListMailboxesResponses, ListMailMessagesData, ListMailMessagesResponses, ListMembersData, ListMembersResponses, ListNotesData, ListNotesResponses, ListOutgoingMailsData, ListOutgoingMailsResponses, ListTasksData, ListTasksResponses, ListWorkspacesData, ListWorkspacesResponses, RevokeInviteData, RevokeInviteErrors, RevokeInviteResponses, SyncMailboxData, SyncMailboxErrors, SyncMailboxResponses, UpdateBoardGroupData, UpdateBoardGroupResponses, UpdateCalendarEventData, UpdateCalendarEventResponses, UpdateFocusSessionData, UpdateFocusSessionResponses, UpdateMemberData, UpdateMemberErrors, UpdateMemberResponses, UpdateNoteData, UpdateNoteResponses, UpdateTaskData, UpdateTaskResponses, UploadTaskAttachmentData, UploadTaskAttachmentErrors, UploadTaskAttachmentResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
* You can provide a client instance returned by `createClient()` instead of
|
||||
* individual options. This might be also useful if you want to implement a
|
||||
* custom client.
|
||||
*/
|
||||
client?: Client;
|
||||
/**
|
||||
* You can pass arbitrary values through the `meta` object. This can be
|
||||
* used to access values that aren't defined as part of the SDK function.
|
||||
*/
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const getHealth = <ThrowOnError extends boolean = false>(options?: Options<GetHealthData, ThrowOnError>) => (options?.client ?? client).get<GetHealthResponses, GetHealthErrors, ThrowOnError>({ url: '/v1/health', ...options });
|
||||
|
||||
export const getMetrics = <ThrowOnError extends boolean = false>(options?: Options<GetMetricsData, ThrowOnError>) => (options?.client ?? client).get<GetMetricsResponses, GetMetricsErrors, ThrowOnError>({ url: '/v1/metrics', ...options });
|
||||
|
||||
export const getMetricsPrometheus = <ThrowOnError extends boolean = false>(options?: Options<GetMetricsPrometheusData, ThrowOnError>) => (options?.client ?? client).get<GetMetricsPrometheusResponses, GetMetricsPrometheusErrors, ThrowOnError>({ url: '/v1/metrics/prometheus', ...options });
|
||||
|
||||
export const listWorkspaces = <ThrowOnError extends boolean = false>(options?: Options<ListWorkspacesData, ThrowOnError>) => (options?.client ?? client).get<ListWorkspacesResponses, unknown, ThrowOnError>({ url: '/v1/workspaces', ...options });
|
||||
|
||||
export const listMembers = <ThrowOnError extends boolean = false>(options: Options<ListMembersData, ThrowOnError>) => (options.client ?? client).get<ListMembersResponses, unknown, ThrowOnError>({ url: '/v1/members', ...options });
|
||||
|
||||
export const updateMember = <ThrowOnError extends boolean = false>(options: Options<UpdateMemberData, ThrowOnError>) => (options.client ?? client).patch<UpdateMemberResponses, UpdateMemberErrors, ThrowOnError>({
|
||||
url: '/v1/members/{memberId}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const listInvites = <ThrowOnError extends boolean = false>(options: Options<ListInvitesData, ThrowOnError>) => (options.client ?? client).get<ListInvitesResponses, unknown, ThrowOnError>({ url: '/v1/invites', ...options });
|
||||
|
||||
export const createInvite = <ThrowOnError extends boolean = false>(options: Options<CreateInviteData, ThrowOnError>) => (options.client ?? client).post<CreateInviteResponses, CreateInviteErrors, ThrowOnError>({
|
||||
url: '/v1/invites',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const getInviteByToken = <ThrowOnError extends boolean = false>(options: Options<GetInviteByTokenData, ThrowOnError>) => (options.client ?? client).get<GetInviteByTokenResponses, GetInviteByTokenErrors, ThrowOnError>({ url: '/v1/invites/{token}', ...options });
|
||||
|
||||
export const acceptInvite = <ThrowOnError extends boolean = false>(options: Options<AcceptInviteData, ThrowOnError>) => (options.client ?? client).post<AcceptInviteResponses, AcceptInviteErrors, ThrowOnError>({
|
||||
url: '/v1/invites/{token}/accept',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const revokeInvite = <ThrowOnError extends boolean = false>(options: Options<RevokeInviteData, ThrowOnError>) => (options.client ?? client).post<RevokeInviteResponses, RevokeInviteErrors, ThrowOnError>({ url: '/v1/invites/{inviteId}/revoke', ...options });
|
||||
|
||||
export const listActivity = <ThrowOnError extends boolean = false>(options: Options<ListActivityData, ThrowOnError>) => (options.client ?? client).get<ListActivityResponses, ListActivityErrors, ThrowOnError>({ url: '/v1/activity', ...options });
|
||||
|
||||
export const listBoardGroups = <ThrowOnError extends boolean = false>(options: Options<ListBoardGroupsData, ThrowOnError>) => (options.client ?? client).get<ListBoardGroupsResponses, unknown, ThrowOnError>({ url: '/v1/board-groups', ...options });
|
||||
|
||||
export const createBoardGroup = <ThrowOnError extends boolean = false>(options: Options<CreateBoardGroupData, ThrowOnError>) => (options.client ?? client).post<CreateBoardGroupResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/board-groups',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const updateBoardGroup = <ThrowOnError extends boolean = false>(options: Options<UpdateBoardGroupData, ThrowOnError>) => (options.client ?? client).patch<UpdateBoardGroupResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/board-groups/{groupId}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const listLabels = <ThrowOnError extends boolean = false>(options: Options<ListLabelsData, ThrowOnError>) => (options.client ?? client).get<ListLabelsResponses, unknown, ThrowOnError>({ url: '/v1/labels', ...options });
|
||||
|
||||
export const createLabel = <ThrowOnError extends boolean = false>(options: Options<CreateLabelData, ThrowOnError>) => (options.client ?? client).post<CreateLabelResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/labels',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const listTasks = <ThrowOnError extends boolean = false>(options: Options<ListTasksData, ThrowOnError>) => (options.client ?? client).get<ListTasksResponses, unknown, ThrowOnError>({ url: '/v1/tasks', ...options });
|
||||
|
||||
export const createTask = <ThrowOnError extends boolean = false>(options: Options<CreateTaskData, ThrowOnError>) => (options.client ?? client).post<CreateTaskResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/tasks',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const listCalendarEvents = <ThrowOnError extends boolean = false>(options: Options<ListCalendarEventsData, ThrowOnError>) => (options.client ?? client).get<ListCalendarEventsResponses, unknown, ThrowOnError>({ url: '/v1/calendar/events', ...options });
|
||||
|
||||
export const createCalendarEvent = <ThrowOnError extends boolean = false>(options: Options<CreateCalendarEventData, ThrowOnError>) => (options.client ?? client).post<CreateCalendarEventResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/calendar/events',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const updateTask = <ThrowOnError extends boolean = false>(options: Options<UpdateTaskData, ThrowOnError>) => (options.client ?? client).patch<UpdateTaskResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/tasks/{taskId}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const uploadTaskAttachment = <ThrowOnError extends boolean = false>(options: Options<UploadTaskAttachmentData, ThrowOnError>) => (options.client ?? client).post<UploadTaskAttachmentResponses, UploadTaskAttachmentErrors, ThrowOnError>({
|
||||
...formDataBodySerializer,
|
||||
url: '/v1/tasks/{taskId}/attachments',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': null,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteTaskAttachment = <ThrowOnError extends boolean = false>(options: Options<DeleteTaskAttachmentData, ThrowOnError>) => (options.client ?? client).delete<DeleteTaskAttachmentResponses, DeleteTaskAttachmentErrors, ThrowOnError>({ url: '/v1/tasks/{taskId}/attachments/{attachmentId}', ...options });
|
||||
|
||||
export const downloadTaskAttachment = <ThrowOnError extends boolean = false>(options: Options<DownloadTaskAttachmentData, ThrowOnError>) => (options.client ?? client).get<DownloadTaskAttachmentResponses, DownloadTaskAttachmentErrors, ThrowOnError>({ url: '/v1/tasks/{taskId}/attachments/{attachmentId}/download', ...options });
|
||||
|
||||
export const updateCalendarEvent = <ThrowOnError extends boolean = false>(options: Options<UpdateCalendarEventData, ThrowOnError>) => (options.client ?? client).patch<UpdateCalendarEventResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/calendar/events/{eventId}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const listNotes = <ThrowOnError extends boolean = false>(options: Options<ListNotesData, ThrowOnError>) => (options.client ?? client).get<ListNotesResponses, unknown, ThrowOnError>({ url: '/v1/notes', ...options });
|
||||
|
||||
export const createNote = <ThrowOnError extends boolean = false>(options: Options<CreateNoteData, ThrowOnError>) => (options.client ?? client).post<CreateNoteResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/notes',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const updateNote = <ThrowOnError extends boolean = false>(options: Options<UpdateNoteData, ThrowOnError>) => (options.client ?? client).patch<UpdateNoteResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/notes/{noteId}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const listFocusSessions = <ThrowOnError extends boolean = false>(options: Options<ListFocusSessionsData, ThrowOnError>) => (options.client ?? client).get<ListFocusSessionsResponses, unknown, ThrowOnError>({ url: '/v1/focus/sessions', ...options });
|
||||
|
||||
export const createFocusSession = <ThrowOnError extends boolean = false>(options: Options<CreateFocusSessionData, ThrowOnError>) => (options.client ?? client).post<CreateFocusSessionResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/focus/sessions',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const updateFocusSession = <ThrowOnError extends boolean = false>(options: Options<UpdateFocusSessionData, ThrowOnError>) => (options.client ?? client).patch<UpdateFocusSessionResponses, unknown, ThrowOnError>({
|
||||
url: '/v1/focus/sessions/{sessionId}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const listMailboxes = <ThrowOnError extends boolean = false>(options: Options<ListMailboxesData, ThrowOnError>) => (options.client ?? client).get<ListMailboxesResponses, unknown, ThrowOnError>({ url: '/v1/mailboxes', ...options });
|
||||
|
||||
export const connectMailbox = <ThrowOnError extends boolean = false>(options: Options<ConnectMailboxData, ThrowOnError>) => (options.client ?? client).post<ConnectMailboxResponses, ConnectMailboxErrors, ThrowOnError>({
|
||||
url: '/v1/mailboxes',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const syncMailbox = <ThrowOnError extends boolean = false>(options: Options<SyncMailboxData, ThrowOnError>) => (options.client ?? client).post<SyncMailboxResponses, SyncMailboxErrors, ThrowOnError>({ url: '/v1/mailboxes/{mailboxId}/sync', ...options });
|
||||
|
||||
export const listMailMessages = <ThrowOnError extends boolean = false>(options: Options<ListMailMessagesData, ThrowOnError>) => (options.client ?? client).get<ListMailMessagesResponses, unknown, ThrowOnError>({ url: '/v1/mail/messages', ...options });
|
||||
|
||||
export const createTaskFromMail = <ThrowOnError extends boolean = false>(options: Options<CreateTaskFromMailData, ThrowOnError>) => (options.client ?? client).post<CreateTaskFromMailResponses, CreateTaskFromMailErrors, ThrowOnError>({
|
||||
url: '/v1/mail/messages/{messageId}/create-task',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
export const listOutgoingMails = <ThrowOnError extends boolean = false>(options: Options<ListOutgoingMailsData, ThrowOnError>) => (options.client ?? client).get<ListOutgoingMailsResponses, unknown, ThrowOnError>({ url: '/v1/mail/outgoing', ...options });
|
||||
|
||||
export const createOutgoingMail = <ThrowOnError extends boolean = false>(options: Options<CreateOutgoingMailData, ThrowOnError>) => (options.client ?? client).post<CreateOutgoingMailResponses, CreateOutgoingMailErrors, ThrowOnError>({
|
||||
url: '/v1/mail/outgoing',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export { createClient, createConfig } from "./client/client";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
# Productier OpenClaw Plugin
|
||||
|
||||
This package includes the OpenClaw baseline tools for Productier plus runtime hardening:
|
||||
|
||||
- transient retry handling (network + `429/5xx`)
|
||||
- per-tool rate limiting
|
||||
- structured JSONL audit logging
|
||||
|
||||
## Included tools
|
||||
|
||||
- `productier_list_workspaces`
|
||||
- `productier_list_board_groups`
|
||||
- `productier_list_tasks`
|
||||
- `productier_list_calendar_events`
|
||||
- `productier_list_notes`
|
||||
- `productier_list_mailboxes`
|
||||
- `productier_list_mail_messages`
|
||||
- `productier_list_outgoing_mails`
|
||||
- `productier_connect_mailbox`
|
||||
- `productier_sync_mailbox`
|
||||
- `productier_create_board_group`
|
||||
- `productier_create_task`
|
||||
- `productier_create_calendar_event`
|
||||
- `productier_create_note`
|
||||
- `productier_create_outgoing_mail`
|
||||
- `productier_create_task_from_mail`
|
||||
- `productier_update_board_group`
|
||||
- `productier_update_task`
|
||||
- `productier_update_calendar_event`
|
||||
- `productier_update_note`
|
||||
|
||||
Profiles:
|
||||
|
||||
- `readonly`: `productier_list_workspaces`, `productier_list_tasks`
|
||||
- `standard`: `readonly` tools + board/calendar/notes/task tools + mailbox management + outgoing mail + mail task conversion tools
|
||||
|
||||
## Environment
|
||||
|
||||
- `PRODUCTIER_API_URL` (default: `http://localhost:8080`)
|
||||
- `PRODUCTIER_AUTH_COOKIE` (Better Auth session cookie string used by backend `/v1/*` endpoints)
|
||||
- `PRODUCTIER_WORKSPACE_SLUG_DEFAULT` (optional fallback workspace slug)
|
||||
- `PRODUCTIER_BOARD_GROUP_ID_DEFAULT` (optional fallback board group id for create-task tool)
|
||||
- `PRODUCTIER_TOOL_PROFILE` (`readonly` or `standard`, default `readonly`)
|
||||
- `PRODUCTIER_TOOL_RETRY_MAX_ATTEMPTS` (default: `3`)
|
||||
- `PRODUCTIER_TOOL_RETRY_BASE_DELAY_MS` (default: `150`)
|
||||
- `PRODUCTIER_TOOL_RETRY_MAX_DELAY_MS` (default: `2000`)
|
||||
- `PRODUCTIER_TOOL_RETRY_JITTER_MS` (default: `50`)
|
||||
- `PRODUCTIER_TOOL_RATE_LIMIT_MAX_CALLS` (default: `120`)
|
||||
- `PRODUCTIER_TOOL_RATE_LIMIT_WINDOW_MS` (default: `60000`)
|
||||
- `PRODUCTIER_AUDIT_LOG_PATH` (optional JSONL path for per-tool execution audit entries)
|
||||
|
||||
## Quick usage
|
||||
|
||||
Describe plugin and tool metadata:
|
||||
|
||||
```bash
|
||||
npm run describe -w packages/openclaw-plugin
|
||||
```
|
||||
|
||||
Call the tool:
|
||||
|
||||
```bash
|
||||
node packages/openclaw-plugin/src/cli.mjs call productier_list_tasks '{"workspaceSlug":"personal","limit":20}'
|
||||
```
|
||||
|
||||
Create a task using defaults:
|
||||
|
||||
```bash
|
||||
PRODUCTIER_TOOL_PROFILE=standard \
|
||||
PRODUCTIER_WORKSPACE_SLUG_DEFAULT=personal \
|
||||
PRODUCTIER_BOARD_GROUP_ID_DEFAULT=group-inbox \
|
||||
node packages/openclaw-plugin/src/cli.mjs call productier_create_task '{"title":"Write release notes"}'
|
||||
```
|
||||
|
||||
The tool call returns:
|
||||
|
||||
- `ok: true` with `data` on success
|
||||
- `ok: false` with structured `error` (`code`, `message`, optional `status`, `requestId`, `attempts`, `retryable`) on failure
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@productier/openclaw-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.mjs",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"check": "node --test",
|
||||
"describe": "node ./src/cli.mjs describe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createPlugin } from "./index.mjs";
|
||||
|
||||
function printJSON(value) {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const plugin = createPlugin();
|
||||
const [, , command = "describe", toolName, rawInput] = process.argv;
|
||||
|
||||
if (command === "describe") {
|
||||
const toolsByProfile = Object.fromEntries(
|
||||
Object.keys(plugin.manifest.profiles).map(profileName => [
|
||||
profileName,
|
||||
createPlugin({ profile: profileName }).listTools()
|
||||
]),
|
||||
);
|
||||
printJSON({
|
||||
manifest: plugin.manifest,
|
||||
profile: process.env.PRODUCTIER_TOOL_PROFILE || "readonly",
|
||||
tools: plugin.listTools(),
|
||||
toolsByProfile
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "call") {
|
||||
if (!toolName) {
|
||||
throw new Error("tool name is required: node src/cli.mjs call <toolName> '{\"workspaceSlug\":\"personal\"}'");
|
||||
}
|
||||
|
||||
let input = {};
|
||||
if (rawInput) {
|
||||
try {
|
||||
input = JSON.parse(rawInput);
|
||||
} catch {
|
||||
throw new Error("input must be valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
const result = await plugin.runTool(toolName, input);
|
||||
printJSON(result);
|
||||
process.exitCode = result.ok ? 0 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`unknown command: ${command}`);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
const DEFAULT_API_URL = "http://localhost:8080";
|
||||
|
||||
export class ProductierApiError extends Error {
|
||||
constructor(message, status, code, requestId) {
|
||||
super(message);
|
||||
this.name = "ProductierApiError";
|
||||
this.status = status;
|
||||
this.code = code ?? "unknown_error";
|
||||
this.requestId = requestId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function readErrorMessage(payload, fallback) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
if (payload.error && typeof payload.error === "object" && typeof payload.error.message === "string") {
|
||||
return payload.error.message;
|
||||
}
|
||||
if (typeof payload.message === "string") {
|
||||
return payload.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function createProductierClient(options = {}) {
|
||||
const apiUrl = (options.apiUrl || process.env.PRODUCTIER_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
|
||||
const authCookie = options.authCookie || process.env.PRODUCTIER_AUTH_COOKIE || "";
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
||||
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("fetch implementation is required");
|
||||
}
|
||||
|
||||
async function requestJSON(method, path, { query = {}, body } = {}) {
|
||||
const url = new URL(`${apiUrl}${path}`);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...(authCookie ? { Cookie: authCookie } : {}),
|
||||
Accept: "application/json"
|
||||
};
|
||||
const init = {
|
||||
method,
|
||||
headers
|
||||
};
|
||||
if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
...init
|
||||
});
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = readErrorMessage(payload, `request failed with status ${response.status}`);
|
||||
const code = payload?.error?.code;
|
||||
const requestId = payload?.error?.requestId || response.headers.get("x-request-id");
|
||||
throw new ProductierApiError(message, response.status, code, requestId);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
return {
|
||||
async listWorkspaces() {
|
||||
return requestJSON("GET", "/v1/workspaces");
|
||||
},
|
||||
async listBoardGroups(workspaceSlug) {
|
||||
return requestJSON("GET", "/v1/board-groups", { query: { workspaceSlug } });
|
||||
},
|
||||
async createBoardGroup(input) {
|
||||
return requestJSON("POST", "/v1/board-groups", { body: input });
|
||||
},
|
||||
async updateBoardGroup(groupId, input) {
|
||||
return requestJSON("PATCH", `/v1/board-groups/${encodeURIComponent(groupId)}`, { body: input });
|
||||
},
|
||||
async listTasks(workspaceSlug) {
|
||||
return requestJSON("GET", "/v1/tasks", { query: { workspaceSlug } });
|
||||
},
|
||||
async createTask(input) {
|
||||
return requestJSON("POST", "/v1/tasks", { body: input });
|
||||
},
|
||||
async updateTask(taskId, input) {
|
||||
return requestJSON("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}`, { body: input });
|
||||
},
|
||||
async listCalendarEvents(workspaceSlug) {
|
||||
return requestJSON("GET", "/v1/calendar/events", { query: { workspaceSlug } });
|
||||
},
|
||||
async createCalendarEvent(input) {
|
||||
return requestJSON("POST", "/v1/calendar/events", { body: input });
|
||||
},
|
||||
async updateCalendarEvent(eventId, input) {
|
||||
return requestJSON("PATCH", `/v1/calendar/events/${encodeURIComponent(eventId)}`, { body: input });
|
||||
},
|
||||
async listNotes(workspaceSlug) {
|
||||
return requestJSON("GET", "/v1/notes", { query: { workspaceSlug } });
|
||||
},
|
||||
async createNote(input) {
|
||||
return requestJSON("POST", "/v1/notes", { body: input });
|
||||
},
|
||||
async updateNote(noteId, input) {
|
||||
return requestJSON("PATCH", `/v1/notes/${encodeURIComponent(noteId)}`, { body: input });
|
||||
},
|
||||
async listMailMessages(workspaceSlug, mailboxId) {
|
||||
return requestJSON("GET", "/v1/mail/messages", {
|
||||
query: {
|
||||
workspaceSlug,
|
||||
mailboxId
|
||||
}
|
||||
});
|
||||
},
|
||||
async listMailboxes(workspaceSlug) {
|
||||
return requestJSON("GET", "/v1/mailboxes", { query: { workspaceSlug } });
|
||||
},
|
||||
async connectMailbox(input) {
|
||||
return requestJSON("POST", "/v1/mailboxes", { body: input });
|
||||
},
|
||||
async syncMailbox(mailboxId) {
|
||||
return requestJSON("POST", `/v1/mailboxes/${encodeURIComponent(mailboxId)}/sync`);
|
||||
},
|
||||
async listOutgoingMails(workspaceSlug, mailboxId) {
|
||||
return requestJSON("GET", "/v1/mail/outgoing", {
|
||||
query: {
|
||||
workspaceSlug,
|
||||
mailboxId
|
||||
}
|
||||
});
|
||||
},
|
||||
async createOutgoingMail(input) {
|
||||
return requestJSON("POST", "/v1/mail/outgoing", { body: input });
|
||||
},
|
||||
async createTaskFromMail(messageId, input) {
|
||||
return requestJSON("POST", `/v1/mail/messages/${encodeURIComponent(messageId)}/create-task`, {
|
||||
body: input
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { createProductierClient, ProductierApiError } from "./client.mjs";
|
||||
import {
|
||||
createAuditLogger,
|
||||
createRateLimiter,
|
||||
createRuntimeConfig,
|
||||
invokeWithRetry,
|
||||
ToolRateLimitError,
|
||||
unwrapInvocationError
|
||||
} from "./runtime-hardening.mjs";
|
||||
import { createConnectMailboxTool } from "./tool-connect-mailbox.mjs";
|
||||
import { createCreateBoardGroupTool } from "./tool-create-board-group.mjs";
|
||||
import { createCreateCalendarEventTool } from "./tool-create-calendar-event.mjs";
|
||||
import { createCreateOutgoingMailTool } from "./tool-create-outgoing-mail.mjs";
|
||||
import { createCreateTaskFromMailTool } from "./tool-create-task-from-mail.mjs";
|
||||
import { createCreateNoteTool } from "./tool-create-note.mjs";
|
||||
import { createCreateTaskTool } from "./tool-create-task.mjs";
|
||||
import { createListBoardGroupsTool } from "./tool-list-board-groups.mjs";
|
||||
import { createListCalendarEventsTool } from "./tool-list-calendar-events.mjs";
|
||||
import { createListMailboxesTool } from "./tool-list-mailboxes.mjs";
|
||||
import { createListMailMessagesTool } from "./tool-list-mail-messages.mjs";
|
||||
import { createListNotesTool } from "./tool-list-notes.mjs";
|
||||
import { createListOutgoingMailsTool } from "./tool-list-outgoing-mails.mjs";
|
||||
import { createListTasksTool } from "./tool-list-tasks.mjs";
|
||||
import { createListWorkspacesTool } from "./tool-list-workspaces.mjs";
|
||||
import { createSyncMailboxTool } from "./tool-sync-mailbox.mjs";
|
||||
import { createUpdateBoardGroupTool } from "./tool-update-board-group.mjs";
|
||||
import { createUpdateCalendarEventTool } from "./tool-update-calendar-event.mjs";
|
||||
import { createUpdateNoteTool } from "./tool-update-note.mjs";
|
||||
import { createUpdateTaskTool } from "./tool-update-task.mjs";
|
||||
|
||||
export const pluginManifest = {
|
||||
name: "productier-openclaw",
|
||||
version: "0.1.0",
|
||||
description: "OpenClaw tool plugin for Productier workspace operations.",
|
||||
profiles: {
|
||||
readonly: {
|
||||
tools: ["productier_list_workspaces", "productier_list_tasks"]
|
||||
},
|
||||
standard: {
|
||||
tools: [
|
||||
"productier_list_workspaces",
|
||||
"productier_list_board_groups",
|
||||
"productier_list_tasks",
|
||||
"productier_list_calendar_events",
|
||||
"productier_list_notes",
|
||||
"productier_list_mailboxes",
|
||||
"productier_list_mail_messages",
|
||||
"productier_list_outgoing_mails",
|
||||
"productier_connect_mailbox",
|
||||
"productier_sync_mailbox",
|
||||
"productier_create_board_group",
|
||||
"productier_create_task",
|
||||
"productier_create_calendar_event",
|
||||
"productier_create_note",
|
||||
"productier_create_outgoing_mail",
|
||||
"productier_create_task_from_mail",
|
||||
"productier_update_board_group",
|
||||
"productier_update_task",
|
||||
"productier_update_calendar_event",
|
||||
"productier_update_note"
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function createPlugin(options = {}) {
|
||||
const profile = options.profile || process.env.PRODUCTIER_TOOL_PROFILE || "readonly";
|
||||
if (profile !== "readonly" && profile !== "standard") {
|
||||
throw new Error(`unsupported profile: ${profile}`);
|
||||
}
|
||||
|
||||
const runtimeConfig = createRuntimeConfig(options);
|
||||
const rateLimiter = createRateLimiter(runtimeConfig.rateLimit, options);
|
||||
const auditLogger = createAuditLogger(options, runtimeConfig);
|
||||
const client = createProductierClient(options);
|
||||
const listWorkspaces = createListWorkspacesTool(client);
|
||||
const listBoardGroups = createListBoardGroupsTool(client, options);
|
||||
const listTasks = createListTasksTool(client, options);
|
||||
const listCalendarEvents = createListCalendarEventsTool(client, options);
|
||||
const listNotes = createListNotesTool(client, options);
|
||||
const listMailboxes = createListMailboxesTool(client, options);
|
||||
const listMailMessages = createListMailMessagesTool(client, options);
|
||||
const listOutgoingMails = createListOutgoingMailsTool(client, options);
|
||||
const connectMailbox = createConnectMailboxTool(client, options);
|
||||
const syncMailbox = createSyncMailboxTool(client);
|
||||
const createBoardGroup = createCreateBoardGroupTool(client, options);
|
||||
const createTask = createCreateTaskTool(client, options);
|
||||
const createCalendarEvent = createCreateCalendarEventTool(client, options);
|
||||
const createNote = createCreateNoteTool(client, options);
|
||||
const createOutgoingMail = createCreateOutgoingMailTool(client, options);
|
||||
const createTaskFromMail = createCreateTaskFromMailTool(client, options);
|
||||
const updateBoardGroup = createUpdateBoardGroupTool(client);
|
||||
const updateTask = createUpdateTaskTool(client);
|
||||
const updateCalendarEvent = createUpdateCalendarEventTool(client);
|
||||
const updateNote = createUpdateNoteTool(client);
|
||||
|
||||
const allTools = [
|
||||
listWorkspaces,
|
||||
listBoardGroups,
|
||||
listTasks,
|
||||
listCalendarEvents,
|
||||
listNotes,
|
||||
listMailboxes,
|
||||
listMailMessages,
|
||||
listOutgoingMails,
|
||||
connectMailbox,
|
||||
syncMailbox,
|
||||
createBoardGroup,
|
||||
createTask,
|
||||
createCalendarEvent,
|
||||
createNote,
|
||||
createOutgoingMail,
|
||||
createTaskFromMail,
|
||||
updateBoardGroup,
|
||||
updateTask,
|
||||
updateCalendarEvent,
|
||||
updateNote
|
||||
];
|
||||
const enabledToolNames = new Set(pluginManifest.profiles[profile].tools);
|
||||
const tools = new Map(allTools.filter(tool => enabledToolNames.has(tool.name)).map(tool => [tool.name, tool]));
|
||||
|
||||
return {
|
||||
manifest: pluginManifest,
|
||||
listTools() {
|
||||
return [...tools.values()].map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
}));
|
||||
},
|
||||
async runTool(name, input) {
|
||||
const tool = tools.get(name);
|
||||
if (!tool) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "tool_not_found",
|
||||
message: `Unknown tool: ${name}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const recordedInput = input || {};
|
||||
|
||||
try {
|
||||
rateLimiter.consume(name);
|
||||
const { data, attempts } = await invokeWithRetry(
|
||||
() => tool.invoke(recordedInput),
|
||||
runtimeConfig.retry,
|
||||
options,
|
||||
);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
data,
|
||||
meta: {
|
||||
attempts,
|
||||
durationMs: Date.now() - startedAt
|
||||
}
|
||||
};
|
||||
|
||||
await auditLogger.log({
|
||||
timestamp: new Date().toISOString(),
|
||||
profile,
|
||||
tool: name,
|
||||
ok: true,
|
||||
attempts,
|
||||
durationMs: result.meta.durationMs,
|
||||
errorCode: null,
|
||||
requestId: null
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const normalizedError = unwrapInvocationError(error);
|
||||
const cause = normalizedError.cause;
|
||||
const durationMs = Date.now() - startedAt;
|
||||
let result = null;
|
||||
|
||||
if (cause instanceof ToolRateLimitError) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: cause.code,
|
||||
message: cause.message,
|
||||
status: cause.status,
|
||||
retryAfterMs: cause.retryAfterMs,
|
||||
retryable: false,
|
||||
attempts: normalizedError.attempts
|
||||
}
|
||||
};
|
||||
} else if (cause instanceof ProductierApiError) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: cause.code,
|
||||
message: cause.message,
|
||||
status: cause.status,
|
||||
requestId: cause.requestId,
|
||||
retryable: normalizedError.retryable,
|
||||
attempts: normalizedError.attempts
|
||||
}
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "tool_execution_error",
|
||||
message: cause instanceof Error ? cause.message : "unknown error",
|
||||
retryable: normalizedError.retryable,
|
||||
attempts: normalizedError.attempts
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
await auditLogger.log({
|
||||
timestamp: new Date().toISOString(),
|
||||
profile,
|
||||
tool: name,
|
||||
ok: false,
|
||||
attempts: normalizedError.attempts,
|
||||
durationMs,
|
||||
errorCode: result.error.code,
|
||||
requestId: result.error.requestId ?? null,
|
||||
retryable: result.error.retryable ?? false
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
|
||||
import { ProductierApiError } from "./client.mjs";
|
||||
|
||||
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
const NETWORK_ERROR_PATTERN = /(network|fetch|socket|timeout|timed out|econnreset|enotfound|eai_again)/i;
|
||||
|
||||
function resolveNumberOption(value, envValue, fallback, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
|
||||
const candidate = value ?? envValue;
|
||||
if (candidate === undefined || candidate === null || candidate === "") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(candidate), 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (parsed < min || parsed > max) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function calculateRetryDelayMs(attempt, config, randomFn) {
|
||||
const exponent = Math.max(0, attempt - 1);
|
||||
const baseDelay = Math.min(config.maxDelayMs, config.baseDelayMs * (2 ** exponent));
|
||||
const jitter = config.jitterMs > 0 ? Math.floor(randomFn() * (config.jitterMs + 1)) : 0;
|
||||
return baseDelay + jitter;
|
||||
}
|
||||
|
||||
export class ToolRateLimitError extends Error {
|
||||
constructor(toolName, retryAfterMs) {
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
|
||||
super(`rate limit exceeded for ${toolName}; retry in ${retryAfterSeconds}s`);
|
||||
this.name = "ToolRateLimitError";
|
||||
this.status = 429;
|
||||
this.code = "tool_rate_limited";
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolInvocationError extends Error {
|
||||
constructor(cause, attempts, retryable) {
|
||||
super(cause instanceof Error ? cause.message : "tool invocation failed");
|
||||
this.name = "ToolInvocationError";
|
||||
this.cause = cause;
|
||||
this.attempts = attempts;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRetryableError(error) {
|
||||
if (error instanceof ProductierApiError) {
|
||||
return RETRYABLE_STATUSES.has(Number(error.status));
|
||||
}
|
||||
|
||||
if (error instanceof ToolRateLimitError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error.name === "AbortError" || error.name === "TimeoutError") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return NETWORK_ERROR_PATTERN.test(error.message || "");
|
||||
}
|
||||
|
||||
export function createRuntimeConfig(options = {}) {
|
||||
return {
|
||||
retry: {
|
||||
maxAttempts: resolveNumberOption(
|
||||
options.retryMaxAttempts ?? options.retry?.maxAttempts,
|
||||
process.env.PRODUCTIER_TOOL_RETRY_MAX_ATTEMPTS,
|
||||
3,
|
||||
{ min: 1, max: 10 },
|
||||
),
|
||||
baseDelayMs: resolveNumberOption(
|
||||
options.retryBaseDelayMs ?? options.retry?.baseDelayMs,
|
||||
process.env.PRODUCTIER_TOOL_RETRY_BASE_DELAY_MS,
|
||||
150,
|
||||
{ min: 0, max: 60000 },
|
||||
),
|
||||
maxDelayMs: resolveNumberOption(
|
||||
options.retryMaxDelayMs ?? options.retry?.maxDelayMs,
|
||||
process.env.PRODUCTIER_TOOL_RETRY_MAX_DELAY_MS,
|
||||
2000,
|
||||
{ min: 0, max: 60000 },
|
||||
),
|
||||
jitterMs: resolveNumberOption(
|
||||
options.retryJitterMs ?? options.retry?.jitterMs,
|
||||
process.env.PRODUCTIER_TOOL_RETRY_JITTER_MS,
|
||||
50,
|
||||
{ min: 0, max: 10000 },
|
||||
)
|
||||
},
|
||||
rateLimit: {
|
||||
maxCalls: resolveNumberOption(
|
||||
options.rateLimitMaxCalls ?? options.rateLimit?.maxCalls,
|
||||
process.env.PRODUCTIER_TOOL_RATE_LIMIT_MAX_CALLS,
|
||||
120,
|
||||
{ min: 0, max: 100000 },
|
||||
),
|
||||
windowMs: resolveNumberOption(
|
||||
options.rateLimitWindowMs ?? options.rateLimit?.windowMs,
|
||||
process.env.PRODUCTIER_TOOL_RATE_LIMIT_WINDOW_MS,
|
||||
60000,
|
||||
{ min: 1, max: 3600000 },
|
||||
)
|
||||
},
|
||||
auditLogPath: options.auditLogPath ?? process.env.PRODUCTIER_AUDIT_LOG_PATH ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
export function createRateLimiter(config = {}, options = {}) {
|
||||
const maxCalls = Number(config.maxCalls ?? 0);
|
||||
const windowMs = Number(config.windowMs ?? 60000);
|
||||
const nowFn = typeof options.nowFn === "function" ? options.nowFn : Date.now;
|
||||
|
||||
if (maxCalls < 1 || windowMs < 1) {
|
||||
return {
|
||||
consume() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const buckets = new Map();
|
||||
|
||||
return {
|
||||
consume(toolName) {
|
||||
const now = nowFn();
|
||||
const windowStart = now - windowMs;
|
||||
const existing = buckets.get(toolName) ?? [];
|
||||
const withinWindow = existing.filter(timestamp => timestamp > windowStart);
|
||||
|
||||
if (withinWindow.length >= maxCalls) {
|
||||
const oldestTimestamp = withinWindow[0];
|
||||
const retryAfterMs = Math.max(1, windowMs - (now - oldestTimestamp));
|
||||
throw new ToolRateLimitError(toolName, retryAfterMs);
|
||||
}
|
||||
|
||||
withinWindow.push(now);
|
||||
buckets.set(toolName, withinWindow);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuditLogger(options = {}, runtimeConfig = {}) {
|
||||
const callback = options.auditLogFn;
|
||||
if (typeof callback === "function") {
|
||||
return {
|
||||
async log(entry) {
|
||||
try {
|
||||
await callback(entry);
|
||||
} catch {
|
||||
// Never fail tool execution because audit logging failed.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const auditLogPath = runtimeConfig.auditLogPath;
|
||||
if (!auditLogPath) {
|
||||
return {
|
||||
async log() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async log(entry) {
|
||||
try {
|
||||
await appendFile(auditLogPath, `${JSON.stringify(entry)}\n`, "utf8");
|
||||
} catch {
|
||||
// Never fail tool execution because audit logging failed.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function invokeWithRetry(operation, config = {}, options = {}) {
|
||||
const maxAttempts = Math.max(1, Number(config.maxAttempts ?? 1));
|
||||
const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const randomFn = typeof options.randomFn === "function" ? options.randomFn : Math.random;
|
||||
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
attempts += 1;
|
||||
try {
|
||||
const data = await operation();
|
||||
return { data, attempts };
|
||||
} catch (cause) {
|
||||
const retryable = isRetryableError(cause);
|
||||
const shouldRetry = retryable && attempts < maxAttempts;
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw new ToolInvocationError(cause, attempts, retryable);
|
||||
}
|
||||
|
||||
const delayMs = calculateRetryDelayMs(attempts, config, randomFn);
|
||||
if (delayMs > 0) {
|
||||
await sleepFn(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ToolInvocationError(new Error("tool invocation exhausted retry loop"), maxAttempts, false);
|
||||
}
|
||||
|
||||
export function unwrapInvocationError(error) {
|
||||
if (error instanceof ToolInvocationError) {
|
||||
return {
|
||||
cause: error.cause,
|
||||
attempts: error.attempts,
|
||||
retryable: error.retryable
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cause: error,
|
||||
attempts: 1,
|
||||
retryable: isRetryableError(error)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { optionalTrimmedString, requireStringField, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function normalizePort(rawValue, fieldName, fallback) {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(String(rawValue), 10);
|
||||
if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error(`${fieldName} must be an integer between 1 and 65535`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeBoolean(rawValue, fieldName, fallback) {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof rawValue === "boolean") {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
const value = String(rawValue).trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "off"].includes(value)) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`${fieldName} must be a boolean`);
|
||||
}
|
||||
|
||||
function validateEmail(email, fieldName) {
|
||||
if (!EMAIL_PATTERN.test(email)) {
|
||||
throw new Error(`${fieldName} must be a valid email address`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createConnectMailboxTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_connect_mailbox",
|
||||
description: "Connect an IMAP/SMTP mailbox to a Productier workspace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
label: { type: "string", description: "Optional mailbox label." },
|
||||
email: { type: "string", description: "Mailbox sender email address." },
|
||||
displayName: { type: "string", description: "Optional sender display name." },
|
||||
imapHost: { type: "string", description: "IMAP host (required)." },
|
||||
imapPort: { type: "integer", minimum: 1, maximum: 65535, description: "IMAP port, default 993." },
|
||||
imapUsername: { type: "string", description: "Optional IMAP username (defaults to email)." },
|
||||
imapPassword: { type: "string", description: "IMAP password (required)." },
|
||||
imapUseTls: { type: "boolean", description: "Use TLS for IMAP, default true." },
|
||||
smtpHost: { type: "string", description: "SMTP host (required)." },
|
||||
smtpPort: { type: "integer", minimum: 1, maximum: 65535, description: "SMTP port, default 587." },
|
||||
smtpUsername: { type: "string", description: "Optional SMTP username (defaults to IMAP username)." },
|
||||
smtpPassword: { type: "string", description: "Optional SMTP password (blank reuses IMAP password)." },
|
||||
smtpUseTls: { type: "boolean", description: "Use TLS for SMTP, default true." }
|
||||
},
|
||||
required: ["email", "imapHost", "imapPassword", "smtpHost"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const email = requireStringField(rawInput.email, "email").toLowerCase();
|
||||
validateEmail(email, "email");
|
||||
|
||||
const imapHost = requireStringField(rawInput.imapHost, "imapHost");
|
||||
const imapPassword = requireStringField(rawInput.imapPassword, "imapPassword");
|
||||
const smtpHost = requireStringField(rawInput.smtpHost, "smtpHost");
|
||||
|
||||
const label = optionalTrimmedString(rawInput.label);
|
||||
const displayName = optionalTrimmedString(rawInput.displayName);
|
||||
const imapUsername = optionalTrimmedString(rawInput.imapUsername);
|
||||
const smtpUsername = optionalTrimmedString(rawInput.smtpUsername);
|
||||
const smtpPassword = optionalTrimmedString(rawInput.smtpPassword);
|
||||
const imapPort = normalizePort(rawInput.imapPort, "imapPort", 993);
|
||||
const smtpPort = normalizePort(rawInput.smtpPort, "smtpPort", 587);
|
||||
const imapUseTls = normalizeBoolean(rawInput.imapUseTls, "imapUseTls", true);
|
||||
const smtpUseTls = normalizeBoolean(rawInput.smtpUseTls, "smtpUseTls", true);
|
||||
|
||||
const body = {
|
||||
workspaceSlug,
|
||||
email,
|
||||
imapHost,
|
||||
imapPort,
|
||||
imapPassword,
|
||||
imapUseTls,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpUseTls,
|
||||
...(label ? { label } : {}),
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(imapUsername ? { imapUsername } : {}),
|
||||
...(smtpUsername ? { smtpUsername } : {}),
|
||||
...(smtpPassword ? { smtpPassword } : {})
|
||||
};
|
||||
|
||||
const response = await client.connectMailbox(body);
|
||||
return {
|
||||
connected: true,
|
||||
workspaceSlug,
|
||||
mailbox: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { resolveWorkspaceSlug, requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
export function createCreateBoardGroupTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
const defaultColor = options.defaultBoardGroupColor || process.env.PRODUCTIER_BOARD_GROUP_COLOR_DEFAULT || "slate";
|
||||
|
||||
return {
|
||||
name: "productier_create_board_group",
|
||||
description: "Create a board group in a Productier workspace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
name: { type: "string" },
|
||||
color: { type: "string", description: "Color token. Defaults to PRODUCTIER_BOARD_GROUP_COLOR_DEFAULT or slate." }
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const name = requireStringField(rawInput.name, "name");
|
||||
const color = rawInput.color ? String(rawInput.color) : defaultColor;
|
||||
const response = await client.createBoardGroup({
|
||||
workspaceSlug,
|
||||
name,
|
||||
color
|
||||
});
|
||||
|
||||
return {
|
||||
created: true,
|
||||
boardGroup: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { optionalISODateTime, optionalTrimmedString, resolveWorkspaceSlug, requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
export function createCreateCalendarEventTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_create_calendar_event",
|
||||
description: "Create a Productier calendar event.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
startsAt: { type: "string", description: "ISO date-time." },
|
||||
endsAt: { type: "string", description: "ISO date-time." },
|
||||
linkedTaskId: { type: "string" },
|
||||
color: { type: "string" }
|
||||
},
|
||||
required: ["title", "startsAt", "endsAt"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const title = requireStringField(rawInput.title, "title");
|
||||
const startsAt = optionalISODateTime(requireStringField(rawInput.startsAt, "startsAt"), "startsAt");
|
||||
const endsAt = optionalISODateTime(requireStringField(rawInput.endsAt, "endsAt"), "endsAt");
|
||||
|
||||
if (new Date(endsAt).getTime() < new Date(startsAt).getTime()) {
|
||||
throw new Error("endsAt must be greater than or equal to startsAt");
|
||||
}
|
||||
|
||||
const linkedTaskId = optionalTrimmedString(rawInput.linkedTaskId);
|
||||
const color = optionalTrimmedString(rawInput.color);
|
||||
const body = {
|
||||
workspaceSlug,
|
||||
title,
|
||||
description: rawInput.description === undefined ? "" : String(rawInput.description),
|
||||
startsAt,
|
||||
endsAt,
|
||||
...(linkedTaskId ? { linkedTaskId } : {}),
|
||||
...(color ? { color } : {})
|
||||
};
|
||||
|
||||
const response = await client.createCalendarEvent(body);
|
||||
return {
|
||||
created: true,
|
||||
event: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { resolveWorkspaceSlug, requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
export function createCreateNoteTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_create_note",
|
||||
description: "Create a note in a Productier workspace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
title: { type: "string" },
|
||||
content: { type: "string" }
|
||||
},
|
||||
required: ["title", "content"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const title = requireStringField(rawInput.title, "title");
|
||||
const content = requireStringField(rawInput.content, "content");
|
||||
const response = await client.createNote({
|
||||
workspaceSlug,
|
||||
title,
|
||||
content
|
||||
});
|
||||
|
||||
return {
|
||||
created: true,
|
||||
note: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
optionalISODateTime,
|
||||
optionalString,
|
||||
optionalTrimmedString,
|
||||
requireStringField,
|
||||
resolveWorkspaceSlug
|
||||
} from "./tool-helpers.mjs";
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function parseAddressString(rawAddress, fieldName, index) {
|
||||
const value = String(rawAddress || "").trim();
|
||||
if (!value) {
|
||||
throw new Error(`${fieldName}[${index}] must be a non-empty email or "Name <email>"`);
|
||||
}
|
||||
|
||||
const match = value.match(/^(.*?)<([^>]+)>$/);
|
||||
const name = match ? match[1].trim() : "";
|
||||
const email = (match ? match[2] : value).trim().toLowerCase();
|
||||
if (!EMAIL_PATTERN.test(email)) {
|
||||
throw new Error(`${fieldName}[${index}] must include a valid email address`);
|
||||
}
|
||||
return name ? { name, email } : { email };
|
||||
}
|
||||
|
||||
function parseAddressObject(rawAddress, fieldName, index) {
|
||||
if (!rawAddress || typeof rawAddress !== "object" || Array.isArray(rawAddress)) {
|
||||
throw new Error(`${fieldName}[${index}] must be a string or object`);
|
||||
}
|
||||
const email = String(rawAddress.email || "").trim().toLowerCase();
|
||||
if (!EMAIL_PATTERN.test(email)) {
|
||||
throw new Error(`${fieldName}[${index}].email must be a valid email address`);
|
||||
}
|
||||
const name = optionalTrimmedString(rawAddress.name);
|
||||
return name ? { name, email } : { email };
|
||||
}
|
||||
|
||||
function normalizeAddressList(rawValue, fieldName, required) {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
||||
if (required) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
let values = [];
|
||||
if (typeof rawValue === "string") {
|
||||
values = rawValue
|
||||
.split(",")
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean);
|
||||
} else if (Array.isArray(rawValue)) {
|
||||
values = rawValue;
|
||||
} else {
|
||||
throw new Error(`${fieldName} must be a comma-separated string or an array`);
|
||||
}
|
||||
|
||||
if (required && values.length === 0) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
|
||||
return values.map((item, index) => {
|
||||
if (typeof item === "string") {
|
||||
return parseAddressString(item, fieldName, index);
|
||||
}
|
||||
return parseAddressObject(item, fieldName, index);
|
||||
});
|
||||
}
|
||||
|
||||
export function createCreateOutgoingMailTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_create_outgoing_mail",
|
||||
description: "Queue an outgoing email (send now or schedule) in Productier.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
mailboxId: { type: "string", description: "Mailbox id used for delivery." },
|
||||
to: {
|
||||
description: "Recipients as comma-separated string or array of strings/objects ({email,name}).",
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string" },
|
||||
name: { type: "string" }
|
||||
},
|
||||
required: ["email"],
|
||||
additionalProperties: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
cc: {
|
||||
description: "Optional recipients as comma-separated string or array of strings/objects ({email,name}).",
|
||||
anyOf: [{ type: "string" }, { type: "array" }]
|
||||
},
|
||||
bcc: {
|
||||
description: "Optional recipients as comma-separated string or array of strings/objects ({email,name}).",
|
||||
anyOf: [{ type: "string" }, { type: "array" }]
|
||||
},
|
||||
subject: { type: "string" },
|
||||
textBody: { type: "string" },
|
||||
htmlBody: { type: "string" },
|
||||
scheduledFor: { type: "string", description: "Optional ISO date-time; future values schedule delivery." }
|
||||
},
|
||||
required: ["mailboxId", "to"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const mailboxId = requireStringField(rawInput.mailboxId, "mailboxId");
|
||||
const to = normalizeAddressList(rawInput.to, "to", true);
|
||||
const cc = normalizeAddressList(rawInput.cc, "cc", false);
|
||||
const bcc = normalizeAddressList(rawInput.bcc, "bcc", false);
|
||||
const subject = optionalString(rawInput.subject);
|
||||
const textBody = optionalString(rawInput.textBody);
|
||||
const htmlBody = optionalString(rawInput.htmlBody);
|
||||
const scheduledFor = optionalISODateTime(rawInput.scheduledFor, "scheduledFor");
|
||||
|
||||
const body = {
|
||||
workspaceSlug,
|
||||
mailboxId,
|
||||
to,
|
||||
...(cc.length ? { cc } : {}),
|
||||
...(bcc.length ? { bcc } : {}),
|
||||
...(subject !== undefined ? { subject } : {}),
|
||||
...(textBody !== undefined ? { textBody } : {}),
|
||||
...(htmlBody !== undefined ? { htmlBody } : {}),
|
||||
...(scheduledFor ? { scheduledFor } : {})
|
||||
};
|
||||
|
||||
const response = await client.createOutgoingMail(body);
|
||||
return {
|
||||
queued: true,
|
||||
workspaceSlug,
|
||||
mailboxId,
|
||||
outgoingMail: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { optionalISODateTime, optionalTrimmedString, requireStringField, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
export function createCreateTaskFromMailTool(client, options = {}) {
|
||||
const defaultBoardGroupId = options.defaultBoardGroupId || process.env.PRODUCTIER_BOARD_GROUP_ID_DEFAULT || "";
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_create_task_from_mail",
|
||||
description: "Create a task from a mail message in Productier.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
messageId: { type: "string" },
|
||||
boardGroupId: { type: "string", description: "Board group id. Defaults to PRODUCTIER_BOARD_GROUP_ID_DEFAULT." },
|
||||
title: { type: "string", description: "Optional custom task title." },
|
||||
dueAt: { type: "string", description: "Optional ISO date-time." },
|
||||
color: { type: "string", description: "Optional task color token." },
|
||||
workspaceSlug: { type: "string", description: "Optional workspace slug for output context only." }
|
||||
},
|
||||
required: ["messageId"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const messageId = requireStringField(rawInput.messageId, "messageId");
|
||||
const boardGroupId = String(rawInput.boardGroupId || defaultBoardGroupId).trim();
|
||||
if (!boardGroupId) {
|
||||
throw new Error("boardGroupId is required (or set PRODUCTIER_BOARD_GROUP_ID_DEFAULT)");
|
||||
}
|
||||
|
||||
const dueAt = optionalISODateTime(rawInput.dueAt, "dueAt");
|
||||
const title = optionalTrimmedString(rawInput.title);
|
||||
const color = optionalTrimmedString(rawInput.color);
|
||||
const body = {
|
||||
boardGroupId,
|
||||
...(title ? { title } : {}),
|
||||
...(dueAt ? { dueAt } : {}),
|
||||
...(color ? { color } : {})
|
||||
};
|
||||
|
||||
const response = await client.createTaskFromMail(messageId, body);
|
||||
return {
|
||||
created: true,
|
||||
sourceMessageId: messageId,
|
||||
workspaceSlug: resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug) || null,
|
||||
task: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { optionalISODateTime, resolveWorkspaceSlug, requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
const allowedStatuses = new Set(["todo", "in_progress", "done"]);
|
||||
|
||||
export function createCreateTaskTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
const defaultBoardGroupID = options.defaultBoardGroupId || process.env.PRODUCTIER_BOARD_GROUP_ID_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_create_task",
|
||||
description: "Create a task in a Productier workspace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
boardGroupId: { type: "string", description: "Board group id. Defaults to PRODUCTIER_BOARD_GROUP_ID_DEFAULT." },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
color: { type: "string", description: "Task color token. Defaults to slate." },
|
||||
dueAt: { type: "string", description: "Optional ISO date-time string." }
|
||||
},
|
||||
required: ["title"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
const boardGroupId = resolveWorkspaceSlug(rawInput.boardGroupId, defaultBoardGroupID);
|
||||
const title = requireStringField(rawInput.title, "title");
|
||||
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
if (!boardGroupId) {
|
||||
throw new Error("boardGroupId is required (or set PRODUCTIER_BOARD_GROUP_ID_DEFAULT)");
|
||||
}
|
||||
|
||||
const dueAt = optionalISODateTime(rawInput.dueAt, "dueAt");
|
||||
const body = {
|
||||
workspaceSlug,
|
||||
boardGroupId,
|
||||
title,
|
||||
description: rawInput.description ? String(rawInput.description) : "",
|
||||
color: rawInput.color ? String(rawInput.color) : "slate",
|
||||
...(dueAt ? { dueAt } : {})
|
||||
};
|
||||
|
||||
const response = await client.createTask(body);
|
||||
const task = response?.data ?? null;
|
||||
|
||||
if (task?.status && !allowedStatuses.has(String(task.status))) {
|
||||
throw new Error(`unexpected task status from API: ${task.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
created: task !== null,
|
||||
task
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export function resolveWorkspaceSlug(rawValue, defaultWorkspaceSlug) {
|
||||
return String(rawValue || defaultWorkspaceSlug || "").trim();
|
||||
}
|
||||
|
||||
export function requireStringField(rawValue, fieldName) {
|
||||
const value = String(rawValue || "").trim();
|
||||
if (!value) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalString(rawValue) {
|
||||
if (rawValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return String(rawValue);
|
||||
}
|
||||
|
||||
export function optionalTrimmedString(rawValue) {
|
||||
if (rawValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const value = String(rawValue).trim();
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalISODateTime(rawValue, fieldName) {
|
||||
if (rawValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const value = String(rawValue).trim();
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new Error(`${fieldName} must be a valid ISO date-time string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ensureAtLeastOneField(body, message = "at least one field must be provided") {
|
||||
if (Object.keys(body).length === 0) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLimit(rawValue, defaultValue, maxValue) {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
const parsed = Number.parseInt(String(rawValue), 10);
|
||||
if (Number.isNaN(parsed) || parsed < 1 || parsed > maxValue) {
|
||||
throw new Error(`limit must be an integer between 1 and ${maxValue}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
export function createListBoardGroupsTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_list_board_groups",
|
||||
description: "List board groups for a Productier workspace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
query: { type: "string", description: "Optional case-insensitive name filter." },
|
||||
color: { type: "string", description: "Optional color filter." },
|
||||
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max groups returned, default ${DEFAULT_LIMIT}.` }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
|
||||
const color = rawInput.color ? String(rawInput.color).trim().toLowerCase() : "";
|
||||
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||
|
||||
const response = await client.listBoardGroups(workspaceSlug);
|
||||
const groups = Array.isArray(response?.data) ? response.data : [];
|
||||
const filtered = groups
|
||||
.filter(group => (query ? String(group.name || "").toLowerCase().includes(query) : true))
|
||||
.filter(group => (color ? String(group.color || "").toLowerCase() === color : true))
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
workspaceSlug,
|
||||
count: filtered.length,
|
||||
truncated: groups.length > filtered.length,
|
||||
boardGroups: filtered
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { normalizeLimit, optionalISODateTime, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
export function createListCalendarEventsTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_list_calendar_events",
|
||||
description: "List calendar events for a Productier workspace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
query: { type: "string", description: "Optional case-insensitive title/description filter." },
|
||||
from: { type: "string", description: "Optional ISO date-time lower bound for startsAt." },
|
||||
to: { type: "string", description: "Optional ISO date-time upper bound for startsAt." },
|
||||
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max events returned, default ${DEFAULT_LIMIT}.` }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
|
||||
const from = optionalISODateTime(rawInput.from, "from");
|
||||
const to = optionalISODateTime(rawInput.to, "to");
|
||||
const fromMs = from ? new Date(from).getTime() : undefined;
|
||||
const toMs = to ? new Date(to).getTime() : undefined;
|
||||
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||
|
||||
const response = await client.listCalendarEvents(workspaceSlug);
|
||||
const events = Array.isArray(response?.data) ? response.data : [];
|
||||
const filtered = events
|
||||
.filter(event => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const title = String(event.title || "").toLowerCase();
|
||||
const description = String(event.description || "").toLowerCase();
|
||||
return title.includes(query) || description.includes(query);
|
||||
})
|
||||
.filter(event => {
|
||||
if (!fromMs && !toMs) {
|
||||
return true;
|
||||
}
|
||||
const startsMs = new Date(String(event.startsAt || "")).getTime();
|
||||
if (Number.isNaN(startsMs)) {
|
||||
return false;
|
||||
}
|
||||
if (fromMs && startsMs < fromMs) {
|
||||
return false;
|
||||
}
|
||||
if (toMs && startsMs > toMs) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
workspaceSlug,
|
||||
count: filtered.length,
|
||||
truncated: events.length > filtered.length,
|
||||
events: filtered
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
function messageMatchesQuery(message, query) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const subject = String(message.subject || "").toLowerCase();
|
||||
const snippet = String(message.snippet || "").toLowerCase();
|
||||
const textBody = String(message.textBody || "").toLowerCase();
|
||||
const fromName = String(message.from?.name || "").toLowerCase();
|
||||
const fromEmail = String(message.from?.email || "").toLowerCase();
|
||||
return (
|
||||
subject.includes(query) ||
|
||||
snippet.includes(query) ||
|
||||
textBody.includes(query) ||
|
||||
fromName.includes(query) ||
|
||||
fromEmail.includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
export function createListMailMessagesTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_list_mail_messages",
|
||||
description: "List mail messages for a Productier workspace and optional mailbox.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
mailboxId: { type: "string", description: "Optional mailbox filter." },
|
||||
query: { type: "string", description: "Optional case-insensitive search over sender/subject/snippet/body." },
|
||||
unreadOnly: { type: "boolean", description: "When true, return only unread messages." },
|
||||
linked: { type: "string", enum: ["all", "linked", "unlinked"], description: "Task-link filter, default all." },
|
||||
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max messages returned, default ${DEFAULT_LIMIT}.` }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const mailboxId = rawInput.mailboxId ? String(rawInput.mailboxId).trim() : undefined;
|
||||
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
|
||||
const unreadOnly = rawInput.unreadOnly === true;
|
||||
const linked = rawInput.linked ? String(rawInput.linked).trim() : "all";
|
||||
if (linked !== "all" && linked !== "linked" && linked !== "unlinked") {
|
||||
throw new Error("linked must be one of: all, linked, unlinked");
|
||||
}
|
||||
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||
|
||||
const response = await client.listMailMessages(workspaceSlug, mailboxId);
|
||||
const messages = Array.isArray(response?.data) ? response.data : [];
|
||||
const filtered = messages
|
||||
.filter(message => (unreadOnly ? message.isRead === false : true))
|
||||
.filter(message => {
|
||||
if (linked === "all") {
|
||||
return true;
|
||||
}
|
||||
if (linked === "linked") {
|
||||
return Boolean(message.linkedTaskId);
|
||||
}
|
||||
return !message.linkedTaskId;
|
||||
})
|
||||
.filter(message => messageMatchesQuery(message, query))
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
workspaceSlug,
|
||||
mailboxId: mailboxId || null,
|
||||
count: filtered.length,
|
||||
truncated: messages.length > filtered.length,
|
||||
messages: filtered
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
function mailboxMatchesQuery(mailbox, query) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const label = String(mailbox.label || "").toLowerCase();
|
||||
const email = String(mailbox.email || "").toLowerCase();
|
||||
const displayName = String(mailbox.displayName || "").toLowerCase();
|
||||
const imapHost = String(mailbox.imapHost || "").toLowerCase();
|
||||
const smtpHost = String(mailbox.smtpHost || "").toLowerCase();
|
||||
const syncError = String(mailbox.syncError || "").toLowerCase();
|
||||
return (
|
||||
label.includes(query) ||
|
||||
email.includes(query) ||
|
||||
displayName.includes(query) ||
|
||||
imapHost.includes(query) ||
|
||||
smtpHost.includes(query) ||
|
||||
syncError.includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
export function createListMailboxesTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_list_mailboxes",
|
||||
description: "List connected mailboxes for a Productier workspace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
query: { type: "string", description: "Optional case-insensitive search over mailbox fields." },
|
||||
syncStatus: {
|
||||
type: "string",
|
||||
enum: ["all", "idle", "syncing", "ready", "error"],
|
||||
description: "Sync status filter, default all."
|
||||
},
|
||||
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max mailboxes returned, default ${DEFAULT_LIMIT}.` }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
|
||||
const syncStatus = rawInput.syncStatus ? String(rawInput.syncStatus).trim().toLowerCase() : "all";
|
||||
if (!["all", "idle", "syncing", "ready", "error"].includes(syncStatus)) {
|
||||
throw new Error("syncStatus must be one of: all, idle, syncing, ready, error");
|
||||
}
|
||||
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||
|
||||
const response = await client.listMailboxes(workspaceSlug);
|
||||
const mailboxes = Array.isArray(response?.data) ? response.data : [];
|
||||
const filtered = mailboxes
|
||||
.filter(mailbox => (syncStatus === "all" ? true : String(mailbox.syncStatus) === syncStatus))
|
||||
.filter(mailbox => mailboxMatchesQuery(mailbox, query))
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
workspaceSlug,
|
||||
count: filtered.length,
|
||||
truncated: mailboxes.length > filtered.length,
|
||||
mailboxes: filtered
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
export function createListNotesTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_list_notes",
|
||||
description: "List notes for a Productier workspace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
query: { type: "string", description: "Optional case-insensitive title/content filter." },
|
||||
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max notes returned, default ${DEFAULT_LIMIT}.` }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
|
||||
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||
const response = await client.listNotes(workspaceSlug);
|
||||
const notes = Array.isArray(response?.data) ? response.data : [];
|
||||
const filtered = notes
|
||||
.filter(note => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const title = String(note.title || "").toLowerCase();
|
||||
const content = String(note.content || "").toLowerCase();
|
||||
return title.includes(query) || content.includes(query);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
workspaceSlug,
|
||||
count: filtered.length,
|
||||
truncated: notes.length > filtered.length,
|
||||
notes: filtered
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
function outgoingMatchesQuery(item, query) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const subject = String(item.subject || "").toLowerCase();
|
||||
const textBody = String(item.textBody || "").toLowerCase();
|
||||
const htmlBody = String(item.htmlBody || "").toLowerCase();
|
||||
const recipients = [...(item.to || []), ...(item.cc || []), ...(item.bcc || [])]
|
||||
.flatMap(address => [address?.name, address?.email])
|
||||
.filter(Boolean)
|
||||
.map(value => String(value).toLowerCase());
|
||||
return (
|
||||
subject.includes(query) ||
|
||||
textBody.includes(query) ||
|
||||
htmlBody.includes(query) ||
|
||||
recipients.some(value => value.includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
export function createListOutgoingMailsTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_list_outgoing_mails",
|
||||
description: "List outgoing mail queue items for a Productier workspace and optional mailbox.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
mailboxId: { type: "string", description: "Optional mailbox filter." },
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["all", "queued", "scheduled", "sent", "failed"],
|
||||
description: "Outgoing status filter, default all."
|
||||
},
|
||||
query: { type: "string", description: "Optional case-insensitive search over recipients and content." },
|
||||
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max outgoing records returned, default ${DEFAULT_LIMIT}.` }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const mailboxId = rawInput.mailboxId ? String(rawInput.mailboxId).trim() : undefined;
|
||||
const status = rawInput.status ? String(rawInput.status).trim().toLowerCase() : "all";
|
||||
if (!["all", "queued", "scheduled", "sent", "failed"].includes(status)) {
|
||||
throw new Error("status must be one of: all, queued, scheduled, sent, failed");
|
||||
}
|
||||
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
|
||||
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||
|
||||
const response = await client.listOutgoingMails(workspaceSlug, mailboxId);
|
||||
const outgoing = Array.isArray(response?.data) ? response.data : [];
|
||||
const filtered = outgoing
|
||||
.filter(item => (status === "all" ? true : String(item.status) === status))
|
||||
.filter(item => outgoingMatchesQuery(item, query))
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
workspaceSlug,
|
||||
mailboxId: mailboxId || null,
|
||||
status,
|
||||
count: filtered.length,
|
||||
truncated: outgoing.length > filtered.length,
|
||||
outgoing: filtered
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { normalizeLimit, resolveWorkspaceSlug } from "./tool-helpers.mjs";
|
||||
|
||||
const MAX_LIMIT = 200;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
|
||||
export function createListTasksTool(client, options = {}) {
|
||||
const defaultWorkspaceSlug = options.defaultWorkspaceSlug || process.env.PRODUCTIER_WORKSPACE_SLUG_DEFAULT || "";
|
||||
|
||||
return {
|
||||
name: "productier_list_tasks",
|
||||
description: "List tasks from a Productier workspace with optional status/text filtering.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspaceSlug: { type: "string", description: "Workspace slug. Defaults to PRODUCTIER_WORKSPACE_SLUG_DEFAULT." },
|
||||
status: { type: "string", description: "Optional status filter (for example todo, in_progress, done)." },
|
||||
query: { type: "string", description: "Optional case-insensitive text filter over title and description." },
|
||||
limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT, description: `Max tasks returned, default ${DEFAULT_LIMIT}.` }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const workspaceSlug = resolveWorkspaceSlug(rawInput.workspaceSlug, defaultWorkspaceSlug);
|
||||
if (!workspaceSlug) {
|
||||
throw new Error("workspaceSlug is required (or set PRODUCTIER_WORKSPACE_SLUG_DEFAULT)");
|
||||
}
|
||||
|
||||
const limit = normalizeLimit(rawInput.limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||
const statusFilter = rawInput.status ? String(rawInput.status).trim().toLowerCase() : "";
|
||||
const query = rawInput.query ? String(rawInput.query).trim().toLowerCase() : "";
|
||||
|
||||
const response = await client.listTasks(workspaceSlug);
|
||||
const tasks = Array.isArray(response?.data) ? response.data : [];
|
||||
|
||||
const matched = tasks
|
||||
.filter(task => (statusFilter ? String(task.status || "").toLowerCase() === statusFilter : true))
|
||||
.filter(task => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const title = String(task.title || "").toLowerCase();
|
||||
const description = String(task.description || "").toLowerCase();
|
||||
return title.includes(query) || description.includes(query);
|
||||
});
|
||||
|
||||
const filtered = matched.slice(0, limit);
|
||||
|
||||
return {
|
||||
workspaceSlug,
|
||||
count: filtered.length,
|
||||
truncated: matched.length > limit,
|
||||
tasks: filtered
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function createListWorkspacesTool(client) {
|
||||
return {
|
||||
name: "productier_list_workspaces",
|
||||
description: "List workspaces visible to the authenticated Productier user.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke() {
|
||||
const response = await client.listWorkspaces();
|
||||
const workspaces = Array.isArray(response?.data) ? response.data : [];
|
||||
return {
|
||||
count: workspaces.length,
|
||||
workspaces
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
export function createSyncMailboxTool(client) {
|
||||
return {
|
||||
name: "productier_sync_mailbox",
|
||||
description: "Trigger mailbox sync and return the updated mailbox state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mailboxId: { type: "string", description: "Mailbox id to sync." }
|
||||
},
|
||||
required: ["mailboxId"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const mailboxId = requireStringField(rawInput.mailboxId, "mailboxId");
|
||||
const response = await client.syncMailbox(mailboxId);
|
||||
return {
|
||||
synced: true,
|
||||
mailboxId,
|
||||
mailbox: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ensureAtLeastOneField, requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
function optionalInteger(rawValue, fieldName) {
|
||||
if (rawValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(rawValue), 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`${fieldName} must be an integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function createUpdateBoardGroupTool(client) {
|
||||
return {
|
||||
name: "productier_update_board_group",
|
||||
description: "Update a Productier board group.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
groupId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
color: { type: "string" },
|
||||
order: { type: "integer" }
|
||||
},
|
||||
required: ["groupId"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const groupId = requireStringField(rawInput.groupId, "groupId");
|
||||
const body = Object.fromEntries(
|
||||
Object.entries({
|
||||
name: rawInput.name === undefined ? undefined : String(rawInput.name),
|
||||
color: rawInput.color === undefined ? undefined : String(rawInput.color),
|
||||
order: optionalInteger(rawInput.order, "order")
|
||||
}).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
ensureAtLeastOneField(body, "at least one field must be provided to update");
|
||||
|
||||
const response = await client.updateBoardGroup(groupId, body);
|
||||
return {
|
||||
updated: true,
|
||||
boardGroup: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ensureAtLeastOneField, optionalISODateTime, optionalString, optionalTrimmedString, requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
export function createUpdateCalendarEventTool(client) {
|
||||
return {
|
||||
name: "productier_update_calendar_event",
|
||||
description: "Update a Productier calendar event.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
eventId: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
startsAt: { type: "string", description: "Optional ISO date-time." },
|
||||
endsAt: { type: "string", description: "Optional ISO date-time." },
|
||||
linkedTaskId: { type: "string" },
|
||||
color: { type: "string" }
|
||||
},
|
||||
required: ["eventId"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const eventId = requireStringField(rawInput.eventId, "eventId");
|
||||
const startsAt = optionalISODateTime(rawInput.startsAt, "startsAt");
|
||||
const endsAt = optionalISODateTime(rawInput.endsAt, "endsAt");
|
||||
|
||||
if (startsAt && endsAt && new Date(endsAt).getTime() < new Date(startsAt).getTime()) {
|
||||
throw new Error("endsAt must be greater than or equal to startsAt");
|
||||
}
|
||||
|
||||
const body = Object.fromEntries(
|
||||
Object.entries({
|
||||
title: optionalString(rawInput.title),
|
||||
description: optionalString(rawInput.description),
|
||||
startsAt,
|
||||
endsAt,
|
||||
linkedTaskId: optionalTrimmedString(rawInput.linkedTaskId),
|
||||
color: optionalTrimmedString(rawInput.color)
|
||||
}).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
ensureAtLeastOneField(body, "at least one field must be provided to update");
|
||||
|
||||
const response = await client.updateCalendarEvent(eventId, body);
|
||||
return {
|
||||
updated: true,
|
||||
event: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ensureAtLeastOneField, optionalString, requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
export function createUpdateNoteTool(client) {
|
||||
return {
|
||||
name: "productier_update_note",
|
||||
description: "Update a Productier note.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
noteId: { type: "string" },
|
||||
title: { type: "string" },
|
||||
content: { type: "string" }
|
||||
},
|
||||
required: ["noteId"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const noteId = requireStringField(rawInput.noteId, "noteId");
|
||||
const body = Object.fromEntries(
|
||||
Object.entries({
|
||||
title: optionalString(rawInput.title),
|
||||
content: optionalString(rawInput.content)
|
||||
}).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
ensureAtLeastOneField(body, "at least one field must be provided to update");
|
||||
|
||||
const response = await client.updateNote(noteId, body);
|
||||
return {
|
||||
updated: true,
|
||||
note: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ensureAtLeastOneField, optionalISODateTime, optionalString, requireStringField } from "./tool-helpers.mjs";
|
||||
|
||||
const allowedStatuses = new Set(["todo", "in_progress", "done"]);
|
||||
|
||||
export function createUpdateTaskTool(client) {
|
||||
return {
|
||||
name: "productier_update_task",
|
||||
description: "Update a Productier task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
taskId: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
status: { type: "string", enum: ["todo", "in_progress", "done"] },
|
||||
boardGroupId: { type: "string" },
|
||||
color: { type: "string" },
|
||||
dueAt: { type: "string", description: "Optional ISO date-time string." },
|
||||
scheduledStart: { type: "string", description: "Optional ISO date-time string." },
|
||||
scheduledEnd: { type: "string", description: "Optional ISO date-time string." },
|
||||
assigneeId: { type: "string" }
|
||||
},
|
||||
required: ["taskId"],
|
||||
additionalProperties: false
|
||||
},
|
||||
async invoke(rawInput = {}) {
|
||||
const taskId = requireStringField(rawInput.taskId, "taskId");
|
||||
|
||||
const status = rawInput.status ? String(rawInput.status).trim() : undefined;
|
||||
if (status && !allowedStatuses.has(status)) {
|
||||
throw new Error("status must be one of: todo, in_progress, done");
|
||||
}
|
||||
|
||||
const update = {
|
||||
title: optionalString(rawInput.title),
|
||||
description: optionalString(rawInput.description),
|
||||
status,
|
||||
boardGroupId: optionalString(rawInput.boardGroupId),
|
||||
color: optionalString(rawInput.color),
|
||||
dueAt: optionalISODateTime(rawInput.dueAt, "dueAt"),
|
||||
scheduledStart: optionalISODateTime(rawInput.scheduledStart, "scheduledStart"),
|
||||
scheduledEnd: optionalISODateTime(rawInput.scheduledEnd, "scheduledEnd"),
|
||||
assigneeId: optionalString(rawInput.assigneeId)
|
||||
};
|
||||
|
||||
const body = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined));
|
||||
ensureAtLeastOneField(body, "at least one field must be provided to update");
|
||||
|
||||
const response = await client.updateTask(taskId, body);
|
||||
return {
|
||||
updated: true,
|
||||
task: response?.data ?? null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createPlugin } from "../src/index.mjs";
|
||||
|
||||
function createJSONResponse(payload, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("lists readonly tool metadata", () => {
|
||||
const plugin = createPlugin({
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
const tools = plugin.listTools();
|
||||
assert.equal(tools.length, 2);
|
||||
assert.deepEqual(
|
||||
tools.map(tool => tool.name),
|
||||
["productier_list_workspaces", "productier_list_tasks"],
|
||||
);
|
||||
});
|
||||
|
||||
test("lists standard profile tool metadata", () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
plugin.listTools().map(tool => tool.name),
|
||||
[
|
||||
"productier_list_workspaces",
|
||||
"productier_list_board_groups",
|
||||
"productier_list_tasks",
|
||||
"productier_list_calendar_events",
|
||||
"productier_list_notes",
|
||||
"productier_list_mailboxes",
|
||||
"productier_list_mail_messages",
|
||||
"productier_list_outgoing_mails",
|
||||
"productier_connect_mailbox",
|
||||
"productier_sync_mailbox",
|
||||
"productier_create_board_group",
|
||||
"productier_create_task",
|
||||
"productier_create_calendar_event",
|
||||
"productier_create_note",
|
||||
"productier_create_outgoing_mail",
|
||||
"productier_create_task_from_mail",
|
||||
"productier_update_board_group",
|
||||
"productier_update_task",
|
||||
"productier_update_calendar_event",
|
||||
"productier_update_note"
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns tool_not_found for unknown tool", async () => {
|
||||
const plugin = createPlugin({
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
const result = await plugin.runTool("unknown_tool", {});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "tool_not_found");
|
||||
});
|
||||
|
||||
test("runs productier_list_workspaces", async () => {
|
||||
const plugin = createPlugin({
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
data: [{ id: "ws-1", slug: "personal", name: "Personal HQ", role: "owner", createdAt: "2026-01-01T00:00:00Z" }]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_workspaces", {});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.workspaces[0].slug, "personal");
|
||||
});
|
||||
|
||||
test("runs productier_list_board_groups with query/color filter", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
data: [
|
||||
{ id: "group-1", name: "Inbox", color: "slate", order: 0 },
|
||||
{ id: "group-2", name: "In progress", color: "sky", order: 1 },
|
||||
{ id: "group-3", name: "Done", color: "slate", order: 2 }
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_board_groups", {
|
||||
query: "in",
|
||||
color: "slate",
|
||||
limit: 1
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.workspaceSlug, "personal");
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.truncated, true);
|
||||
assert.equal(result.data.boardGroups[0].id, "group-1");
|
||||
});
|
||||
|
||||
test("runs productier_list_tasks with local filtering", async () => {
|
||||
const plugin = createPlugin({
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
data: [
|
||||
{ id: "task-1", title: "Ship docs", description: "Write release notes", status: "todo" },
|
||||
{ id: "task-2", title: "Review API", description: "Read endpoints", status: "in_progress" },
|
||||
{ id: "task-3", title: "Docs QA", description: "Review docs", status: "todo" }
|
||||
]
|
||||
}),
|
||||
defaultWorkspaceSlug: "personal"
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_tasks", {
|
||||
status: "todo",
|
||||
query: "docs",
|
||||
limit: 1
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.workspaceSlug, "personal");
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.truncated, true);
|
||||
assert.equal(result.data.tasks[0].id, "task-1");
|
||||
});
|
||||
|
||||
test("runs productier_list_calendar_events with date/query filters", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
data: [
|
||||
{ id: "ev-1", title: "Planning", description: "Q2 goals", startsAt: "2026-04-01T10:00:00Z" },
|
||||
{ id: "ev-2", title: "Review", description: "Retro", startsAt: "2026-04-10T10:00:00Z" },
|
||||
{ id: "ev-3", title: "Planning follow-up", description: "", startsAt: "2026-05-01T10:00:00Z" }
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_calendar_events", {
|
||||
query: "planning",
|
||||
from: "2026-04-01T00:00:00Z",
|
||||
to: "2026-04-30T23:59:59Z"
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.events[0].id, "ev-1");
|
||||
});
|
||||
|
||||
test("runs productier_list_notes with query filter", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
data: [
|
||||
{ id: "note-1", title: "Sprint plan", content: "Ship board polish" },
|
||||
{ id: "note-2", title: "Random", content: "groceries" }
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_notes", {
|
||||
query: "sprint"
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.notes[0].id, "note-1");
|
||||
});
|
||||
|
||||
test("runs productier_list_mail_messages with filters", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
data: [
|
||||
{
|
||||
id: "mail-1",
|
||||
subject: "Sprint planning",
|
||||
snippet: "please review",
|
||||
textBody: "board updates",
|
||||
from: { name: "Alex", email: "alex@example.com" },
|
||||
isRead: false,
|
||||
linkedTaskId: undefined
|
||||
},
|
||||
{
|
||||
id: "mail-2",
|
||||
subject: "Random",
|
||||
snippet: "hello",
|
||||
textBody: "",
|
||||
from: { name: "Jamie", email: "jamie@example.com" },
|
||||
isRead: true,
|
||||
linkedTaskId: "task-9"
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_mail_messages", {
|
||||
unreadOnly: true,
|
||||
linked: "unlinked",
|
||||
query: "planning"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.messages[0].id, "mail-1");
|
||||
});
|
||||
|
||||
test("runs productier_list_mailboxes with filters", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
data: [
|
||||
{ id: "mb-1", label: "Team", email: "team@example.com", syncStatus: "ready", syncError: "" },
|
||||
{ id: "mb-2", label: "Alerts", email: "alerts@example.com", syncStatus: "error", syncError: "auth failed" }
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_mailboxes", {
|
||||
query: "auth",
|
||||
syncStatus: "error"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.mailboxes[0].id, "mb-2");
|
||||
});
|
||||
|
||||
test("runs productier_list_outgoing_mails with filters", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
data: [
|
||||
{
|
||||
id: "out-1",
|
||||
mailboxId: "mb-1",
|
||||
status: "queued",
|
||||
subject: "Release draft",
|
||||
textBody: "please review",
|
||||
htmlBody: "",
|
||||
to: [{ email: "alex@example.com" }],
|
||||
cc: [],
|
||||
bcc: []
|
||||
},
|
||||
{
|
||||
id: "out-2",
|
||||
mailboxId: "mb-1",
|
||||
status: "sent",
|
||||
subject: "Invoice",
|
||||
textBody: "",
|
||||
htmlBody: "",
|
||||
to: [{ email: "billing@example.com" }],
|
||||
cc: [],
|
||||
bcc: []
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_outgoing_mails", {
|
||||
status: "queued",
|
||||
query: "release"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.outgoing[0].id, "out-1");
|
||||
});
|
||||
|
||||
test("runs productier_create_board_group", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "group-new",
|
||||
workspaceSlug: "personal",
|
||||
name: "Blocked",
|
||||
color: "slate",
|
||||
order: 3
|
||||
}
|
||||
}, 201);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_create_board_group", {
|
||||
name: "Blocked"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.boardGroup.id, "group-new");
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.match(calls[0].url, /\/v1\/board-groups$/);
|
||||
});
|
||||
|
||||
test("runs productier_create_task and forwards payload", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
defaultBoardGroupId: "group-inbox",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "task-10",
|
||||
workspaceSlug: "personal",
|
||||
boardGroupId: "group-inbox",
|
||||
title: "Write docs",
|
||||
description: "",
|
||||
status: "todo",
|
||||
color: "slate",
|
||||
labelIds: [],
|
||||
attachments: [],
|
||||
comments: [],
|
||||
createdAt: "2026-03-01T08:00:00Z",
|
||||
updatedAt: "2026-03-01T08:00:00Z"
|
||||
}
|
||||
}, 201);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_create_task", {
|
||||
title: "Write docs"
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.task.id, "task-10");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.match(calls[0].url, /\/v1\/tasks$/);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
workspaceSlug: "personal",
|
||||
boardGroupId: "group-inbox",
|
||||
title: "Write docs",
|
||||
description: "",
|
||||
color: "slate"
|
||||
});
|
||||
});
|
||||
|
||||
test("runs productier_create_calendar_event and validates date ordering", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "ev-new",
|
||||
workspaceSlug: "personal",
|
||||
title: "Sync",
|
||||
startsAt: "2026-04-03T10:00:00Z",
|
||||
endsAt: "2026-04-03T11:00:00Z"
|
||||
}
|
||||
}, 201);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_create_calendar_event", {
|
||||
title: "Sync",
|
||||
startsAt: "2026-04-03T10:00:00Z",
|
||||
endsAt: "2026-04-03T11:00:00Z"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.match(calls[0].url, /\/v1\/calendar\/events$/);
|
||||
|
||||
const invalid = await plugin.runTool("productier_create_calendar_event", {
|
||||
title: "Bad",
|
||||
startsAt: "2026-04-03T12:00:00Z",
|
||||
endsAt: "2026-04-03T11:00:00Z"
|
||||
});
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.match(invalid.error.message, /endsAt must be greater than or equal to startsAt/i);
|
||||
});
|
||||
|
||||
test("runs productier_create_note", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "note-new",
|
||||
workspaceSlug: "personal",
|
||||
title: "Changelog",
|
||||
content: "Draft"
|
||||
}
|
||||
}, 201);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_create_note", {
|
||||
title: "Changelog",
|
||||
content: "Draft"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.match(calls[0].url, /\/v1\/notes$/);
|
||||
});
|
||||
|
||||
test("runs productier_connect_mailbox with defaults", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "mb-1",
|
||||
workspaceSlug: "personal",
|
||||
label: "team@example.com",
|
||||
email: "team@example.com"
|
||||
}
|
||||
}, 201);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_connect_mailbox", {
|
||||
email: "team@example.com",
|
||||
imapHost: "imap.example.com",
|
||||
imapPassword: "imap-secret",
|
||||
smtpHost: "smtp.example.com"
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.connected, true);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.match(calls[0].url, /\/v1\/mailboxes$/);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
workspaceSlug: "personal",
|
||||
email: "team@example.com",
|
||||
imapHost: "imap.example.com",
|
||||
imapPort: 993,
|
||||
imapPassword: "imap-secret",
|
||||
imapUseTls: true,
|
||||
smtpHost: "smtp.example.com",
|
||||
smtpPort: 587,
|
||||
smtpUseTls: true
|
||||
});
|
||||
});
|
||||
|
||||
test("runs productier_sync_mailbox", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "mb-1",
|
||||
syncStatus: "ready"
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_sync_mailbox", {
|
||||
mailboxId: "mb-1"
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.synced, true);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.match(calls[0].url, /\/v1\/mailboxes\/mb-1\/sync$/);
|
||||
});
|
||||
|
||||
test("runs productier_create_outgoing_mail and normalizes recipients", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "out-3",
|
||||
workspaceSlug: "personal",
|
||||
mailboxId: "mb-1",
|
||||
status: "scheduled"
|
||||
}
|
||||
}, 201);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_create_outgoing_mail", {
|
||||
mailboxId: "mb-1",
|
||||
to: "Alex <alex@example.com>, jamie@example.com",
|
||||
cc: [{ email: "ops@example.com", name: "Ops" }],
|
||||
subject: "Plan",
|
||||
textBody: "Draft",
|
||||
scheduledFor: "2026-04-10T09:00:00Z"
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.queued, true);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.match(calls[0].url, /\/v1\/mail\/outgoing$/);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
workspaceSlug: "personal",
|
||||
mailboxId: "mb-1",
|
||||
to: [
|
||||
{ name: "Alex", email: "alex@example.com" },
|
||||
{ email: "jamie@example.com" }
|
||||
],
|
||||
cc: [{ name: "Ops", email: "ops@example.com" }],
|
||||
subject: "Plan",
|
||||
textBody: "Draft",
|
||||
scheduledFor: "2026-04-10T09:00:00Z"
|
||||
});
|
||||
});
|
||||
|
||||
test("runs productier_create_task_from_mail with defaults", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
defaultBoardGroupId: "group-inbox",
|
||||
defaultWorkspaceSlug: "personal",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "task-from-mail",
|
||||
workspaceSlug: "personal",
|
||||
boardGroupId: "group-inbox",
|
||||
title: "From mail"
|
||||
}
|
||||
}, 201);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_create_task_from_mail", {
|
||||
messageId: "mail-1"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data.sourceMessageId, "mail-1");
|
||||
assert.equal(result.data.workspaceSlug, "personal");
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.match(calls[0].url, /\/v1\/mail\/messages\/mail-1\/create-task$/);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
boardGroupId: "group-inbox"
|
||||
});
|
||||
});
|
||||
|
||||
test("runs productier_update_board_group with partial fields", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: { id: "group-2", name: "Doing", color: "sky", order: 1 }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_update_board_group", {
|
||||
groupId: "group-2",
|
||||
name: "Doing"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls[0].init.method, "PATCH");
|
||||
assert.match(calls[0].url, /\/v1\/board-groups\/group-2$/);
|
||||
});
|
||||
|
||||
test("runs productier_update_task with partial fields", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: {
|
||||
id: "task-11",
|
||||
workspaceSlug: "personal",
|
||||
boardGroupId: "group-inbox",
|
||||
title: "Renamed",
|
||||
description: "Body",
|
||||
status: "done",
|
||||
color: "sky",
|
||||
labelIds: [],
|
||||
attachments: [],
|
||||
comments: [],
|
||||
createdAt: "2026-03-01T08:00:00Z",
|
||||
updatedAt: "2026-03-01T08:00:00Z"
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_update_task", {
|
||||
taskId: "task-11",
|
||||
status: "done",
|
||||
title: "Renamed"
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.method, "PATCH");
|
||||
assert.match(calls[0].url, /\/v1\/tasks\/task-11$/);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
title: "Renamed",
|
||||
status: "done"
|
||||
});
|
||||
});
|
||||
|
||||
test("runs productier_update_calendar_event and validates empty patch", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: { id: "ev-1", title: "Updated", startsAt: "2026-04-03T10:00:00Z", endsAt: "2026-04-03T11:00:00Z" }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_update_calendar_event", {
|
||||
eventId: "ev-1",
|
||||
title: "Updated"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls[0].init.method, "PATCH");
|
||||
assert.match(calls[0].url, /\/v1\/calendar\/events\/ev-1$/);
|
||||
|
||||
const invalid = await plugin.runTool("productier_update_calendar_event", { eventId: "ev-1" });
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.match(invalid.error.message, /at least one field/i);
|
||||
});
|
||||
|
||||
test("runs productier_update_note and validates empty patch", async () => {
|
||||
const calls = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return createJSONResponse({
|
||||
data: { id: "note-1", title: "Renamed", content: "Body" }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_update_note", {
|
||||
noteId: "note-1",
|
||||
title: "Renamed"
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls[0].init.method, "PATCH");
|
||||
assert.match(calls[0].url, /\/v1\/notes\/note-1$/);
|
||||
|
||||
const invalid = await plugin.runTool("productier_update_note", { noteId: "note-1" });
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.match(invalid.error.message, /at least one field/i);
|
||||
});
|
||||
|
||||
test("maps backend structured errors", async () => {
|
||||
const plugin = createPlugin({
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse({
|
||||
error: {
|
||||
code: "unauthorized",
|
||||
message: "authentication required",
|
||||
requestId: "req-123"
|
||||
}
|
||||
}, 401)
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "unauthorized");
|
||||
assert.equal(result.error.status, 401);
|
||||
assert.equal(result.error.requestId, "req-123");
|
||||
});
|
||||
|
||||
test("returns validation error when workspace slug is missing", async () => {
|
||||
const plugin = createPlugin({
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_tasks", {});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "tool_execution_error");
|
||||
assert.match(result.error.message, /workspaceSlug is required/i);
|
||||
});
|
||||
|
||||
test("returns validation error for invalid update task status", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_update_task", {
|
||||
taskId: "task-1",
|
||||
status: "bad_status"
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "tool_execution_error");
|
||||
assert.match(result.error.message, /status must be one of/i);
|
||||
});
|
||||
|
||||
test("returns validation error for invalid mail linked filter", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_mail_messages", {
|
||||
workspaceSlug: "personal",
|
||||
linked: "bad-value"
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "tool_execution_error");
|
||||
assert.match(result.error.message, /linked must be one of/i);
|
||||
});
|
||||
|
||||
test("returns validation error for invalid mailbox sync filter", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_mailboxes", {
|
||||
workspaceSlug: "personal",
|
||||
syncStatus: "bad-value"
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "tool_execution_error");
|
||||
assert.match(result.error.message, /syncStatus must be one of/i);
|
||||
});
|
||||
|
||||
test("returns validation error for invalid outgoing status filter", async () => {
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_outgoing_mails", {
|
||||
workspaceSlug: "personal",
|
||||
status: "bad-value"
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "tool_execution_error");
|
||||
assert.match(result.error.message, /status must be one of/i);
|
||||
});
|
||||
|
||||
test("retries transient backend errors and succeeds", async () => {
|
||||
let calls = 0;
|
||||
const plugin = createPlugin({
|
||||
retryMaxAttempts: 3,
|
||||
retryBaseDelayMs: 0,
|
||||
retryMaxDelayMs: 0,
|
||||
retryJitterMs: 0,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return createJSONResponse(
|
||||
{
|
||||
error: {
|
||||
code: "service_unavailable",
|
||||
message: "temporary outage"
|
||||
}
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
return createJSONResponse({
|
||||
data: [{ id: "ws-1", slug: "personal", name: "Personal HQ", role: "owner", createdAt: "2026-01-01T00:00:00Z" }]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_workspaces", {});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.meta.attempts, 2);
|
||||
});
|
||||
|
||||
test("returns retry metadata when retries are exhausted", async () => {
|
||||
const plugin = createPlugin({
|
||||
retryMaxAttempts: 2,
|
||||
retryBaseDelayMs: 0,
|
||||
retryMaxDelayMs: 0,
|
||||
retryJitterMs: 0,
|
||||
fetchImpl: async () =>
|
||||
createJSONResponse(
|
||||
{
|
||||
error: {
|
||||
code: "service_unavailable",
|
||||
message: "temporary outage"
|
||||
}
|
||||
},
|
||||
503,
|
||||
)
|
||||
});
|
||||
|
||||
const result = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "service_unavailable");
|
||||
assert.equal(result.error.status, 503);
|
||||
assert.equal(result.error.retryable, true);
|
||||
assert.equal(result.error.attempts, 2);
|
||||
});
|
||||
|
||||
test("enforces per-tool rate limits", async () => {
|
||||
let now = 1_000;
|
||||
let fetchCalls = 0;
|
||||
const plugin = createPlugin({
|
||||
rateLimitMaxCalls: 1,
|
||||
rateLimitWindowMs: 60_000,
|
||||
nowFn: () => now,
|
||||
fetchImpl: async () => {
|
||||
fetchCalls += 1;
|
||||
return createJSONResponse({ data: [] });
|
||||
}
|
||||
});
|
||||
|
||||
const first = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const second = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
|
||||
assert.equal(second.ok, false);
|
||||
assert.equal(second.error.code, "tool_rate_limited");
|
||||
assert.equal(second.error.status, 429);
|
||||
assert.ok(second.error.retryAfterMs >= 1);
|
||||
|
||||
now += 60_001;
|
||||
const third = await plugin.runTool("productier_list_tasks", { workspaceSlug: "personal" });
|
||||
assert.equal(third.ok, true);
|
||||
assert.equal(fetchCalls, 2);
|
||||
});
|
||||
|
||||
test("writes structured audit entries for success and failure", async () => {
|
||||
const auditEntries = [];
|
||||
const plugin = createPlugin({
|
||||
profile: "standard",
|
||||
auditLogFn: async entry => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
fetchImpl: async () => createJSONResponse({ data: [] })
|
||||
});
|
||||
|
||||
const success = await plugin.runTool("productier_list_workspaces", {});
|
||||
assert.equal(success.ok, true);
|
||||
|
||||
const failure = await plugin.runTool("productier_create_task", {});
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.error.code, "tool_execution_error");
|
||||
|
||||
assert.equal(auditEntries.length, 2);
|
||||
assert.equal(auditEntries[0].tool, "productier_list_workspaces");
|
||||
assert.equal(auditEntries[0].ok, true);
|
||||
assert.equal(auditEntries[1].tool, "productier_create_task");
|
||||
assert.equal(auditEntries[1].ok, false);
|
||||
assert.equal(auditEntries[1].errorCode, "tool_execution_error");
|
||||
});
|
||||
Reference in New Issue
Block a user