diff --git a/AGENTS.md b/AGENTS.md index 14aeea1..bb64d95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,81 +1,59 @@ # AGENTS.md -Kintone Customize Manager 项目开发指南。 +Kintone Customize Manager - Electron + React 应用,用于管理 Kintone 自定义资源。 ## 1. 构建命令 -### 开发 - ```bash -# 启动开发服务器(HMR + 热重载) +# 开发(HMR + 热重载) npm run dev # 类型检查 npx tsc --noEmit -``` -### 构建 - -```bash -# 构建生产版本 +# 构建 npm run build -# 打包应用 +# 打包 npm run package:win # Windows npm run package:mac # macOS npm run package:linux # Linux + +# 代码质量 +npm run lint # ESLint 检查 +npm run format # Prettier 格式化 ``` -### 代码质量 +**注意**: 无测试框架,项目暂无测试文件。 -```bash -# ESLint 检查 -npm run lint - -# 格式化代码 -npm run format -``` - -## 2. 项目结构 +## 2. 项目架构 ``` -kintone-customize-manager/ -├── src/ -│ ├── main/ # Electron 主进程 -│ │ ├── index.ts # 主进程入口 -│ │ ├── ipc-handlers.ts # IPC 通信处理 -│ │ ├── storage.ts # 文件系统操作 -│ │ ├── kintone-api.ts # Kintone API 封装 -│ │ ├── updater.ts # 自动更新逻辑 -│ │ └── config.ts # 配置管理 -│ ├── preload/ # Preload 脚本 -│ │ └── index.ts # 暴露 API 到渲染进程 -│ └── renderer/ # React 渲染进程 -│ └── src/ -│ ├── main.tsx # React 入口 -│ ├── App.tsx # 根组件 -│ ├── components/ # React 组件 -│ ├── hooks/ # 自定义 Hooks -│ ├── stores/ # Zustand Stores -│ ├── utils/ # 工具函数 -│ └── types/ # TypeScript 类型 -├── resources/ # 应用资源(图标等) -└── build/ # 构建配置 +src/ +├── main/ # Electron 主进程 +│ ├── index.ts # 入口,创建窗口 +│ ├── ipc-handlers.ts # IPC 处理器(所有通信入口) +│ ├── storage.ts # 文件存储 + 密码加密 +│ └── kintone-api.ts # Kintone REST API 封装 +├── preload/ # Preload 脚本 +│ ├── index.ts # 暴露 API 到渲染进程 +│ └── index.d.ts # 类型声明 +└── renderer/ # React 渲染进程 + └── src/ + ├── main.tsx # React 入口 + ├── App.tsx # 根组件 + ├── components/ # React 组件 + ├── stores/ # Zustand Stores + └── types/ # TypeScript 类型 ``` ## 3. 路径别名 -| 别名 | 路径 | -|------|------| +| 别名 | 路径 | +| ------------- | -------------------- | | `@renderer/*` | `src/renderer/src/*` | -| `@main/*` | `src/main/*` | -| `@preload/*` | `src/preload/*` | - -使用示例: -```typescript -import { useStore } from '@renderer/stores' -import { ipcHandler } from '@main/ipc-handlers' -``` +| `@main/*` | `src/main/*` | +| `@preload/*` | `src/preload/*` | ## 4. 代码风格 @@ -83,67 +61,54 @@ import { ipcHandler } from '@main/ipc-handlers' ```typescript // 1. Node.js 内置模块 -import { join } from 'path' -import { app, BrowserWindow } from 'electron' +import { join } from "path"; +import { app, BrowserWindow } from "electron"; // 2. 第三方库 -import React, { useState, useEffect } from 'react' -import { Button, Layout } from 'antd' +import React, { useState, useEffect } from "react"; +import { Button, Layout } from "antd"; -// 3. 项目内部模块(使用别名) -import { useDomainStore } from '@renderer/stores' -import { formatDate } from '@renderer/utils' +// 3. 项目内部模块(别名) +import { useDomainStore } from "@renderer/stores"; // 4. 相对导入 -import './styles.css' +import "./styles.css"; ``` ### 命名规范 -| 类型 | 规范 | 示例 | -|------|------|------| -| 组件文件 | PascalCase | `DomainManager.tsx` | -| 工具函数文件 | camelCase | `formatDate.ts` | -| Store 文件 | camelCase + Store | `domainStore.ts` | -| 类型文件 | camelCase | `types.ts` | -| 组件名 | PascalCase | `DomainManager` | -| 函数/变量 | camelCase | `handleSubmit` | -| 常量 | UPPER_SNAKE_CASE | `MAX_FILE_SIZE` | -| 类型/接口 | PascalCase | `DomainConfig` | +- 组件文件/名: `PascalCase` (e.g., `DomainManager.tsx`) +- 工具函数文件: `camelCase` (e.g., `formatDate.ts`) +- Store 文件: `camelCase + Store` (e.g., `domainStore.ts`) +- 函数/变量: `camelCase` (e.g., `handleSubmit`) +- 常量: `UPPER_SNAKE_CASE` (e.g., `MAX_FILE_SIZE`) +- 类型/接口: `PascalCase` (e.g., `DomainConfig`) ### TypeScript 规范 ```typescript -// 显式类型定义 +// 显式类型定义,避免 any interface DomainConfig { - id: string - name: string - domain: string - username: string - authType: 'password' | 'api_token' - createdAt: string + id: string; + name: string; + authType: "password" | "api_token"; // 使用字面量联合类型 } -// 函数返回类型 -function createWindow(): void { } +// 异步函数返回 Promise +async function fetchDomains(): Promise {} -// 异步函数 -async function fetchDomains(): Promise { } - -// 避免使用 any,使用 unknown 或具体类型 +// 使用类型守卫处理 unknown function parseResponse(data: unknown): DomainConfig { - // 类型守卫 - if (typeof data !== 'object' || data === null) { - throw new Error('Invalid response') + if (typeof data !== "object" || data === null) { + throw new Error("Invalid response"); } - return data as DomainConfig + return data as DomainConfig; } ``` ### React 组件规范 ```typescript -// 函数组件优先 interface DomainListProps { domains: Domain[] onSelect: (domain: Domain) => void @@ -151,21 +116,15 @@ interface DomainListProps { function DomainList({ domains, onSelect }: DomainListProps) { const [selectedId, setSelectedId] = useState(null) - + // Hooks 放在组件顶部 - const { token } = theme.useToken() - // 事件处理函数使用 useCallback const handleClick = useCallback((domain: Domain) => { setSelectedId(domain.id) onSelect(domain) }, [onSelect]) - - return ( -
- {/* JSX */} -
- ) + + return
...
} export default DomainList @@ -174,123 +133,65 @@ export default DomainList ### Zustand Store 规范 ```typescript -import { create } from 'zustand' -import { persist } from 'zustand/middleware' +import { create } from "zustand"; +import { persist } from "zustand/middleware"; interface DomainState { - domains: Domain[] - currentDomain: Domain | null - addDomain: (domain: Domain) => void - removeDomain: (id: string) => void - setCurrentDomain: (domain: Domain | null) => void + domains: Domain[]; + addDomain: (domain: Domain) => void; } export const useDomainStore = create()( persist( (set) => ({ domains: [], - currentDomain: null, - addDomain: (domain) => set((state) => ({ - domains: [...state.domains, domain] - })), - removeDomain: (id) => set((state) => ({ - domains: state.domains.filter(d => d.id !== id) - })), - setCurrentDomain: (domain) => set({ currentDomain: domain }) + addDomain: (domain) => + set((state) => ({ + domains: [...state.domains, domain], + })), }), - { name: 'domain-storage' } - ) -) + { name: "domain-storage" }, + ), +); ``` -## 5. 错误处理 +## 5. IPC 通信规范 -### 主进程错误处理 +### Result 模式(所有 IPC 返回) ```typescript -// IPC 处理错误 -ipcMain.handle('fetch-domains', async () => { - try { - const domains = await fetchDomainsFromApi() - return { success: true, data: domains } - } catch (error) { - console.error('Failed to fetch domains:', error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - } - } -}) +type Result = { success: true; data: T } | { success: false; error: string }; ``` -### 渲染进程错误处理 - -```typescript -// 使用 Result 模式 -type Result = - | { success: true; data: T } - | { success: false; error: string } - -async function handleFetch(): Promise> { - try { - const result = await window.api.fetchDomains() - if (!result.success) { - message.error(result.error) - } - return result - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unknown error' - message.error(errorMsg) - return { success: false, error: errorMsg } - } -} -``` - -## 6. IPC 通信规范 - ### Preload 暴露 API ```typescript -// preload/index.ts const api = { - // 使用 invoke 进行双向通信 - fetchDomains: () => ipcRenderer.invoke('fetch-domains'), - - // 使用 send 进行单向通信 - notify: (message: string) => ipcRenderer.send('notify', message), - - // 监听事件 - onUpdate: (callback: (info: UpdateInfo) => void) => - ipcRenderer.on('update-available', (_, info) => callback(info)) -} + // 双向通信使用 invoke + fetchDomains: () => ipcRenderer.invoke("fetch-domains"), + // 单向通信使用 send + notify: (message: string) => ipcRenderer.send("notify", message), +}; -contextBridge.exposeInMainWorld('api', api) +contextBridge.exposeInMainWorld("api", api); ``` ### 类型定义 ```typescript // preload/index.d.ts -interface ElectronAPI { - fetchDomains: () => Promise> - notify: (message: string) => void - onUpdate: (callback: (info: UpdateInfo) => void) => void -} - declare global { interface Window { - electron: ElectronAPI - api: ElectronAPI + api: ElectronAPI; } } ``` -## 7. UI 组件规范 +## 6. UI 组件规范 -### 使用 LobeHub UI + Ant Design +使用 **Ant Design 6** + **antd-style**(不用 Tailwind): ```typescript -// 使用 antd-style 进行样式 import { createStyles } from 'antd-style' const useStyles = createStyles(({ token, css }) => ({ @@ -307,59 +208,85 @@ function MyComponent() { } ``` -### 国际化 +国际化:使用中文默认 `import zhCN from 'antd/locale/zh_CN'` -```typescript -// 使用中文默认 -import zhCN from 'antd/locale/zh_CN' - - - - -``` - -## 8. 安全规范 +## 7. 安全规范 ### 密码存储 ```typescript -// 使用 safeStorage 加密存储 -import { safeStorage } from 'electron' +import { safeStorage } from "electron"; // 加密 -const encrypted = safeStorage.encryptString(password) - +const encrypted = safeStorage.encryptString(password); // 解密 -const decrypted = safeStorage.decryptString(encrypted) +const decrypted = safeStorage.decryptString(encrypted); ``` -### CSP 配置 +### WebPreferences -```html - +必须启用:`contextIsolation: true`, `nodeIntegration: false`, `sandbox: false` + +## 8. 错误处理 + +```typescript +// 主进程 IPC +ipcMain.handle("fetch-domains", async () => { + try { + const domains = await fetchDomainsFromApi(); + return { success: true, data: domains }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +}); + +// 渲染进程调用 +const result = await window.api.fetchDomains(); +if (!result.success) { + message.error(result.error); +} ``` ## 9. fnm 环境配置 -所有 npm/npx 命令需要先加载 fnm 环境: +所有 npm/npx 命令需加载 fnm 环境: ```bash -# 方式一:使用 wrapper 脚本 -~/.config/opencode/node-fnm-wrapper.sh npm run dev - -# 方式二:手动加载 eval "$(fnm env --use-on-cd)" && npm run dev ``` ## 10. 注意事项 -1. **ESM Only**: LobeHub UI 仅支持 ESM,确保 `tsconfig.json` 中 `"module": "ESNext"` -2. **React 19**: 必须使用 `@types/react@^19.0.0` 和 `@types/react-dom@^19.0.0` -3. **CSS 方案**: 使用 `antd-style`,不使用 Tailwind CSS -4. **Context Isolation**: 必须启用 `contextIsolation: true` -5. **禁止类型断言**: 避免使用 `as any`,优先使用类型守卫 \ No newline at end of file +1. **ESM Only**: LobeHub UI 仅支持 ESM +2. **React 19**: 使用 `@types/react@^19.0.0` +3. **CSS 方案**: 使用 `antd-style`,禁止 Tailwind +4. **禁止 `as any`**: 使用类型守卫或 `unknown` +5. **函数组件优先**: 禁止 class 组件 + +## 11. MVP Phase - Breaking Changes + +**This is MVP phase - breaking changes are acceptable for better design.** However, you MUST: + +- **Explain what will break and why**: Document which components/APIs/workflows will be affected +- **Compare old vs new approach**: Show the differences and improvements +- **Document the tradeoffs**: What are the pros and cons of this change +- **Ask for confirmation**: If the change is significant (affects multiple modules or core architecture) + +**Examples of acceptable breaking changes during MVP**: + +- Refactoring data structures for better type safety +- Changing IPC communication patterns +- Restructuring component hierarchy +- Modifying store architecture +- Updating API interfaces + +**Process for breaking changes**: + +1. Identify the change and its impact scope +2. Document the breaking change in code comments +3. Explain the reasoning and benefits +4. If significant, ask user for confirmation before implementing +5. Update related documentation after implementation diff --git a/package-lock.json b/package-lock.json index a1b0aae..36b91e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@codemirror/view": "^6.36.0", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", + "@kintone/rest-api-client": "^6.1.2", "@lobehub/ui": "^5.5.0", "@uiw/react-codemirror": "^4.23.0", "antd": "^6.1.0", @@ -2383,6 +2384,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kintone/rest-api-client": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/@kintone/rest-api-client/-/rest-api-client-6.1.2.tgz", + "integrity": "sha512-/6RMKD/cNg8KOfQDXLOTzKkfG+EAKuU6czgVF5b5c4Na9uFB9BENmdg7e1eV3qNwszfIt7UpONfT3apCiw2R3Q==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.2", + "core-js": "^3.46.0", + "form-data": "^4.0.5", + "js-base64": "^3.7.8", + "mime": "^3.0.0", + "qs": "^6.14.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@kintone/rest-api-client/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@lezer/common": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz", @@ -5909,7 +5939,6 @@ "version": "0.4.0", "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5957,6 +5986,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -6355,7 +6395,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6369,7 +6408,6 @@ "version": "1.0.4", "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6707,7 +6745,6 @@ "version": "1.0.8", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6841,6 +6878,17 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", @@ -7671,7 +7719,6 @@ "version": "1.0.0", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7919,7 +7966,6 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -8409,7 +8455,6 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8419,7 +8464,6 @@ "version": "1.3.0", "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8458,7 +8502,6 @@ "version": "1.1.1", "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -8471,7 +8514,6 @@ "version": "2.1.0", "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9253,6 +9295,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", @@ -9312,7 +9374,6 @@ "version": "4.0.5", "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -9487,7 +9548,6 @@ "version": "1.3.0", "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -9512,7 +9572,6 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -9704,7 +9763,6 @@ "version": "1.2.0", "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9806,7 +9864,6 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9819,7 +9876,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -10991,6 +11047,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmmirror.com/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/js-cookie/-/js-cookie-3.0.5.tgz", @@ -11480,7 +11542,6 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12684,7 +12745,6 @@ "version": "1.52.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -12694,7 +12754,6 @@ "version": "2.1.35", "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -13206,7 +13265,6 @@ "version": "1.13.4", "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13890,6 +13948,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", @@ -13910,6 +13974,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/query-string": { "version": "9.3.1", "resolved": "https://registry.npmmirror.com/query-string/-/query-string-9.3.1.tgz", @@ -15314,7 +15393,6 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15334,7 +15412,6 @@ "version": "1.0.0", "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15351,7 +15428,6 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15370,7 +15446,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", diff --git a/package.json b/package.json index daad4aa..8b144be 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@codemirror/view": "^6.36.0", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", + "@kintone/rest-api-client": "^6.1.2", "@lobehub/ui": "^5.5.0", "@uiw/react-codemirror": "^4.23.0", "antd": "^6.1.0", diff --git a/src/main/kintone-api.ts b/src/main/kintone-api.ts index b70d641..18d9b79 100644 --- a/src/main/kintone-api.ts +++ b/src/main/kintone-api.ts @@ -1,9 +1,5 @@ -/** - * Kintone REST API Client - * Handles authentication and API calls to Kintone - * Based on REQUIREMENTS.md:331-345, 502-522 - */ - +import { KintoneRestAPIClient } from "@kintone/rest-api-client"; +import type { KintoneRestAPIError } from "@kintone/rest-api-client"; import type { DomainWithPassword } from "@renderer/types/domain"; import type { KintoneSpace, @@ -12,10 +8,10 @@ import type { FileContent, AppCustomizationConfig, KintoneApiError, + JSFileConfig, + CSSFileConfig, } from "@renderer/types/kintone"; -const REQUEST_TIMEOUT = 30000; // 30 seconds - /** * Custom error class for Kintone API errors */ @@ -41,446 +37,256 @@ export class KintoneError extends Error { * Kintone REST API Client */ export class KintoneClient { - private baseUrl: string; - private headers: Headers; + private client: KintoneRestAPIClient; private domain: string; constructor(domainConfig: DomainWithPassword) { this.domain = domainConfig.domain; - this.baseUrl = `https://${domainConfig.domain}/k/v1/`; - this.headers = new Headers({ - "Content-Type": "application/json", + + const auth = + domainConfig.authType === "api_token" + ? { apiToken: domainConfig.apiToken || "" } + : { + username: domainConfig.username, + password: domainConfig.password, + }; + + this.client = new KintoneRestAPIClient({ + baseUrl: `https://${domainConfig.domain}`, + auth, }); - - if (domainConfig.authType === "api_token") { - // API Token authentication - this.headers.set("X-Cybozu-API-Token", domainConfig.apiToken || ""); - } else { - // Password authentication (Basic Auth) - const credentials = Buffer.from( - `${domainConfig.username}:${domainConfig.password}`, - ).toString("base64"); - this.headers.set("X-Cybozu-Authorization", credentials); - } } - /** - * Make an API request with timeout - */ - private async request( - endpoint: string, - options: RequestInit = {}, - ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); + 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 { - const url = this.baseUrl + endpoint; - const response = await fetch(url, { - ...options, - headers: this.headers, - signal: controller.signal, - }); - - const data = await response.json(); - - if (!response.ok) { - const apiError = data as KintoneApiError; - throw new KintoneError( - apiError.message || `API request failed: ${response.status}`, - apiError, - response.status, - ); - } - - return data as T; + return await operation(); } catch (error) { - if (error instanceof KintoneError) { - throw error; - } - if (error instanceof Error) { - if (error.name === "AbortError") { - throw new KintoneError("Request timeout"); - } - throw new KintoneError(`Network error: ${error.message}`); - } - throw new KintoneError("Unknown error occurred"); - } finally { - clearTimeout(timeoutId); + throw this.convertError(error); } } - // ==================== Space APIs ==================== - - /** - * Get list of spaces - * GET /k/v1/space.json - */ - async getSpaces(): Promise { - interface SpacesResponse { - spaces: Array<{ - id: string; - name: string; - code: string; - createdAt?: string; - creator?: { code: string; name: string }; - }>; - } - - const response = await this.request("space.json"); - - return response.spaces.map((space) => ({ - id: space.id, + private mapSpace(space: { + id: string | number; + name: string; + code: string; + createdAt?: string; + creator?: { code: string; name: string }; + }): KintoneSpace { + return { + id: String(space.id), name: space.name, code: space.code, createdAt: space.createdAt, - creator: space.creator, - })); + creator: space.creator + ? { code: space.creator.code, name: space.creator.name } + : undefined, + }; } - // ==================== App APIs ==================== - - /** - * Get list of apps, optionally filtered by space - * GET /k/v1/apps.json?space={spaceId} - */ - async getApps(spaceId?: string): Promise { - interface AppsResponse { - apps: Array<{ - appId: string; - name: string; - code?: string; - spaceId?: string; - createdAt: string; - creator?: { code: string; name: string }; - modifiedAt?: string; - modifier?: { code: string; name: string }; - }>; - } - - let endpoint = "apps.json"; - if (spaceId) { - endpoint += `?space=${spaceId}`; - } - - const response = await this.request(endpoint); - - return response.apps.map((app) => ({ - appId: app.appId, + private mapApp(app: { + appId: string | number; + name: string; + code?: string; + spaceId?: string | number; + createdAt: string; + creator?: { code: string; name: string }; + modifiedAt?: string; + modifier?: { code: string; name: string }; + }): KintoneApp { + return { + appId: String(app.appId), name: app.name, code: app.code, - spaceId: app.spaceId, + spaceId: app.spaceId ? String(app.spaceId) : undefined, createdAt: app.createdAt, creator: app.creator, modifiedAt: app.modifiedAt, modifier: app.modifier, - })); + }; } - /** - * Get app details including customization config - * GET /k/v1/app.json?app={appId} - */ - async getAppDetail(appId: string): Promise { - interface AppResponse { - appId: string; - name: string; - code?: string; - description?: string; - spaceId?: string; - createdAt: string; - creator: { code: string; name: string }; - modifiedAt: string; - modifier: { code: string; name: string }; - } - - interface AppCustomizeResponse { - appId: string; - scope: string; - desktop: { - js?: Array<{ - type: string; - file?: { fileKey: string; name: string }; - url?: string; - }>; - css?: Array<{ - type: string; - file?: { fileKey: string; name: string }; - url?: string; - }>; - }; - mobile: { - js?: Array<{ - type: string; - file?: { fileKey: string; name: string }; - url?: string; - }>; - css?: Array<{ - type: string; - file?: { fileKey: string; name: string }; - url?: string; - }>; - }; - } - - // Get basic app info - const appInfo = await this.request(`app.json?app=${appId}`); - - // Get customization config - const customizeInfo = await this.request( - `app/customize.json?app=${appId}`, - ); - - // Transform customization config - const customization: AppCustomizationConfig = { - javascript: { - pc: - customizeInfo.desktop?.js?.map((js) => ({ - type: js.type as "FILE" | "URL", - file: js.file - ? { fileKey: js.file.fileKey, name: js.file.name, size: 0 } - : undefined, - url: js.url, - })) || [], - mobile: - customizeInfo.mobile?.js?.map((js) => ({ - type: js.type as "FILE" | "URL", - file: js.file - ? { fileKey: js.file.fileKey, name: js.file.name, size: 0 } - : undefined, - url: js.url, - })) || [], - }, - stylesheet: { - pc: - customizeInfo.desktop?.css?.map((css) => ({ - type: css.type as "FILE" | "URL", - file: css.file - ? { fileKey: css.file.fileKey, name: css.file.name, size: 0 } - : undefined, - url: css.url, - })) || [], - mobile: - customizeInfo.mobile?.css?.map((css) => ({ - type: css.type as "FILE" | "URL", - file: css.file - ? { fileKey: css.file.fileKey, name: css.file.name, size: 0 } - : undefined, - url: css.url, - })) || [], - }, - plugins: [], + private mapResource(resource: { + type: string; + file?: { fileKey: string; name: string }; + url?: string; + }): JSFileConfig | CSSFileConfig { + return { + type: resource.type as "FILE" | "URL", + file: resource.file + ? { fileKey: resource.file.fileKey, name: resource.file.name, size: 0 } + : undefined, + url: resource.url, }; + } + + private buildCustomizeSection( + js?: JSFileConfig[], + css?: CSSFileConfig[], + ): + | { js?: CustomizeResourceItem[]; css?: CustomizeResourceItem[] } + | undefined { + if (!js && !css) return undefined; return { - appId: appInfo.appId, - name: appInfo.name, - code: appInfo.code, - description: appInfo.description, - spaceId: appInfo.spaceId, - createdAt: appInfo.createdAt, - creator: appInfo.creator, - modifiedAt: appInfo.modifiedAt, - modifier: appInfo.modifier, - customization, + js: js?.map((item) => ({ + type: item.type, + file: item.file ? { fileKey: item.file.fileKey } : undefined, + url: item.url, + })), + css: css?.map((item) => ({ + type: item.type, + file: item.file ? { fileKey: item.file.fileKey } : undefined, + url: item.url, + })), }; } + // ==================== Space APIs ==================== + + async getSpaces(): Promise { + return this.withErrorHandling(async () => { + const response = await this.client.space.getSpaces(); + return response.spaces.map((space) => this.mapSpace(space)); + }); + } + + // ==================== App APIs ==================== + + async getApps(spaceId?: string): Promise { + return this.withErrorHandling(async () => { + const params = spaceId ? { spaceId } : {}; + const response = await this.client.app.getApps(params); + return response.apps.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: String(appInfo.appId), + name: appInfo.name, + code: appInfo.code, + description: appInfo.description, + spaceId: appInfo.spaceId ? String(appInfo.spaceId) : undefined, + createdAt: appInfo.createdAt, + creator: appInfo.creator, + modifiedAt: appInfo.modifiedAt, + modifier: appInfo.modifier, + customization, + }; + }); + } + // ==================== File APIs ==================== - /** - * Get file content from Kintone - * GET /k/v1/file.json?fileKey={fileKey} - */ async getFileContent(fileKey: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); - - try { - const url = `${this.baseUrl}file.json?fileKey=${fileKey}`; - const response = await fetch(url, { - headers: this.headers, - signal: controller.signal, - }); - - if (!response.ok) { - const error = await response.json(); - throw new KintoneError( - error.message || "Failed to get file", - error, - response.status, - ); - } - - const contentType = - response.headers.get("content-type") || "application/octet-stream"; - const arrayBuffer = await response.arrayBuffer(); - const content = Buffer.from(arrayBuffer).toString("base64"); + 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, // Kintone doesn't return filename in file API - size: arrayBuffer.byteLength, - mimeType: contentType, + name: fileKey, + size: buffer.byteLength, + mimeType: "application/octet-stream", content, }; - } finally { - clearTimeout(timeoutId); - } + }); } - /** - * Upload a file to Kintone - * POST /k/v1/file.json (multipart/form-data) - */ async uploadFile( content: string | Buffer, fileName: string, - mimeType: string = "application/javascript", + _mimeType?: string, ): Promise<{ fileKey: string }> { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); - - try { - const url = `${this.baseUrl}file.json`; - - // Create form data - const formData = new FormData(); - const blob = new Blob( - [typeof content === "string" ? content : Buffer.from(content)], - { type: mimeType }, - ); - formData.append("file", blob, fileName); - - // Remove Content-Type header to let browser set it with boundary - const uploadHeaders = new Headers(this.headers); - uploadHeaders.delete("Content-Type"); - - const response = await fetch(url, { - method: "POST", - headers: uploadHeaders, - body: formData, - signal: controller.signal, + return this.withErrorHandling(async () => { + const response = await this.client.file.uploadFile({ + file: { name: fileName, data: content }, }); - - if (!response.ok) { - const error = await response.json(); - throw new KintoneError( - error.message || "Failed to upload file", - error, - response.status, - ); - } - - const data = await response.json(); - return { fileKey: data.fileKey }; - } finally { - clearTimeout(timeoutId); - } + return { fileKey: response.fileKey }; + }); } // ==================== Deploy APIs ==================== - /** - * Update app customization config - * PUT /k/v1/app/customize.json - */ async updateAppCustomize( appId: string, config: AppCustomizationConfig, ): Promise { - interface CustomizeUpdateRequest { - app: string; - desktop?: { - js?: Array<{ type: string; file?: { fileKey: string }; url?: string }>; - css?: Array<{ type: string; file?: { fileKey: string }; url?: string }>; - }; - mobile?: { - js?: Array<{ type: string; file?: { fileKey: string }; url?: string }>; - css?: Array<{ type: string; file?: { fileKey: string }; url?: string }>; - }; - } - - const requestBody: CustomizeUpdateRequest = { - app: appId, - desktop: { - js: config.javascript?.pc - ?.map((js) => ({ - type: js.type, - file: js.file ? { fileKey: js.file.fileKey } : undefined, - url: js.url, - })) - .filter(Boolean), - css: config.stylesheet?.pc - ?.map((css) => ({ - type: css.type, - file: css.file ? { fileKey: css.file.fileKey } : undefined, - url: css.url, - })) - .filter(Boolean), - }, - mobile: { - js: config.javascript?.mobile - ?.map((js) => ({ - type: js.type, - file: js.file ? { fileKey: js.file.fileKey } : undefined, - url: js.url, - })) - .filter(Boolean), - css: config.stylesheet?.mobile - ?.map((css) => ({ - type: css.type, - file: css.file ? { fileKey: css.file.fileKey } : undefined, - url: css.url, - })) - .filter(Boolean), - }, - }; - - await this.request("app/customize.json", { - method: "PUT", - body: JSON.stringify(requestBody), + 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", + }); }); } - /** - * Deploy app changes - * POST /k/v1/preview/app/deploy.json - */ async deployApp(appId: string): Promise { - await this.request("preview/app/deploy.json", { - method: "POST", - body: JSON.stringify({ apps: [{ app: appId }] }), + return this.withErrorHandling(async () => { + await this.client.app.deployApp({ apps: [{ app: appId }] }); }); } - /** - * Get deploy status - * GET /k/v1/preview/app/deploy.json?app={appId} - */ async getDeployStatus( appId: string, ): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> { - interface DeployStatusResponse { - apps: Array<{ - app: string; - status: "PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"; - }>; - } - - const response = await this.request( - `preview/app/deploy.json?app=${appId}`, - ); - - const appStatus = response.apps.find((a) => a.app === appId); - return appStatus?.status || "FAIL"; + return this.withErrorHandling(async () => { + const response = await this.client.app.getDeployStatus({ app: appId }); + return response.apps[0]?.status || "FAIL"; + }); } // ==================== Utility Methods ==================== - /** - * Test connection to Kintone - */ async testConnection(): Promise<{ success: boolean; error?: string }> { try { await this.getApps(); @@ -494,15 +300,17 @@ export class KintoneClient { } } - /** - * Get the domain name - */ getDomain(): string { return this.domain; } } -// Export factory function for convenience +type CustomizeResourceItem = { + type: string; + file?: { fileKey: string }; + url?: string; +}; + export function createKintoneClient(domain: DomainWithPassword): KintoneClient { return new KintoneClient(domain); }