diff --git a/packages/create-fs/README.md b/packages/create-fs/README.md new file mode 100644 index 00000000..b2435a92 --- /dev/null +++ b/packages/create-fs/README.md @@ -0,0 +1,17 @@ +

create-fs

+ +

The file system used by create. ๐Ÿ—„๏ธ

+ +

+ ๐Ÿค Code of Conduct: Kept + ๐Ÿงช Coverage + ๐Ÿ“ License: MIT + ๐Ÿ“ฆ npm version + ๐Ÿ’ช TypeScript: Strict +

+ +See **[create.bingo](https://create.bingo)** for documentation. +Specifically: + +- [Packages > `create-fs`](https://www.create.bingo/engine/packages/create-fs): for this package's documentation +- [Runtime > Creations > `files`](https://www.create.bingo/engine/runtime/creations#files): for where these are used diff --git a/packages/create-fs/package.json b/packages/create-fs/package.json new file mode 100644 index 00000000..a7d4d1a1 --- /dev/null +++ b/packages/create-fs/package.json @@ -0,0 +1,32 @@ +{ + "name": "create-fs", + "version": "0.1.0", + "description": "The file system used by create>. ๐Ÿ—„๏ธ", + "repository": { + "type": "git", + "url": "https://github.com/JoshuaKGoldberg/create", + "directory": "packages/create-fs" + }, + "license": "MIT", + "author": { + "name": "Josh Goldberg โœจ", + "email": "npm@joshuakgoldberg.com" + }, + "type": "module", + "main": "./lib/index.js", + "files": [ + "lib/", + "package.json", + "LICENSE.md", + "README.md" + ], + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "@types/node": "^22.10.5" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/create-fs/src/createReadingFileSystem.ts b/packages/create-fs/src/createReadingFileSystem.ts new file mode 100644 index 00000000..acfb824c --- /dev/null +++ b/packages/create-fs/src/createReadingFileSystem.ts @@ -0,0 +1,11 @@ +import * as fs from "node:fs/promises"; + +import { ReadingFileSystem } from "./types/system.js"; + +export function createReadingFileSystem(): ReadingFileSystem { + return { + readDirectory: async (filePath: string) => await fs.readdir(filePath), + readFile: async (filePath: string) => + (await fs.readFile(filePath)).toString(), + }; +} diff --git a/packages/create-fs/src/createWritingFileSystem.ts b/packages/create-fs/src/createWritingFileSystem.ts new file mode 100644 index 00000000..72a6efa0 --- /dev/null +++ b/packages/create-fs/src/createWritingFileSystem.ts @@ -0,0 +1,14 @@ +import * as fs from "node:fs/promises"; + +import { createReadingFileSystem } from "./createReadingFileSystem.js"; + +export function createWritingFileSystem() { + return { + ...createReadingFileSystem(), + writeDirectory: async (directoryPath: string) => + void (await fs.mkdir(directoryPath, { recursive: true })), + writeFile: async (filePath: string, contents: string) => { + await fs.writeFile(filePath, contents); + }, + }; +} diff --git a/packages/create-fs/src/index.ts b/packages/create-fs/src/index.ts new file mode 100644 index 00000000..f7c8e225 --- /dev/null +++ b/packages/create-fs/src/index.ts @@ -0,0 +1,5 @@ +export * from "./createReadingFileSystem.js"; +export * from "./createWritingFileSystem.js"; +export * from "./intake/intakeFromDirectory.js"; +export type * from "./types/files.js"; +export type * from "./types/system.js"; diff --git a/packages/create-fs/src/intake/intakeFromDirectory.test.ts b/packages/create-fs/src/intake/intakeFromDirectory.test.ts new file mode 100644 index 00000000..6626f0c5 --- /dev/null +++ b/packages/create-fs/src/intake/intakeFromDirectory.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; + +import { intakeFromDirectory } from "./intakeFromDirectory.js"; + +const mockReaddir = vi.fn(); +const mockReadFile = vi.fn(); +const mockStat = vi.fn(); + +vi.mock("node:fs/promises", () => ({ + get readdir() { + return mockReaddir; + }, + get readFile() { + return mockReadFile; + }, + get stat() { + return mockStat; + }, +})); + +describe("intakeFromDirectory", () => { + it("returns an empty object when the directory has no files", async () => { + mockReaddir.mockResolvedValueOnce([]); + + const directory = await intakeFromDirectory("from"); + + expect(directory).toEqual({}); + expect(mockReaddir.mock.calls).toEqual([["from"]]); + expect(mockStat).not.toHaveBeenCalled(); + }); + + it("returns files when the directory contains files", async () => { + mockReaddir.mockResolvedValueOnce(["included-a", "included-b"]); + mockReadFile + .mockResolvedValueOnce("contents-a") + .mockResolvedValueOnce("contents-b"); + mockStat + .mockResolvedValueOnce({ + isDirectory: () => false, + mode: 123, + }) + .mockResolvedValueOnce({ + isDirectory: () => false, + mode: 456, + }); + + const directory = await intakeFromDirectory("from"); + + expect(directory).toEqual({ + "included-a": ["contents-a", { mode: 123 }], + "included-b": ["contents-b", { mode: 456 }], + }); + expect(mockReaddir.mock.calls).toEqual([["from"]]); + expect(mockStat.mock.calls).toEqual([ + ["from/included-a"], + ["from/included-b"], + ]); + }); + + it("returns non-excluded files when the directory contains files and excludes is provided", async () => { + mockReaddir.mockResolvedValueOnce(["excluded", "included-a", "included-b"]); + mockReadFile + .mockResolvedValueOnce("contents-a") + .mockResolvedValueOnce("contents-b"); + mockStat + .mockResolvedValueOnce({ + isDirectory: () => false, + mode: 123, + }) + .mockResolvedValueOnce({ + isDirectory: () => false, + mode: 456, + }); + + const directory = await intakeFromDirectory("from", { + exclude: /excluded/, + }); + + expect(directory).toEqual({ + "included-a": ["contents-a", { mode: 123 }], + "included-b": ["contents-b", { mode: 456 }], + }); + expect(mockReaddir.mock.calls).toEqual([["from"]]); + expect(mockStat.mock.calls).toEqual([ + ["from/included-a"], + ["from/included-b"], + ]); + }); + + it("returns a nested file when the directory contains a nested directory", async () => { + mockReaddir + .mockResolvedValueOnce(["middle"]) + .mockResolvedValueOnce(["excluded", "included"]); + mockReadFile.mockResolvedValueOnce("contents"); + mockStat + .mockResolvedValueOnce({ + isDirectory: () => true, + }) + .mockResolvedValueOnce({ + isDirectory: () => false, + mode: 123, + }); + + const directory = await intakeFromDirectory("from", { + exclude: /excluded/, + }); + + expect(directory).toEqual({ + middle: { + included: ["contents", { mode: 123 }], + }, + }); + expect(mockReaddir.mock.calls).toEqual([["from"], ["from/middle"]]); + expect(mockStat.mock.calls).toEqual([ + ["from/middle"], + ["from/middle/included"], + ]); + }); +}); diff --git a/packages/create-fs/src/intake/intakeFromDirectory.ts b/packages/create-fs/src/intake/intakeFromDirectory.ts new file mode 100644 index 00000000..4c8cc829 --- /dev/null +++ b/packages/create-fs/src/intake/intakeFromDirectory.ts @@ -0,0 +1,31 @@ +import * as fs from "node:fs/promises"; +import path from "node:path"; + +import { CreatedDirectory } from "../types/files.js"; + +export interface IntakeFromDirectorySettings { + exclude?: RegExp; +} + +export async function intakeFromDirectory( + directoryPath: string, + settings: IntakeFromDirectorySettings = {}, +) { + const directory: CreatedDirectory = {}; + const children = await fs.readdir(directoryPath); + + for (const child of children) { + if (settings.exclude?.test(child)) { + continue; + } + + const childPath = path.join(directoryPath, child); + const stat = await fs.stat(childPath); + + directory[child] = stat.isDirectory() + ? await intakeFromDirectory(childPath, settings) + : [(await fs.readFile(childPath)).toString(), { mode: stat.mode }]; + } + + return directory; +} diff --git a/packages/create-fs/src/types/files.ts b/packages/create-fs/src/types/files.ts new file mode 100644 index 00000000..456566a8 --- /dev/null +++ b/packages/create-fs/src/types/files.ts @@ -0,0 +1,18 @@ +export interface CreatedDirectory { + [i: string]: CreatedFileEntry | undefined; +} + +export type CreatedFileEntry = + | [string, CreatedFileOptions] + | [string] + | CreatedDirectory + | false + | string; + +export interface CreatedFileOptions { + /** + * File mode (permission and sticky bits) per chmod(). + * @example 0o777 for an executable file. + */ + mode?: number; +} diff --git a/packages/create-fs/src/types/system.ts b/packages/create-fs/src/types/system.ts new file mode 100644 index 00000000..578b3687 --- /dev/null +++ b/packages/create-fs/src/types/system.ts @@ -0,0 +1,30 @@ +export interface DirectoryChild { + name: string; + type: "directory" | "file"; +} + +export type ReadDirectory = (filePath: string) => Promise; + +export type ReadFile = (filePath: string) => Promise; + +export interface ReadingFileSystem { + readDirectory: ReadDirectory; + readFile: ReadFile; +} + +export type WriteDirectory = (directoryPath: string) => Promise; + +export type WriteFile = ( + filePath: string, + contents: string, + options?: WriteFileOptions, +) => Promise; + +export interface WriteFileOptions { + mode?: number; +} + +export interface WritingFileSystem extends ReadingFileSystem { + writeDirectory: WriteDirectory; + writeFile: WriteFile; +} diff --git a/packages/create-fs/tsconfig.json b/packages/create-fs/tsconfig.json new file mode 100644 index 00000000..de061cbc --- /dev/null +++ b/packages/create-fs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/create-fs/vitest.config.ts b/packages/create-fs/vitest.config.ts new file mode 100644 index 00000000..5966133b --- /dev/null +++ b/packages/create-fs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + clearMocks: true, + exclude: ["lib", "node_modules"], + }, +}); diff --git a/packages/create-testers/package.json b/packages/create-testers/package.json index 4132add6..001099ec 100644 --- a/packages/create-testers/package.json +++ b/packages/create-testers/package.json @@ -24,13 +24,17 @@ "build": "tsc" }, "dependencies": { - "octokit": "^4.1.0" + "diff": "^7.0.0", + "octokit": "^4.1.0", + "without-undefined-properties": "^0.1.1" }, "devDependencies": { + "@types/diff": "^7.0.0", "zod": "3.24.1" }, "peerDependencies": { - "create": "workspace:^" + "create": "workspace:^", + "create-fs": "workspace:^" }, "engines": { "node": ">=18" diff --git a/packages/create-testers/src/createMockSystems.ts b/packages/create-testers/src/createMockSystems.ts index 38d41018..16a265bd 100644 --- a/packages/create-testers/src/createMockSystems.ts +++ b/packages/create-testers/src/createMockSystems.ts @@ -1,4 +1,5 @@ -import { NativeSystem, TakeInput, WritingFileSystem } from "create"; +import { NativeSystem, TakeInput } from "create"; +import { WritingFileSystem } from "create-fs"; import { Octokit } from "octokit"; import { MockSystemOptions } from "./types.js"; @@ -20,6 +21,7 @@ export function createMockSystems( }; const fs: WritingFileSystem = { + readDirectory: createFailingFunction("fs.readDirectory", "an input"), readFile: createFailingFunction("fs.readFile", "an input"), writeDirectory: createFailingFunction("fs.writeDirectory", "an input"), writeFile: createFailingFunction("fs.writeFile", "an input"), diff --git a/packages/create-testers/src/diffCreatedDirectory.test.ts b/packages/create-testers/src/diffCreatedDirectory.test.ts new file mode 100644 index 00000000..2775b8dc --- /dev/null +++ b/packages/create-testers/src/diffCreatedDirectory.test.ts @@ -0,0 +1,139 @@ +import { CreatedDirectory } from "create-fs"; +import { describe, expect, test } from "vitest"; + +import { + diffCreatedDirectory, + DiffedCreatedDirectory, +} from "./diffCreatedDirectory.js"; + +describe("diffCreatedDirectory", () => { + test.each([ + [{}, {}, undefined], + [{}, { a: "" }, { a: "" }], + [{}, { a: { b: "" } }, { a: { b: "" } }], + [{ a: "" }, { a: "" }, undefined], + [ + { a: "" }, + { a: "b\n" }, + { + a: `@@ -0,0 +1,1 @@ ++b +`, + }, + ], + [{ a: "b\n" }, { a: "b\n" }, undefined], + [ + { a: "abc\n" }, + { a: "bbc\n" }, + { + a: `@@ -1,1 +1,1 @@ +-abc ++bbc +`, + }, + ], + [{ a: "b\n" }, {}, undefined], + [{ a: "" }, { a: [""] }, undefined], + [{ a: "" }, { a: ["", { mode: undefined }] }, undefined], + [{ a: "" }, { a: ["", { mode: 123 }] }, undefined], + [{ a: [""] }, { a: ["", { mode: 123 }] }, undefined], + [ + { a: ["", { mode: undefined }] }, + { a: ["", { mode: undefined }] }, + undefined, + ], + [{ a: ["", { mode: undefined }] }, { a: ["", { mode: 123 }] }, undefined], + [{ a: ["", { mode: 123 }] }, { a: ["", { mode: 123 }] }, undefined], + [{ a: ["", { mode: 123 }] }, { a: [""] }, undefined], + [{ a: ["", { mode: 123 }] }, { a: ["", {}] }, undefined], + [{ a: ["", { mode: 123 }] }, { a: ["", { mode: undefined }] }, undefined], + [ + { a: ["", { mode: 123 }] }, + { a: ["", { mode: 456 }] }, + { + a: [ + undefined, + { + mode: `@@ -1,1 +1,1 @@ +-7b +\\ No newline at end of file ++1c8 +\\ No newline at end of file +`, + }, + ], + }, + ], + [ + { a: "" }, + { a: { b: {} } }, + { a: "Mismatched a: actual is string; created is object." }, + ], + [ + { a: [""] }, + { a: { b: {} } }, + { a: "Mismatched a: actual is created file; created is object." }, + ], + [{ a: [""] }, { a: "" }, undefined], + [ + { a: ["b\n"] }, + { a: "" }, + { + a: `@@ -1,1 +0,0 @@ +-b +`, + }, + ], + [ + { a: { b: {} } }, + { a: "" }, + { a: "Mismatched a: actual is object; created is string." }, + ], + [ + { a: { b: {} } }, + { a: [""] }, + { a: "Mismatched a: actual is object; created is created file." }, + ], + [{ a: "" }, { a: [""] }, undefined], + [{ a: { b: "c\n" } }, {}, undefined], + [{ a: { b: "c\n" } }, { b: {} }, undefined], + [{ a: { b: "c\n" } }, { a: { b: "c\n" } }, undefined], + [ + { a: { b: "c\n" } }, + { a: { b: "d\n" } }, + { + a: { + b: `@@ -1,1 +1,1 @@ +-c ++d +`, + }, + }, + ], + [{ a: { b: "c\n" } }, { a: { d: "e\n" } }, { a: { d: "e\n" } }], + [ + { a: { b: { c: undefined } } }, + { a: { b: { c: { d: "e\n" } } } }, + { a: { b: { c: { d: "e\n" } } } }, + ], + [ + { a: { b: { c: { d: "e\n" } } } }, + { a: { b: { c: undefined } } }, + undefined, + ], + [{ a: { b: "c\n" } }, { a: { d: "e\n" } }, { a: { d: "e\n" } }], + [ + { a: { b: { c: { d: "e\n" } } } }, + { a: { b: { f: "g\n" } } }, + { a: { b: { f: "g\n" } } }, + ], + ] satisfies [ + CreatedDirectory, + CreatedDirectory, + DiffedCreatedDirectory | undefined, + ][])("%j and %j", (actual, created, expected) => { + expect(diffCreatedDirectory(actual, created, (text) => text)).toEqual( + expected, + ); + }); +}); diff --git a/packages/create-testers/src/diffCreatedDirectory.ts b/packages/create-testers/src/diffCreatedDirectory.ts new file mode 100644 index 00000000..172f3b77 --- /dev/null +++ b/packages/create-testers/src/diffCreatedDirectory.ts @@ -0,0 +1,203 @@ +import { + CreatedDirectory, + CreatedFileEntry, + CreatedFileOptions, +} from "create-fs"; +import { createTwoFilesPatch } from "diff"; +import path from "node:path"; +import { withoutUndefinedProperties } from "without-undefined-properties"; + +export interface DiffedCreatedDirectory { + [i: string]: DiffedCreatedDirectory | DiffedCreatedFileEntry | undefined; +} + +export type DiffedCreatedFileEntry = + | [string] + | [string | undefined, DiffedCreatedFileOptions?] + | CreatedFileEntry + | DiffedCreatedDirectory + | string; + +export interface DiffedCreatedFileOptions { + mode?: string; +} + +export type ProcessText = (text: string, filePath: string) => string; + +export function diffCreatedDirectory( + actual: CreatedDirectory, + created: CreatedDirectory, + processText: ProcessText, +): DiffedCreatedDirectory | undefined { + const result = diffCreatedDirectoryWorker(actual, created, ".", processText); + + return result && withoutUndefinedProperties(result); +} + +function diffCreatedDirectoryChild( + childActual: CreatedFileEntry | undefined, + childCreated: CreatedFileEntry | undefined, + pathToChild: string, + processText: ProcessText, +): DiffedCreatedFileEntry | undefined { + if (childActual === undefined) { + return childCreated; + } + + if (childCreated === undefined) { + return undefined; + } + + if (typeof childActual === "string") { + if (typeof childCreated === "string") { + return diffCreatedFileText( + childActual, + childCreated, + pathToChild, + processText, + ); + } + } + + if (Array.isArray(childActual)) { + if (Array.isArray(childCreated)) { + const fileDiff = diffCreatedFileText( + childActual[0], + childCreated[0], + pathToChild, + processText, + ); + const optionsDiff = diffCreatedFileOptions( + childActual[1], + childCreated[1], + pathToChild, + ); + + return (fileDiff ?? optionsDiff) ? [fileDiff, optionsDiff] : undefined; + } + + if (typeof childCreated === "string") { + return diffCreatedFileText( + childActual[0], + childCreated, + pathToChild, + processText, + ); + } + + return `Mismatched ${pathToChild}: actual is created file; created is ${typeof childCreated}.`; + } + + if (Array.isArray(childCreated)) { + if (typeof childActual === "string") { + return diffCreatedFileText( + childActual, + childCreated[0], + pathToChild, + processText, + ); + } + + return `Mismatched ${pathToChild}: actual is ${typeof childActual}; created is created file.`; + } + + if (typeof childActual === "object") { + if (typeof childCreated === "object") { + return diffCreatedDirectoryWorker( + childActual, + childCreated, + pathToChild, + processText, + ); + } + } + + return `Mismatched ${pathToChild}: actual is ${typeof childActual}; created is ${typeof childCreated}.`; +} + +function diffCreatedDirectoryWorker( + actual: CreatedDirectory, + created: CreatedDirectory, + pathTo: string, + processText: ProcessText, +): DiffedCreatedDirectory | undefined { + const result: DiffedCreatedDirectory = {}; + + for (const [childName, childCreated] of Object.entries(created)) { + if (!(childName in actual)) { + result[childName] = undefinedIfEmpty(childCreated); + continue; + } + + const childActual = actual[childName]; + const pathToChild = path.join(pathTo, childName); + + const childDiffed = diffCreatedDirectoryChild( + childActual, + childCreated, + pathToChild, + processText, + ); + + if (childDiffed !== undefined) { + result[childName] = childDiffed; + } + } + + return undefinedIfEmpty(withoutUndefinedProperties(result)); +} + +function diffCreatedFileText( + actual: string, + created: string, + pathToFile: string, + processText: ProcessText, +) { + const actualProcessed = processText(created, pathToFile); + const createdProcessed = processText(actual, pathToFile); + + return actualProcessed === createdProcessed + ? undefined + : createTwoFilesPatch( + pathToFile, + pathToFile, + createdProcessed, + actualProcessed, + ).replace(/^Index: .+\n=+\n-{3} .+\n\+{3} .+\n/gmu, ""); +} + +/** + * @todo + * Unclear yet how to represent a diff in the mode... + * Should a DiffedFileEntry type replace CreatedFileOptions.mode with string? + */ +function diffCreatedFileOptions( + actual: CreatedFileOptions | undefined, + created: CreatedFileOptions | undefined, + pathToFile: string, +): DiffedCreatedFileOptions | undefined { + if ( + actual?.mode === undefined || + created?.mode === undefined || + actual.mode === created.mode + ) { + return undefined; + } + + return { + mode: diffCreatedFileText( + actual.mode.toString(16), + created.mode.toString(16), + pathToFile, + (text) => text, + ), + }; +} +function undefinedIfEmpty(value: T) { + return !!value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 0 + ? undefined + : value; +} diff --git a/packages/create-testers/src/index.ts b/packages/create-testers/src/index.ts index 297b2e02..c1838ff4 100644 --- a/packages/create-testers/src/index.ts +++ b/packages/create-testers/src/index.ts @@ -1,3 +1,4 @@ +export { diffCreatedDirectory } from "./diffCreatedDirectory.js"; export { testBase } from "./testBase.js"; export { testBlock } from "./testBlock.js"; export { testInput } from "./testInput.js"; diff --git a/packages/create-testers/src/types.ts b/packages/create-testers/src/types.ts index c2af93cb..42ba020d 100644 --- a/packages/create-testers/src/types.ts +++ b/packages/create-testers/src/types.ts @@ -1,4 +1,5 @@ -import { SystemRunner, TakeInput, WritingFileSystem } from "create"; +import { SystemRunner, TakeInput } from "create"; +import { WritingFileSystem } from "create-fs"; export interface MockSystemOptions { fetch?: typeof fetch; diff --git a/packages/create/package.json b/packages/create/package.json index b787775e..29b9b207 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -28,6 +28,7 @@ "@clack/prompts": "^0.9.1", "cached-factory": "^0.1.0", "chalk": "^5.4.1", + "create-fs": "workspace:^", "execa": "^9.5.2", "get-github-auth-token": "^0.1.0", "hash-object": "^5.0.1", @@ -40,7 +41,8 @@ "zod": "^3.24.1" }, "devDependencies": { - "@types/hosted-git-info": "3.0.5" + "@types/hosted-git-info": "3.0.5", + "@types/node": "^22.10.5" }, "engines": { "node": ">=18" diff --git a/packages/create/src/cli/prompts/promptForBaseOptions.test.ts b/packages/create/src/cli/prompts/promptForBaseOptions.test.ts index 697fe463..0bef89c3 100644 --- a/packages/create/src/cli/prompts/promptForBaseOptions.test.ts +++ b/packages/create/src/cli/prompts/promptForBaseOptions.test.ts @@ -27,6 +27,7 @@ const system = { octokit: {} as Octokit, }, fs: { + readDirectory: vi.fn(), readFile: vi.fn(), writeDirectory: vi.fn(), writeFile: vi.fn(), diff --git a/packages/create/src/creators/createInput.test.ts b/packages/create/src/creators/createInput.test.ts index 23c986fb..854e5931 100644 --- a/packages/create/src/creators/createInput.test.ts +++ b/packages/create/src/creators/createInput.test.ts @@ -14,7 +14,10 @@ describe("createInput", () => { const actual = input({ fetchers: createSystemFetchers({ fetch: vi.fn() }), - fs: { readFile: vi.fn() }, + fs: { + readDirectory: vi.fn(), + readFile: vi.fn(), + }, runner: vi.fn(), take: vi.fn(), }); @@ -37,7 +40,10 @@ describe("createInput", () => { offset: 1000, }, fetchers: createSystemFetchers({ fetch: vi.fn() }), - fs: { readFile: vi.fn() }, + fs: { + readDirectory: vi.fn(), + readFile: vi.fn(), + }, runner: vi.fn(), take: vi.fn(), }); diff --git a/packages/create/src/index.ts b/packages/create/src/index.ts index 35076a9e..d6db9e6b 100644 --- a/packages/create/src/index.ts +++ b/packages/create/src/index.ts @@ -23,7 +23,6 @@ export * from "./runners/runPreset.js"; // TODO: These might be better as their own packages? export * from "./runners/applyFilesToSystem.js"; export * from "./system/createSystemContext.js"; -export * from "./system/createWritingFileSystem.js"; export * from "./utils/awaitLazyProperties.js"; // Types diff --git a/packages/create/src/mergers/mergeFileCreations.test.ts b/packages/create/src/mergers/mergeCreatedDirectories.test.ts similarity index 68% rename from packages/create/src/mergers/mergeFileCreations.test.ts rename to packages/create/src/mergers/mergeCreatedDirectories.test.ts index c73ee7b3..87c59fac 100644 --- a/packages/create/src/mergers/mergeFileCreations.test.ts +++ b/packages/create/src/mergers/mergeCreatedDirectories.test.ts @@ -1,7 +1,7 @@ +import { CreatedDirectory } from "create-fs"; import { describe, expect, test } from "vitest"; -import { CreatedFiles } from "../types/creations.js"; -import { mergeFileCreations } from "./mergeFileCreations.js"; +import { mergeCreatedDirectories } from "./mergeCreatedDirectories.js"; describe("mergeFileCreations", () => { test.each([ @@ -27,13 +27,21 @@ describe("mergeFileCreations", () => { { a: "" }, "Conflicting created directory and file at path: 'a'.", ], - ] satisfies [CreatedFiles, CreatedFiles, CreatedFiles | string][])( + ] satisfies [ + CreatedDirectory, + CreatedDirectory, + CreatedDirectory | string, + ][])( "%j with %j", - (first: CreatedFiles, second: CreatedFiles, expected?: object | string) => { + ( + first: CreatedDirectory, + second: CreatedDirectory, + expected?: object | string, + ) => { if (typeof expected === "string") { - expect(() => mergeFileCreations(first, second)).toThrow(expected); + expect(() => mergeCreatedDirectories(first, second)).toThrow(expected); } else { - expect(mergeFileCreations(first, second)).toEqual(expected); + expect(mergeCreatedDirectories(first, second)).toEqual(expected); } }, ); diff --git a/packages/create/src/mergers/mergeFileCreations.ts b/packages/create/src/mergers/mergeCreatedDirectories.ts similarity index 60% rename from packages/create/src/mergers/mergeFileCreations.ts rename to packages/create/src/mergers/mergeCreatedDirectories.ts index af8d7cdb..f5583a86 100644 --- a/packages/create/src/mergers/mergeFileCreations.ts +++ b/packages/create/src/mergers/mergeCreatedDirectories.ts @@ -1,19 +1,20 @@ -import { CreatedFiles } from "../types/creations.js"; +import { CreatedDirectory } from "create-fs"; + import { mergeFileEntries } from "./mergeFileEntries.js"; -export function mergeFileCreations( - firsts: CreatedFiles, - seconds: CreatedFiles, +export function mergeCreatedDirectories( + firsts: CreatedDirectory, + seconds: CreatedDirectory, ) { - return mergeFileCreationsWorker(firsts, seconds, []); + return mergeCreatedDirectoriesWorker(firsts, seconds, []); } -function mergeFileCreationsWorker( - firsts: CreatedFiles, - seconds: CreatedFiles, +function mergeCreatedDirectoriesWorker( + firsts: CreatedDirectory, + seconds: CreatedDirectory, path: string[], ) { - const result: CreatedFiles = { ...firsts }; + const result: CreatedDirectory = { ...firsts }; for (const i in seconds) { const second = seconds[i]; @@ -36,7 +37,11 @@ function mergeFileCreationsWorker( } result[i] = firstIsDirectory - ? mergeFileCreationsWorker(first, second as CreatedFiles, nextPath) + ? mergeCreatedDirectoriesWorker( + first, + second as CreatedDirectory, + nextPath, + ) : mergeFileEntries(first, second, nextPath); } diff --git a/packages/create/src/mergers/mergeCreations.ts b/packages/create/src/mergers/mergeCreations.ts index b326f690..e16097ce 100644 --- a/packages/create/src/mergers/mergeCreations.ts +++ b/packages/create/src/mergers/mergeCreations.ts @@ -3,7 +3,7 @@ import { withoutUndefinedProperties } from "without-undefined-properties"; import { Creation } from "../types/creations.js"; import { applyMerger } from "./applyMerger.js"; import { mergeAddons } from "./mergeAddons.js"; -import { mergeFileCreations } from "./mergeFileCreations.js"; +import { mergeCreatedDirectories } from "./mergeCreatedDirectories.js"; import { mergeRequests } from "./mergeRequests.js"; import { mergeScripts } from "./mergeScripts.js"; import { mergeSuggestions } from "./mergeSuggestions.js"; @@ -14,7 +14,7 @@ export function mergeCreations( ): Partial> { return withoutUndefinedProperties({ addons: applyMerger(first.addons, second.addons, mergeAddons), - files: applyMerger(first.files, second.files, mergeFileCreations), + files: applyMerger(first.files, second.files, mergeCreatedDirectories), requests: applyMerger(first.requests, second.requests, mergeRequests), scripts: applyMerger(first.scripts, second.scripts, mergeScripts), suggestions: applyMerger( diff --git a/packages/create/src/mergers/mergeFileEntries.test.ts b/packages/create/src/mergers/mergeFileEntries.test.ts index 047297ab..2bda5c65 100644 --- a/packages/create/src/mergers/mergeFileEntries.test.ts +++ b/packages/create/src/mergers/mergeFileEntries.test.ts @@ -1,6 +1,6 @@ +import { CreatedFileEntry } from "create-fs"; import { describe, expect, test } from "vitest"; -import { CreatedFileEntry } from "../types/creations.js"; import { mergeFileEntries } from "./mergeFileEntries.js"; const path = ["test", "path"]; diff --git a/packages/create/src/mergers/mergeFileEntries.ts b/packages/create/src/mergers/mergeFileEntries.ts index dad2d8cc..3336ea38 100644 --- a/packages/create/src/mergers/mergeFileEntries.ts +++ b/packages/create/src/mergers/mergeFileEntries.ts @@ -1,4 +1,4 @@ -import { CreatedFileEntry } from "../types/creations.js"; +import { CreatedFileEntry } from "create-fs"; export function mergeFileEntries( first: CreatedFileEntry | undefined, diff --git a/packages/create/src/producers/produceBase.test.ts b/packages/create/src/producers/produceBase.test.ts index 1937adf1..572c29f1 100644 --- a/packages/create/src/producers/produceBase.test.ts +++ b/packages/create/src/producers/produceBase.test.ts @@ -11,6 +11,7 @@ const system = { octokit: {} as Octokit, }, fs: { + readDirectory: vi.fn(), readFile: vi.fn(), writeDirectory: vi.fn(), writeFile: vi.fn(), diff --git a/packages/create/src/producers/produceBlocks.test.ts b/packages/create/src/producers/produceBlocks.test.ts index 67e93981..2b1485ea 100644 --- a/packages/create/src/producers/produceBlocks.test.ts +++ b/packages/create/src/producers/produceBlocks.test.ts @@ -12,7 +12,12 @@ const presetContext = { log: vi.fn(), }, fetchers: createSystemFetchers({ fetch: vi.fn() }), - fs: { readFile: vi.fn(), writeDirectory: vi.fn(), writeFile: vi.fn() }, + fs: { + readDirectory: vi.fn(), + readFile: vi.fn(), + writeDirectory: vi.fn(), + writeFile: vi.fn(), + }, runner: vi.fn(), take: vi.fn(), }; diff --git a/packages/create/src/producers/producePreset.test.ts b/packages/create/src/producers/producePreset.test.ts index 812282d8..08d6afd7 100644 --- a/packages/create/src/producers/producePreset.test.ts +++ b/packages/create/src/producers/producePreset.test.ts @@ -19,6 +19,7 @@ const system = { octokit: {} as Octokit, }, fs: { + readDirectory: vi.fn(), readFile: vi.fn(), writeDirectory: vi.fn(), writeFile: vi.fn(), diff --git a/packages/create/src/runners/applyFilesToSystem.ts b/packages/create/src/runners/applyFilesToSystem.ts index f65038d9..60ed9184 100644 --- a/packages/create/src/runners/applyFilesToSystem.ts +++ b/packages/create/src/runners/applyFilesToSystem.ts @@ -1,11 +1,9 @@ +import { CreatedDirectory, WritingFileSystem } from "create-fs"; import * as path from "node:path"; import prettier from "prettier"; -import { CreatedFiles } from "../types/creations.js"; -import { WritingFileSystem } from "../types/system.js"; - export async function applyFilesToSystem( - files: CreatedFiles, + files: CreatedDirectory, system: WritingFileSystem, directory: string, ) { @@ -45,7 +43,7 @@ function inferParser(fileName: string, text: string) { } async function writeToSystemWorker( - files: CreatedFiles, + files: CreatedDirectory, system: WritingFileSystem, basePath: string, ) { diff --git a/packages/create/src/runners/runBlock.test.ts b/packages/create/src/runners/runBlock.test.ts index 144bacd5..45e04bd3 100644 --- a/packages/create/src/runners/runBlock.test.ts +++ b/packages/create/src/runners/runBlock.test.ts @@ -18,6 +18,7 @@ function createSystem() { octokit: {} as Octokit, }, fs: { + readDirectory: noop("readDirectory"), readFile: noop("readFile"), writeDirectory: vi.fn(), writeFile: vi.fn(), diff --git a/packages/create/src/runners/runPreset.test.ts b/packages/create/src/runners/runPreset.test.ts index b16e3840..c8b7bb4a 100644 --- a/packages/create/src/runners/runPreset.test.ts +++ b/packages/create/src/runners/runPreset.test.ts @@ -18,6 +18,7 @@ function createSystem() { octokit: {} as Octokit, }, fs: { + readDirectory: noop("readDirectory"), readFile: noop("readFile"), writeDirectory: vi.fn(), writeFile: vi.fn(), diff --git a/packages/create/src/system/createSystemContext.ts b/packages/create/src/system/createSystemContext.ts index fd211fd9..e47b1724 100644 --- a/packages/create/src/system/createSystemContext.ts +++ b/packages/create/src/system/createSystemContext.ts @@ -1,10 +1,11 @@ +import { createWritingFileSystem } from "create-fs"; + import { TakeInput } from "../types/inputs.js"; import { NativeSystem, SystemContext, SystemDisplay } from "../types/system.js"; import { createOfflineFetchers } from "./createOfflineFetchers.js"; import { createSystemDisplay } from "./createSystemDisplay.js"; import { createSystemFetchers } from "./createSystemFetchers.js"; import { createSystemRunner } from "./createSystemRunner.js"; -import { createWritingFileSystem } from "./createWritingFileSystem.js"; export interface SystemContextSettings extends Partial { auth?: string; diff --git a/packages/create/src/system/createWritingFileSystem.ts b/packages/create/src/system/createWritingFileSystem.ts deleted file mode 100644 index 4480eb46..00000000 --- a/packages/create/src/system/createWritingFileSystem.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as nodeFS from "node:fs/promises"; - -export function createWritingFileSystem() { - return { - readFile: async (filePath: string) => - (await nodeFS.readFile(filePath)).toString(), - writeDirectory: async (directoryPath: string) => - void (await nodeFS.mkdir(directoryPath, { recursive: true })), - writeFile: async (filePath: string, contents: string) => { - await nodeFS.writeFile(filePath, contents); - }, - }; -} diff --git a/packages/create/src/types/creations.ts b/packages/create/src/types/creations.ts index 4c497bad..c796bc34 100644 --- a/packages/create/src/types/creations.ts +++ b/packages/create/src/types/creations.ts @@ -1,3 +1,5 @@ +import { CreatedDirectory } from "create-fs"; + import { BlockWithAddons } from "./blocks.js"; import { SystemFetchers } from "./system.js"; @@ -9,25 +11,6 @@ export interface CreatedBlockAddons< block: BlockWithAddons; } -export type CreatedFileEntry = - | [string, CreatedFileOptions] - | [string] - | CreatedFiles - | false - | string; - -export interface CreatedFileOptions { - /** - * File mode (permission and sticky bits) per chmod(). - * @example 0o777 for an executable file. - */ - mode?: number; -} - -export interface CreatedFiles { - [i: string]: CreatedFileEntry | undefined; -} - export interface CreatedRequest { id: string; send: CreatedRequestSender; @@ -46,7 +29,7 @@ export type Creation = DirectCreation & IndirectCreation; export interface DirectCreation { - files: CreatedFiles; + files: CreatedDirectory; requests: CreatedRequest[]; scripts: CreatedScript[]; } diff --git a/packages/create/src/types/inputs.ts b/packages/create/src/types/inputs.ts index 8254e0bb..b57866c8 100644 --- a/packages/create/src/types/inputs.ts +++ b/packages/create/src/types/inputs.ts @@ -1,8 +1,8 @@ +import { ReadingFileSystem } from "create-fs"; + import { TakeContext } from "./context.js"; import { SystemFetchers, SystemRunner } from "./system.js"; -export type FileSystemReadFile = (filePath: string) => Promise; - export type Input< Result, Args extends object | undefined = undefined, @@ -15,7 +15,7 @@ export type InputArgsFor = export interface InputContext extends TakeContext { fetchers: SystemFetchers; - fs: InputFileSystem; + fs: ReadingFileSystem; runner: SystemRunner; } @@ -36,10 +36,6 @@ export interface InputContextWithArgs args: Args; } -export interface InputFileSystem { - readFile: FileSystemReadFile; -} - export type InputWithArgs = ( context: InputContextWithArgs, ) => Result; diff --git a/packages/create/src/types/system.ts b/packages/create/src/types/system.ts index 4b7496b4..44fc833f 100644 --- a/packages/create/src/types/system.ts +++ b/packages/create/src/types/system.ts @@ -1,20 +1,8 @@ +import { WritingFileSystem } from "create-fs"; import { ExecaError, Result } from "execa"; import { Octokit } from "octokit"; import { TakeContext } from "./context.js"; -import { InputFileSystem } from "./inputs.js"; - -export type FileSystemWriteDirectory = (directoryPath: string) => Promise; - -export type FileSystemWriteFile = ( - filePath: string, - contents: string, - options?: FileSystemWriteFileOptions, -) => Promise; - -export interface FileSystemWriteFileOptions { - mode?: number; -} export interface NativeSystem { fetchers: SystemFetchers; @@ -44,8 +32,3 @@ export interface SystemFetchers { } export type SystemRunner = (command: string) => Promise; - -export interface WritingFileSystem extends InputFileSystem { - writeDirectory: FileSystemWriteDirectory; - writeFile: FileSystemWriteFile; -} diff --git a/packages/site/astro.config.ts b/packages/site/astro.config.ts index 45bddff4..effa898a 100644 --- a/packages/site/astro.config.ts +++ b/packages/site/astro.config.ts @@ -48,10 +48,19 @@ export default defineConfig({ { label: "Creators", link: "engine/apis/creators" }, { label: "Producers", link: "engine/apis/producers" }, { label: "Runners", link: "engine/apis/runners" }, - { label: "Testers", link: "engine/apis/testers" }, ], label: "APIs", }, + { + items: [ + { label: "create-fs", link: "engine/packages/create-fs" }, + { + label: "create-testers", + link: "engine/packages/create-testers", + }, + ], + label: "Packages", + }, ], label: "Templating Engine", }, diff --git a/packages/site/src/content/docs/engine/packages/create-fs.mdx b/packages/site/src/content/docs/engine/packages/create-fs.mdx new file mode 100644 index 00000000..213f51b4 --- /dev/null +++ b/packages/site/src/content/docs/engine/packages/create-fs.mdx @@ -0,0 +1,68 @@ +--- +title: create-fs +--- + +import { PackageManagers } from "starlight-package-managers"; + +The file system used by create. ๐Ÿ—„๏ธ + + + +The separate `create-fs` package includes types and utility functions for the file system used in [Runtime > Creations > `files`](https://www.create.bingo/engine/runtime/creations#files). + +This file system is a simplified abstraction over the lower-level APIs in Node.js and other platforms. +APIs and data are optimized for simplicity and ease of use, rather than completeness. + +For example, given a structure like: + +```plaintext +/ +โ””โ”€โ”€ README.md +โ””โ”€โ”€ src + โ””โ”€โ”€ index.ts +``` + +`create-fs` would represent that structure with an object like: + +```json +{ + "README.md": "...", + "src": { + "index.ts": "..." + } +} +``` + +## APIs + +### `intakeFromDirectory` + +Given a directory path, reads in the directory as to the `create-fs` directory structure. + +```ts +import { intakeFromDirectory } from "create-fs"; + +// Result: { "index.ts": "..." } +await intakeFromDirectory("src"); +``` + +Parameters: + +1. `directoryPath: string` _(required)_ +2. `settings: IntakeFromDirectorySettings` _(optional)_: + - [`exclude: RegExp`](#intakefromdirectory-exclude) + +#### `exclude` {#intakefromdirectory-exclude} + +An optional regular expression to filter out directory children. + +For example, you may want to avoid `.git` and `node_modules` directories: + +```ts +import { intakeFromDirectory } from "create-fs"; + +// Result: { README.md: "...", src: { "index.ts": "..." }} +await intakeFromDirectory(".", { + exclude: /node_modules|^\.git$/, +}); +``` diff --git a/packages/site/src/content/docs/engine/apis/testers.mdx b/packages/site/src/content/docs/engine/packages/create-testers.mdx similarity index 89% rename from packages/site/src/content/docs/engine/apis/testers.mdx rename to packages/site/src/content/docs/engine/packages/create-testers.mdx index d6cd6696..a0fdf61d 100644 --- a/packages/site/src/content/docs/engine/apis/testers.mdx +++ b/packages/site/src/content/docs/engine/packages/create-testers.mdx @@ -1,19 +1,68 @@ --- -title: Tester APIs +title: create-testers --- +import { PackageManagers } from "starlight-package-managers"; + +Test utilities for composable, testable, type-safe templates. โš—๏ธ + + + The separate `create-testers` package includes testing utilities that run [Producers](./producers) in fully virtualized environments. This is intended for use in unit tests that should mock out all [System Context](../runtime/contexts#system-contexts). -```shell -npm i create-testers -D -``` - :::tip `create-testers` is test-framework-agnostic. You can use it with any typical testing framework, including [Jest](https://jestjs.io) and [Vitest](https://vitest.dev). ::: +## `diffCreatedDirectory` + +Produces a nested object diff comparing the [`files`](../runtime/creations#files) between an actual directory and produced results from a Creation. + +This is most commonly useful in conjunction with the [`create-fs` `intakeFromDirectory` API](./create-fs#intakefromdirectory). + +For example, this test snippet runs an integration test for a template repository, making sure its files on disk match its own `everything` Preset: + +```ts +import { producePreset } from "create"; +import { intakeFromDirectory } from "create-fs"; +import { diffCreatedDirectory } from "create-testers"; + +import { presetEverything } from "./presetEverything.js"; + +const actual = await intakeFromDirectory(".", { + exclude: /node_modules|^\.git$/, +}); + +const created = await producePreset(presetEverything); + +expect(diffCreatedDirectory(actual, created)).toBeUndefined(); +``` + +### DiffedCreatedDirectory + +`diffCreatedDirectory` will return an object matching a `DiffedCreatedDirectory` type. +Any files that are different in the `created` argument compared to the `actual` argument will be included in that object. + +Differences are computed as: + +- If a file exists in `created` but not in `actual`, it will be included as-is +- If a file exists in both but has different text content and/or `mode`, it will be included as a diff using [`diff`](https://www.npmjs.com/package/diff)'s `createTwoFilesPatch`, omitting headers before the `@@` + +For example, if a `src/index.ts` has content `abc` in `actual` but content `bbc` in `created`, the diff would look like: + +```js +{ + "src": { + "index.ts": `@@ -1,1 +1,1 @@ +-abc ++bbc +` + } +} +``` + ## `testBase` For [Bases](../concepts/bases), a `testBase` function is exported that is analogous to [`produceBase`](./producers#producebase). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94d364e2..c8f74f6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: chalk: specifier: ^5.4.1 version: 5.4.1 + create-fs: + specifier: workspace:^ + version: link:../create-fs execa: specifier: ^9.5.2 version: 9.5.2 @@ -150,16 +153,37 @@ importers: '@types/hosted-git-info': specifier: 3.0.5 version: 3.0.5 + '@types/node': + specifier: ^22.10.5 + version: 22.10.6 + + packages/create-fs: + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.10.6 packages/create-testers: dependencies: create: specifier: workspace:^ version: link:../create + create-fs: + specifier: workspace:^ + version: link:../create-fs + diff: + specifier: ^7.0.0 + version: 7.0.0 octokit: specifier: ^4.1.0 version: 4.1.0 + without-undefined-properties: + specifier: ^0.1.1 + version: 0.1.1 devDependencies: + '@types/diff': + specifier: ^7.0.0 + version: 7.0.0 zod: specifier: 3.24.1 version: 3.24.1 @@ -1405,6 +1429,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/diff@7.0.0': + resolution: {integrity: sha512-sVpkpbnTJL9CYoDf4U+tHaQLe5HiTaHWY7m9FuYA7oMCHwC9ie0Vh9eIGapyzYrU3+pILlSY2fAc4elfw5m4dg==} + '@types/eslint-plugin-markdown@2.0.2': resolution: {integrity: sha512-ImmEw5xBVb9vCaFfQ+5kUcVatUO4XPpTvryAmhpKzalUKhDb3EZmeuHvIUO6E1/WDOTw+/b9qlWsZhxULhZdfQ==} @@ -2035,6 +2062,10 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} + direction@2.0.1: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true @@ -5498,6 +5529,8 @@ snapshots: dependencies: '@types/ms': 0.7.34 + '@types/diff@7.0.0': {} + '@types/eslint-plugin-markdown@2.0.2': dependencies: '@types/eslint': 9.6.0 @@ -6277,6 +6310,8 @@ snapshots: diff@5.2.0: {} + diff@7.0.0: {} + direction@2.0.1: {} dlv@1.1.3: {} diff --git a/tsconfig.build.json b/tsconfig.build.json index 90c8ebcc..e7aa95bb 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,7 @@ "extends": "./tsconfig.base.json", "files": [], "references": [ + { "path": "./packages/create-fs" }, { "path": "./packages/create" }, { "path": "./packages/create-testers" }, { "path": "./packages/input-from-file" }, diff --git a/vitest.config.ts b/vitest.config.ts index 8a48530f..faec7445 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "**/*.astro", "**/vitest.*.ts", "packages/*/src/index.ts", + "packages/create-fs/src/create*FileSystem.ts", "packages/site/astro.config.ts", "packages/site/src/content", ],