diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index d6d0dd8630..0737bc5c8a 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -258,6 +258,7 @@ const main: Window['main'] = { curlRequest: options => invokeWithNormalizedError('curlRequest', options), cancelCurlRequest: options => ipcRenderer.send('cancelCurlRequest', options), writeFile: options => invokeWithNormalizedError('writeFile', options), + deleteFile: options => invokeWithNormalizedError('deleteFile', options), writeResponseBodyToFile: options => invokeWithNormalizedError('writeResponseBodyToFile', options), getAuthHeader: (renderedRequest: RenderedRequest, url: string): Promise => invokeWithNormalizedError('getAuthHeader', renderedRequest, url), diff --git a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts index a2fde1ab4c..62d36c3648 100644 --- a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts +++ b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts @@ -11,13 +11,13 @@ export const canDuplicate = true; export const canSync = true; -export const optionalKeys = ['rulesetFilePath']; +export const optionalKeys = ['rulesetContent']; export interface BaseApiSpec { fileName: string; contentType: 'json' | 'yaml'; contents: string; - rulesetFilePath?: string; + rulesetContent?: string; // This is the content of the spectral ruleset file for linting API specs. It is stored in the DB to support cloud sync, but also written to disk for spectral to use when linting. } export type ApiSpec = BaseModel & BaseApiSpec; diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index f5550a2736..1e8897c7f5 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -148,6 +148,7 @@ export type HandleChannels = | 'webSocket.open' | 'webSocket.readyState' | 'writeFile' + | 'deleteFile' | 'writeResponseBodyToFile'; export const ipcMainHandle = ( diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index c12b3c5459..568f226ff1 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -153,6 +153,7 @@ export interface RendererToMainBridgeAPI { parseImport: typeof convert; multipartBufferToArray: (options: { bodyBuffer: Buffer; contentType: string }) => Promise; writeFile: (options: { path: string; content: string | Buffer }) => Promise; + deleteFile: (options: { path: string }) => Promise; writeResponseBodyToFile: (options: { sourcePath: string; destinationPath: string; @@ -321,6 +322,13 @@ export function registerMainHandlers() { throw new Error(err); } }); + ipcMainHandle('deleteFile', async (_, options: { path: string }) => { + try { + await fs.promises.unlink(options.path); + } catch (err) { + throw new Error(err); + } + }); ipcMainHandle('writeResponseBodyToFile', writeResponseBodyToFile); ipcMainHandle('getAuthHeader', (_, renderedRequest: RenderedRequest, url: string) => { return getAuthHeaderInMain(renderedRequest, url); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 4d33deec26..16b91dc907 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -195,6 +195,17 @@ const Component = ({ params }: Route.ComponentProps) => { const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(Boolean(parsedSpec)); const [rulesetPath, setRulesetPath] = useState(''); + // This determines what path to write the ruleset content to when the user selects a custom ruleset file or when the rulesetContent in the DB changes. + // For git sync projects, this will be the git working directory so that it appears in the staging modal and can be committed. + // For cloud/local projects, this will be a per-workspace path — rulesetContent in the DB handles syncing for these projects. + // Spectral requires a file path on disk to lint with a ruleset. Ref: lint-process.mjs. + const rulesetWritePath = useMemo( + () => + gitSyncRulesetPath || + window.path.join(window.app.getPath('userData'), `workspaces/${workspaceId}/.spectral.yaml`), + [gitSyncRulesetPath, workspaceId], + ); + const { components, info, servers, paths } = parsedSpec || {}; const { requestBodies, responses, parameters, headers, schemas, securitySchemes } = components || {}; @@ -262,8 +273,20 @@ const Component = ({ params }: Route.ComponentProps) => { }, [rulesetPath]); useEffect(() => { - setRulesetPath(apiSpec.rulesetFilePath || gitSyncRulesetPath || ''); - }, [apiSpec, gitSyncRulesetPath]); + const syncRulesetToDisk = async () => { + if (apiSpec.rulesetContent) { + // Write the stored content to the correct path for this project type and use it for linting. + await window.main.writeFile({ path: rulesetWritePath, content: apiSpec.rulesetContent }); + setRulesetPath(rulesetWritePath); + } else { + // No ruleset content — for git sync, fall back to any .spectral.yaml already present in the repo (e.g. committed by another user). + // For cloud/local projects, default to OAS ruleset. + setRulesetPath(gitSyncRulesetPath || ''); + } + }; + + syncRulesetToDisk(); + }, [apiSpec.rulesetContent, rulesetWritePath, gitSyncRulesetPath]); reactUse.useUnmount(() => { // delete the helper to avoid it run multiple times when user enter the page next time @@ -411,15 +434,24 @@ const Component = ({ params }: Route.ComponentProps) => { const handleSelectSpectralFile = async () => { const { filePath, canceled } = await selectFileOrFolder({ itemTypes: ['file'], - extensions: ['yaml', 'yml'], + extensions: ['yaml'], showHiddenFiles: true, }); if (canceled || !filePath) { return; } - await services.apiSpec.update(apiSpec, { rulesetFilePath: filePath }); - setRulesetPath(filePath); + + if (window.path.basename(filePath) !== '.spectral.yaml') { + showError({ title: 'Invalid File', message: 'Please select a file named .spectral.yaml' }); + return; + } + + const content = await window.main.insecureReadFile({ path: filePath }); + + await window.main.writeFile({ path: rulesetWritePath, content }); + await services.apiSpec.update(apiSpec, { rulesetContent: content }); + setRulesetPath(rulesetWritePath); }; const handleUnselectSpectralFile = async () => { @@ -432,8 +464,9 @@ const Component = ({ params }: Route.ComponentProps) => { noText: 'Cancel', onDone: async (confirmed: boolean) => { if (confirmed) { - setRulesetPath(gitSyncRulesetPath || ''); - await services.apiSpec.update(apiSpec, { rulesetFilePath: '' }); + await window.main.deleteFile({ path: rulesetWritePath }); + await services.apiSpec.update(apiSpec, { rulesetContent: '' }); + setRulesetPath(''); } }, }); @@ -1101,7 +1134,7 @@ const Component = ({ params }: Route.ComponentProps) => { - {apiSpec.rulesetFilePath === rulesetPath && ( + {!!apiSpec.rulesetContent && (