Skip to content

Commit

Permalink
Merge pull request #2360 from opral/add-cross-sell-sherlock-package
Browse files Browse the repository at this point in the history
Add `@inlang/cross-sell-sherlock` package
  • Loading branch information
felixhaeberle authored Mar 11, 2024
2 parents 6ef23fb + 140d803 commit 8389032
Show file tree
Hide file tree
Showing 11 changed files with 411 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/short-steaks-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inlang/cross-sell-sherlock": patch
---

init package
67 changes: 67 additions & 0 deletions inlang/source-code/cross-sell/cross-sell-sherlock/README.md
Original file line number Diff line number Diff line change
@@ -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<boolean>`

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<boolean>`: `true` if the extension is recommended, `false` otherwise.

### `add(fs: NodeishFilesystem, workingDirectory?: string): Promise<void>`

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).
32 changes: 32 additions & 0 deletions inlang/source-code/cross-sell/cross-sell-sherlock/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
175 changes: 175 additions & 0 deletions inlang/source-code/cross-sell/cross-sell-sherlock/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
16 changes: 16 additions & 0 deletions inlang/source-code/cross-sell/cross-sell-sherlock/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return isInWorkspaceRecommendation(fs, workingDirectory)
}

export async function add(fs: NodeishFilesystem, workingDirectory?: string): Promise<void> {
await addRecommendationToWorkspace(fs, workingDirectory)
}
Original file line number Diff line number Diff line change
@@ -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<void> {
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<boolean> {
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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function joinPath(...parts: string[]): string {
return parts.map((part) => part.replace(/\/$/, "")).join("/")
}
Original file line number Diff line number Diff line change
@@ -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<typeof ExtensionsJson>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/*", "./build.js"],
"compilerOptions": {
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
Loading

0 comments on commit 8389032

Please sign in to comment.