diff --git a/.changeset/short-steaks-call.md b/.changeset/short-steaks-call.md new file mode 100644 index 0000000000..4496b667d2 --- /dev/null +++ b/.changeset/short-steaks-call.md @@ -0,0 +1,5 @@ +--- +"@inlang/cross-sell-sherlock": patch +--- + +init package diff --git a/inlang/source-code/cross-sell/cross-sell-sherlock/README.md b/inlang/source-code/cross-sell/cross-sell-sherlock/README.md new file mode 100644 index 0000000000..4c9300493c --- /dev/null +++ b/inlang/source-code/cross-sell/cross-sell-sherlock/README.md @@ -0,0 +1,67 @@ +# Cross-sell Sherlock package + +## Features + +- **Check for Existing Recommendations**: Quickly verify if an extension is already recommended in the workspace's `.vscode/extensions.json` file. +- **Add New Recommendations**: Automatically add new recommendations to the `.vscode/extensions.json` file if they are not already present. + +## Installation + +Put this one into your `dependencies` in `package.json` & install it with `pnpm install`: + +```bash +"@inlang/cross-sell-sherlock": "workspace:*" +``` + +## Usage + +The module exports two main asynchronous functions: + +### `isAdopted(fs: NodeishFilesystem, workingDirectory?: string): Promise` + +Checks whether the `inlang.vs-code-extension` is recommended in the workspace. + +#### Parameters + +- `fs`: A `NodeishFilesystem` object to interact with the file system. +- `workingDirectory`: (Optional) The working directory path. + +#### Returns + +- `Promise`: `true` if the extension is recommended, `false` otherwise. + +### `add(fs: NodeishFilesystem, workingDirectory?: string): Promise` + +Adds the `inlang.vs-code-extension` recommendation to the workspace if it's not already present. + +#### Parameters + +- `fs`: A `NodeishFilesystem` object to interact with the file system. +- `workingDirectory`: (Optional) The working directory path. + +## Example + +```typescript +import { isAdopted, add } from '@inlang/cross-sell-sherlock'; +import { NodeishFilesystem } from '@lix-js/fs'; + +async function addSherlock(fs: NodeishFilesystem) { + const isExtensionAdopted = await isAdopted(fs); + + if (!isExtensionAdopted) { + // prompt for user confirmation + const userConfirmed = await promptUser(); + + if (userConfirmed) { + await add(fs); + console.log('Extension recommendation added.'); + } else { + console.log('User declined to add extension recommendation.'); + } + } +} +``` + +## Contributing + +Contributions are welcome! If you have a feature request, bug report, or proposal, please open an issue or submit a pull request. Our Discord can be found [here](https://discord.gg/VXHw44ux). \ No newline at end of file diff --git a/inlang/source-code/cross-sell/cross-sell-sherlock/package.json b/inlang/source-code/cross-sell/cross-sell-sherlock/package.json new file mode 100644 index 0000000000..a4934ea026 --- /dev/null +++ b/inlang/source-code/cross-sell/cross-sell-sherlock/package.json @@ -0,0 +1,32 @@ +{ + "name": "@inlang/cross-sell-sherlock", + "description": "A package to cross-sell Sherlock", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "./dist", + "./src" + ], + "scripts": { + "build": "tsc --build", + "dev": "tsc --watch", + "test": "tsc --noEmit && vitest run --passWithNoTests --coverage", + "lint": "eslint ./src --fix", + "format": "prettier ./src --write", + "clean": "rm -rf ./dist ./node_modules" + }, + "devDependencies": { + "@lix-js/fs": "workspace:*", + "@inlang/sdk": "workspace:*", + "@types/vscode": "^1.84.2", + "comment-json": "^4.2.3", + "patch-package": "6.5.1", + "@sinclair/typebox": "^0.31.17", + "typescript": "^5.1.3", + "@vitest/coverage-v8": "0.33.0", + "vitest": "0.33.0" + } +} diff --git a/inlang/source-code/cross-sell/cross-sell-sherlock/src/index.test.ts b/inlang/source-code/cross-sell/cross-sell-sherlock/src/index.test.ts new file mode 100644 index 0000000000..40298196c5 --- /dev/null +++ b/inlang/source-code/cross-sell/cross-sell-sherlock/src/index.test.ts @@ -0,0 +1,175 @@ +import { add, isAdopted } from "./index.js" +import { describe, it, expect, vi } from "vitest" + +describe("Cross-sell Sherlock app", () => { + it("should recognize when extension is already adopted", async () => { + const fsMock: any = { + stat: vi.fn().mockResolvedValue(true), + readFile: vi + .fn() + .mockResolvedValue(JSON.stringify({ recommendations: ["inlang.vs-code-extension"] })), + } + expect(await isAdopted(fsMock, "/mock/path")).toBe(true) + }) + + it("should recognize when extension is not adopted", async () => { + const fsMock: any = { + stat: vi.fn().mockResolvedValue(true), + readFile: vi.fn().mockResolvedValue(JSON.stringify({ recommendations: [] })), + } + expect(await isAdopted(fsMock, "/mock/path")).toBe(false) + }) + + it("should merge with other extensions & add recommendation when not present", async () => { + const fsMock: any = { + stat: vi.fn().mockResolvedValue(true), + readFile: vi + .fn() + .mockResolvedValue(JSON.stringify({ recommendations: ["some.other-extension"] })), + writeFile: vi.fn(), + mkdir: vi.fn(), + } + await add(fsMock, "/mock/path") + expect(fsMock.writeFile).toHaveBeenCalledWith( + "/mock/path/.vscode/extensions.json", + JSON.stringify( + { recommendations: ["some.other-extension", "inlang.vs-code-extension"] }, + undefined, + 2 + ) + ) + }) + + it("should handle when the .vscode directory does not exist", async () => { + const fsMock: any = { + stat: vi.fn((path) => + path.includes(".vscode") ? Promise.resolve(false) : Promise.resolve(true) + ), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + } + await add(fsMock, "/mock/path") + expect(fsMock.mkdir).toHaveBeenCalledWith("/mock/path/.vscode") + expect(fsMock.writeFile).toHaveBeenCalledWith( + "/mock/path/.vscode/extensions.json", + expect.any(String) + ) + }) + + it("should handle when extensions.json does not exist", async () => { + const fsMock: any = { + stat: vi.fn((path) => + path.includes("extensions.json") ? Promise.resolve(false) : Promise.resolve(true) + ), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + } + await add(fsMock, "/mock/path") + expect(fsMock.writeFile).toHaveBeenCalledWith( + "/mock/path/.vscode/extensions.json", + expect.any(String) + ) + }) + + it("should return false if extensions.json does not exist", async () => { + const fsMock: any = { + stat: vi.fn((path) => { + if (path.includes("extensions.json")) return Promise.resolve(false) + return Promise.resolve(true) + }), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + } + expect(await isAdopted(fsMock, "/mock/path")).toBe(false) + }) + + it("should return false for invalid extensions.json content", async () => { + const fsMock: any = { + stat: vi.fn().mockResolvedValue(true), + readFile: vi.fn().mockResolvedValue(JSON.stringify({ invalid: "content" })), + writeFile: vi.fn(), + mkdir: vi.fn(), + } + expect(await isAdopted(fsMock, "/mock/path")).toBe(false) + }) + + it("should create extensions.json in the .vscode folder if it does not exist", async () => { + const fsMock: any = { + stat: vi.fn((path) => { + // Simulate .vscode directory exists but extensions.json does not + if (path.includes("extensions.json")) return Promise.resolve(false) + return Promise.resolve(true) + }), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + } + + await add(fsMock, "/mock/path") + + expect(fsMock.writeFile).toHaveBeenCalledWith( + "/mock/path/.vscode/extensions.json", + expect.any(String) + ) + }) + + it("should not modify extensions.json if extension is already present", async () => { + const fsMock: any = { + stat: vi.fn().mockResolvedValue(true), + readFile: vi + .fn() + .mockResolvedValue(JSON.stringify({ recommendations: ["inlang.vs-code-extension"] })), + writeFile: vi.fn(), + mkdir: vi.fn(), + } + await add(fsMock, "/mock/path") + expect(fsMock.writeFile).not.toHaveBeenCalled() + }) + + it("should handle errors in file operations", async () => { + const error = new Error("File operation failed") + const fsMock: any = { + stat: vi.fn().mockRejectedValue(error), + readFile: vi.fn().mockRejectedValue(error), + writeFile: vi.fn().mockRejectedValue(error), + mkdir: vi.fn().mockRejectedValue(error), + } + + await expect(add(fsMock, "/mock/path")).rejects.toThrow("File operation failed") + }) + + it("should reset extensions.json if it contains malformed content", async () => { + const malformedContent = "this is not valid json" + + const fsMock = { + stat: vi.fn().mockResolvedValue(true), + readFile: vi.fn().mockResolvedValue(malformedContent), + writeFile: vi.fn(), + mkdir: vi.fn(), + } + + // @ts-expect-error + await add(fsMock, "/test/dir") + expect(fsMock.writeFile).toHaveBeenCalledWith( + "/test/dir/.vscode/extensions.json", + expect.any(String) + ) + }) + + it("should handle read/write errors gracefully", async () => { + const error = new Error("File operation failed") + + const fsMock = { + stat: vi.fn().mockRejectedValue(error), + readFile: vi.fn().mockRejectedValue(error), + writeFile: vi.fn().mockRejectedValue(error), + mkdir: vi.fn().mockRejectedValue(error), + } + + // @ts-expect-error + await expect(add(fsMock, "/test/dir")).rejects.toThrow("File operation failed") + }) +}) diff --git a/inlang/source-code/cross-sell/cross-sell-sherlock/src/index.ts b/inlang/source-code/cross-sell/cross-sell-sherlock/src/index.ts new file mode 100644 index 0000000000..d41c071ecb --- /dev/null +++ b/inlang/source-code/cross-sell/cross-sell-sherlock/src/index.ts @@ -0,0 +1,16 @@ +import { type NodeishFilesystem } from "@lix-js/fs" +import { + addRecommendationToWorkspace, + isInWorkspaceRecommendation, +} from "./recommendation/recommendation.js" + +export async function isAdopted( + fs: NodeishFilesystem, + workingDirectory?: string +): Promise { + return isInWorkspaceRecommendation(fs, workingDirectory) +} + +export async function add(fs: NodeishFilesystem, workingDirectory?: string): Promise { + await addRecommendationToWorkspace(fs, workingDirectory) +} diff --git a/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/recommendation.ts b/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/recommendation.ts new file mode 100644 index 0000000000..7ab08acaf5 --- /dev/null +++ b/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/recommendation.ts @@ -0,0 +1,60 @@ +import { normalizePath, type NodeishFilesystem } from "@lix-js/fs" +import { joinPath } from "./utils/joinPath.js" +import { Value } from "@sinclair/typebox/value" +import { parse, type CommentJSONValue, stringify } from "comment-json" +import { type ExtensionsJson as ExtensionsJsonType, ExtensionsJson } from "./utils/types.js" + +export async function addRecommendationToWorkspace( + fs: NodeishFilesystem, + workingDirectory?: string +): Promise { + const vscodeFolderPath = normalizePath(joinPath(workingDirectory ?? "", "./.vscode")) + const extensionsJsonPath = joinPath(vscodeFolderPath, "extensions.json") + + if (!(await fs.stat(vscodeFolderPath))) { + await fs.mkdir(vscodeFolderPath) + } + + let extensions: ExtensionsJsonType + if (await fs.stat(extensionsJsonPath)) { + try { + const parsed = parse(await fs.readFile(extensionsJsonPath, { encoding: "utf-8" })) + if (Value.Check(ExtensionsJson, parsed)) { + extensions = parsed + } else { + extensions = { recommendations: [] } + } + } catch (error) { + extensions = { recommendations: [] } + } + } else { + extensions = { recommendations: [] } + } + + if (!extensions.recommendations.includes("inlang.vs-code-extension")) { + extensions.recommendations.push("inlang.vs-code-extension") + await fs.writeFile(extensionsJsonPath, stringify(extensions, undefined, 2)) + } +} + +export async function isInWorkspaceRecommendation( + fs: NodeishFilesystem, + workingDirectory?: string +): Promise { + const vscodeFolderPath = normalizePath(joinPath(workingDirectory ?? "", "./.vscode")) + const extensionsJsonPath = joinPath(vscodeFolderPath, "extensions.json") + + if (!(await fs.stat(extensionsJsonPath)) || !(await fs.stat(vscodeFolderPath))) { + return false + } + + const extensions = parse( + await fs.readFile(extensionsJsonPath, { encoding: "utf-8" }) + ) as CommentJSONValue + + if (!Value.Check(ExtensionsJson, extensions)) { + return false + } + + return extensions?.recommendations.includes("inlang.vs-code-extension") || false +} diff --git a/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/utils/joinPath.ts b/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/utils/joinPath.ts new file mode 100644 index 0000000000..ef69f9a785 --- /dev/null +++ b/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/utils/joinPath.ts @@ -0,0 +1,3 @@ +export function joinPath(...parts: string[]): string { + return parts.map((part) => part.replace(/\/$/, "")).join("/") +} diff --git a/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/utils/types.ts b/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/utils/types.ts new file mode 100644 index 0000000000..f900cf4681 --- /dev/null +++ b/inlang/source-code/cross-sell/cross-sell-sherlock/src/recommendation/utils/types.ts @@ -0,0 +1,7 @@ +import { Type, type Static } from "@sinclair/typebox" + +export const ExtensionsJson = Type.Object({ + recommendations: Type.Array(Type.String()), +}) + +export type ExtensionsJson = Static diff --git a/inlang/source-code/cross-sell/cross-sell-sherlock/tsconfig.json b/inlang/source-code/cross-sell/cross-sell-sherlock/tsconfig.json new file mode 100644 index 0000000000..f135cfd1a0 --- /dev/null +++ b/inlang/source-code/cross-sell/cross-sell-sherlock/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/*", "./build.js"], + "compilerOptions": { + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3631f12646..e52c9a73cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,36 @@ importers: specifier: 0.34.6 version: 0.34.6(jsdom@22.1.0) + inlang/source-code/cross-sell/cross-sell-sherlock: + devDependencies: + '@inlang/sdk': + specifier: workspace:* + version: link:../../sdk + '@lix-js/fs': + specifier: workspace:* + version: link:../../../../lix/source-code/fs + '@sinclair/typebox': + specifier: ^0.31.17 + version: 0.31.28 + '@types/vscode': + specifier: ^1.84.2 + version: 1.84.2 + '@vitest/coverage-v8': + specifier: 0.34.6 + version: 0.34.6(vitest@0.34.6) + comment-json: + specifier: ^4.2.3 + version: 4.2.3 + patch-package: + specifier: 6.5.1 + version: 6.5.1 + typescript: + specifier: ^5.1.3 + version: 5.3.3 + vitest: + specifier: 0.34.6 + version: 0.34.6(jsdom@22.1.0) + inlang/source-code/design-system: dependencies: '@ctrl/tinycolor': @@ -13337,6 +13367,7 @@ packages: /esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} + hasBin: true /esquery@1.5.0: resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} @@ -15303,6 +15334,7 @@ packages: /is-ci@2.0.0: resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} + hasBin: true dependencies: ci-info: 2.0.0 dev: true @@ -15336,6 +15368,7 @@ packages: /is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true /is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} @@ -18871,6 +18904,7 @@ packages: /patch-package@6.5.1: resolution: {integrity: sha512-I/4Zsalfhc6bphmJTlrLoOcAF87jcxko4q0qsv4bGcurbr8IskEOtdnt9iCmsQVGL1B+iUhSQqweyTLJfCF9rA==} engines: {node: '>=10', npm: '>5'} + hasBin: true dependencies: '@yarnpkg/lockfile': 1.1.0 chalk: 4.1.2 @@ -20318,6 +20352,7 @@ packages: /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true dependencies: glob: 7.2.3 @@ -20574,6 +20609,7 @@ packages: /semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9dded5b8b3..2ac44b15e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - 'lix/source-code/*' - 'inlang/source-code/*' + - 'inlang/source-code/cross-sell/*' - 'inlang/source-code/plugins/*' - 'inlang/source-code/paraglide/*' - 'inlang/source-code/paraglide/paraglide-js-adapter-vite/tests/*'