import { KintoneRestAPIClient } from "@kintone/rest-api-client"; import type { KintoneRestAPIError } from "@kintone/rest-api-client"; import type { DomainWithPassword } from "@shared/types/domain"; import type { KintoneApp, AppDetail, FileContent, AppCustomizationConfig, KintoneApiError, JSFileConfig, CSSFileConfig, } from "@shared/types/kintone"; /** * Custom error class for Kintone API errors */ export class KintoneError extends Error { public readonly code?: string; public readonly id?: string; public readonly statusCode?: number; constructor( message: string, apiError?: KintoneApiError, statusCode?: number, ) { super(message); this.name = "KintoneError"; this.code = apiError?.code; this.id = apiError?.id; this.statusCode = statusCode; } } // Use typeof to get SDK method return types type KintoneClient = KintoneRestAPIClient; type AppResponse = ReturnType; type AppsResponse = ReturnType; type AppCustomizeResponse = ReturnType; /** * Kintone REST API Client */ export class SelfKintoneClient { private client: KintoneRestAPIClient; private domain: string; constructor(domainConfig: DomainWithPassword) { this.domain = domainConfig.domain; const auth = domainConfig.authType === "api_token" ? { apiToken: domainConfig.apiToken || "" } : { username: domainConfig.username, password: domainConfig.password, }; this.client = new KintoneRestAPIClient({ baseUrl: `https://${domainConfig.domain}`, auth, }); } private convertError(error: unknown): KintoneError { if (error && typeof error === "object" && "code" in error) { const apiError = error as KintoneRestAPIError; return new KintoneError( apiError.message, { code: apiError.code, message: apiError.message, id: apiError.id }, apiError.status, ); } if (error instanceof Error) { return new KintoneError(error.message); } return new KintoneError("Unknown error occurred"); } private async withErrorHandling(operation: () => Promise): Promise { try { return await operation(); } catch (error) { throw this.convertError(error); } } private mapApp(app: Awaited): KintoneApp { return { appId: app.appId, name: app.name, code: app.code, spaceId: app.spaceId || undefined, createdAt: app.createdAt, creator: app.creator, modifiedAt: app.modifiedAt, modifier: app.modifier, }; } private mapResource( resource: | Awaited["desktop"]["js"][number] | Awaited["desktop"]["css"][number], ): JSFileConfig | CSSFileConfig { if (resource.type === "FILE") { return { type: "FILE", file: { fileKey: resource.file.fileKey, name: resource.file.name, size: parseInt(resource.file.size, 10), }, }; } return { type: "URL", url: resource.url, }; } private buildCustomizeSection( js?: JSFileConfig[], css?: CSSFileConfig[], ): Parameters[0]["desktop"] { if (!js && !css) return undefined; const mapItem = (item: JSFileConfig | CSSFileConfig) => { if (item.type === "FILE" && item.file) { return { type: "FILE" as const, file: { fileKey: item.file.fileKey } }; } return { type: "URL" as const, url: item.url || "" }; }; return { js: js?.map(mapItem), css: css?.map(mapItem), }; } // ==================== App APIs ==================== /** * Get all apps with pagination support * Fetches all apps by making multiple requests if needed */ async getApps(options?: { limit?: number; offset?: number; }): Promise { return this.withErrorHandling(async () => { // If pagination options provided, use them directly if (options?.limit !== undefined || options?.offset !== undefined) { const params: { limit?: number; offset?: number } = {}; if (options.limit) params.limit = options.limit; if (options.offset) params.offset = options.offset; const response = await this.client.app.getApps(params); return response.apps.map((app) => this.mapApp(app)); } // Otherwise, fetch all apps (pagination handled internally) const allApps: Awaited["apps"] = []; const limit = 100; // Max allowed by Kintone API let offset = 0; let hasMore = true; while (hasMore) { const response = await this.client.app.getApps({ limit, offset }); allApps.push(...response.apps); // If we got fewer than limit, we've reached the end if (response.apps.length < limit) { hasMore = false; } else { offset += limit; } } return allApps.map((app) => this.mapApp(app)); }); } async getAppDetail(appId: string): Promise { return this.withErrorHandling(async () => { const [appInfo, customizeInfo] = await Promise.all([ this.client.app.getApp({ id: appId }), this.client.app.getAppCustomize({ app: appId }), ]); const customization: AppCustomizationConfig = { javascript: { pc: customizeInfo.desktop.js?.map((js) => this.mapResource(js)) || [], mobile: customizeInfo.mobile.js?.map((js) => this.mapResource(js)) || [], }, stylesheet: { pc: customizeInfo.desktop.css?.map((css) => this.mapResource(css)) || [], mobile: customizeInfo.mobile.css?.map((css) => this.mapResource(css)) || [], }, plugins: [], }; return { appId: appInfo.appId, name: appInfo.name, code: appInfo.code, description: appInfo.description, spaceId: appInfo.spaceId || undefined, createdAt: appInfo.createdAt, creator: appInfo.creator, modifiedAt: appInfo.modifiedAt, modifier: appInfo.modifier, customization, }; }); } // ==================== File APIs ==================== async getFileContent(fileKey: string): Promise { return this.withErrorHandling(async () => { const data = await this.client.file.downloadFile({ fileKey }); const buffer = Buffer.from(data); const content = buffer.toString("base64"); return { fileKey, name: fileKey, size: buffer.byteLength, mimeType: "application/octet-stream", content, }; }); } async uploadFile( content: string | Buffer, fileName: string, _mimeType?: string, ): Promise<{ fileKey: string }> { return this.withErrorHandling(async () => { const response = await this.client.file.uploadFile({ file: { name: fileName, data: content }, }); return { fileKey: response.fileKey }; }); } // ==================== Deploy APIs ==================== async updateAppCustomize( appId: string, config: AppCustomizationConfig, ): Promise { return this.withErrorHandling(async () => { await this.client.app.updateAppCustomize({ app: appId, desktop: this.buildCustomizeSection( config.javascript?.pc, config.stylesheet?.pc, ), mobile: this.buildCustomizeSection( config.javascript?.mobile, config.stylesheet?.mobile, ), scope: "ALL", }); }); } async deployApp(appId: string): Promise { return this.withErrorHandling(async () => { await this.client.app.deployApp({ apps: [{ app: appId }] }); }); } async getDeployStatus( appId: string, ): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> { return this.withErrorHandling(async () => { const response = await this.client.app.getDeployStatus({ apps: [appId] }); return response.apps[0]?.status || "FAIL"; }); } // ==================== Utility Methods ==================== async testConnection(): Promise<{ success: boolean; error?: string }> { try { // Use limit=1 to minimize data transfer for faster connection testing await this.client.app.getApps({ limit: 1 }); return { success: true }; } catch (error) { return { success: false, error: error instanceof KintoneError ? error.message : "Connection failed", }; } } getDomain(): string { return this.domain; } } export function createKintoneClient( domain: DomainWithPassword, ): SelfKintoneClient { return new SelfKintoneClient(domain); }