first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
+11
View File
@@ -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 arent 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;
}
+4
View File
@@ -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';
+224
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export * from "./client";
export { createClient, createConfig } from "./client/client";
File diff suppressed because it is too large Load Diff
+78
View File
@@ -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
+14
View File
@@ -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"
}
}
+55
View File
@@ -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);
});
+152
View File
@@ -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
});
}
};
}
+233
View File
@@ -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");
});