diff --git a/packages/create-fs/src/createWritingFileSystem.test.ts b/packages/create-fs/src/createWritingFileSystem.test.ts new file mode 100644 index 00000000..5a8ba178 --- /dev/null +++ b/packages/create-fs/src/createWritingFileSystem.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createWritingFileSystem } from "./createWritingFileSystem.js"; + +const mockMkdir = vi.fn(); +const mockWriteFile = vi.fn(); + +vi.mock("node:fs/promises", () => ({ + get mkdir() { + return mockMkdir; + }, + get writeFile() { + return mockWriteFile; + }, +})); + +const contents = "abc123"; +const directoryPath = "path/to/file"; +const filePath = "path/to/file"; + +describe("createWritingFileSystem", () => { + describe("writeDirectory", () => { + it("writes with recursive: true", async () => { + const system = createWritingFileSystem(); + + await system.writeDirectory(directoryPath); + + expect(mockMkdir).toHaveBeenCalledWith(directoryPath, { + recursive: true, + }); + }); + }); + + describe("writeFile", () => { + it("writes with mode 0x644 when options does not exist", async () => { + const system = createWritingFileSystem(); + + await system.writeFile(filePath, contents); + + expect(mockWriteFile).toHaveBeenCalledWith(filePath, contents, { + mode: 0x644, + }); + }); + + it("writes with mode 0x644 when options.executable is false", async () => { + const system = createWritingFileSystem(); + + await system.writeFile(filePath, contents, { executable: false }); + + expect(mockWriteFile).toHaveBeenCalledWith(filePath, contents, { + mode: 0x644, + }); + }); + + it("writes with mode 0x755 when options.executable is true", async () => { + const system = createWritingFileSystem(); + + await system.writeFile(filePath, contents, { executable: true }); + + expect(mockWriteFile).toHaveBeenCalledWith(filePath, contents, { + mode: 0x755, + }); + }); + }); +}); diff --git a/packages/create-fs/src/createWritingFileSystem.ts b/packages/create-fs/src/createWritingFileSystem.ts index 72a6efa0..a7c973be 100644 --- a/packages/create-fs/src/createWritingFileSystem.ts +++ b/packages/create-fs/src/createWritingFileSystem.ts @@ -1,14 +1,21 @@ import * as fs from "node:fs/promises"; import { createReadingFileSystem } from "./createReadingFileSystem.js"; +import { CreatedFileOptions } from "./types/files.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); + writeFile: async ( + filePath: string, + contents: string, + options?: CreatedFileOptions, + ) => { + await fs.writeFile(filePath, contents, { + mode: options?.executable ? 0x755 : 0x644, + }); }, }; } diff --git a/packages/create-fs/src/intake/intakeFromDirectory.test.ts b/packages/create-fs/src/intake/intakeFromDirectory.test.ts index 6626f0c5..e72c9a66 100644 --- a/packages/create-fs/src/intake/intakeFromDirectory.test.ts +++ b/packages/create-fs/src/intake/intakeFromDirectory.test.ts @@ -37,18 +37,18 @@ describe("intakeFromDirectory", () => { mockStat .mockResolvedValueOnce({ isDirectory: () => false, - mode: 123, + mode: 0x644, }) .mockResolvedValueOnce({ isDirectory: () => false, - mode: 456, + mode: 0x755, }); const directory = await intakeFromDirectory("from"); expect(directory).toEqual({ - "included-a": ["contents-a", { mode: 123 }], - "included-b": ["contents-b", { mode: 456 }], + "included-a": ["contents-a", { executable: false }], + "included-b": ["contents-b", { executable: true }], }); expect(mockReaddir.mock.calls).toEqual([["from"]]); expect(mockStat.mock.calls).toEqual([ @@ -65,11 +65,11 @@ describe("intakeFromDirectory", () => { mockStat .mockResolvedValueOnce({ isDirectory: () => false, - mode: 123, + mode: 0x644, }) .mockResolvedValueOnce({ isDirectory: () => false, - mode: 456, + mode: 0x755, }); const directory = await intakeFromDirectory("from", { @@ -77,8 +77,8 @@ describe("intakeFromDirectory", () => { }); expect(directory).toEqual({ - "included-a": ["contents-a", { mode: 123 }], - "included-b": ["contents-b", { mode: 456 }], + "included-a": ["contents-a", { executable: false }], + "included-b": ["contents-b", { executable: true }], }); expect(mockReaddir.mock.calls).toEqual([["from"]]); expect(mockStat.mock.calls).toEqual([ @@ -98,7 +98,7 @@ describe("intakeFromDirectory", () => { }) .mockResolvedValueOnce({ isDirectory: () => false, - mode: 123, + mode: 0x644, }); const directory = await intakeFromDirectory("from", { @@ -107,7 +107,7 @@ describe("intakeFromDirectory", () => { expect(directory).toEqual({ middle: { - included: ["contents", { mode: 123 }], + included: ["contents", { executable: false }], }, }); expect(mockReaddir.mock.calls).toEqual([["from"], ["from/middle"]]); diff --git a/packages/create-fs/src/intake/intakeFromDirectory.ts b/packages/create-fs/src/intake/intakeFromDirectory.ts index 4c8cc829..9d9c9b10 100644 --- a/packages/create-fs/src/intake/intakeFromDirectory.ts +++ b/packages/create-fs/src/intake/intakeFromDirectory.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import path from "node:path"; import { CreatedDirectory } from "../types/files.js"; +import { isModeExecutable } from "./isModeExecutable.js"; export interface IntakeFromDirectorySettings { exclude?: RegExp; @@ -24,7 +25,10 @@ export async function intakeFromDirectory( directory[child] = stat.isDirectory() ? await intakeFromDirectory(childPath, settings) - : [(await fs.readFile(childPath)).toString(), { mode: stat.mode }]; + : [ + (await fs.readFile(childPath)).toString(), + { executable: isModeExecutable(stat.mode) }, + ]; } return directory; diff --git a/packages/create-fs/src/intake/isModeExecutable.test.ts b/packages/create-fs/src/intake/isModeExecutable.test.ts new file mode 100644 index 00000000..f5088c9f --- /dev/null +++ b/packages/create-fs/src/intake/isModeExecutable.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; + +import { isModeExecutable } from "./isModeExecutable.js"; + +describe("isModeExecutable", () => { + test.each([ + ["0x755", true], + ["0x777", true], + ["0x644", false], + ])("%s is %j", (mode, expected) => { + expect(isModeExecutable(parseInt(mode, 16))).toBe(expected); + }); +}); diff --git a/packages/create-fs/src/intake/isModeExecutable.ts b/packages/create-fs/src/intake/isModeExecutable.ts new file mode 100644 index 00000000..2d6d714a --- /dev/null +++ b/packages/create-fs/src/intake/isModeExecutable.ts @@ -0,0 +1,3 @@ +export function isModeExecutable(mode: number) { + return (mode & 0o1) !== 0; +} diff --git a/packages/create-fs/src/types/files.ts b/packages/create-fs/src/types/files.ts index 456566a8..88ad72cd 100644 --- a/packages/create-fs/src/types/files.ts +++ b/packages/create-fs/src/types/files.ts @@ -11,8 +11,7 @@ export type CreatedFileEntry = export interface CreatedFileOptions { /** - * File mode (permission and sticky bits) per chmod(). - * @example 0o777 for an executable file. + * Whether to set executable permissions (e.g. 0x755) instead of non-executable (e.g. 0x644). */ - mode?: number; + executable?: boolean; } diff --git a/packages/create-fs/src/types/system.ts b/packages/create-fs/src/types/system.ts index 578b3687..19ac9d07 100644 --- a/packages/create-fs/src/types/system.ts +++ b/packages/create-fs/src/types/system.ts @@ -21,7 +21,7 @@ export type WriteFile = ( ) => Promise; export interface WriteFileOptions { - mode?: number; + executable?: boolean; } export interface WritingFileSystem extends ReadingFileSystem { diff --git a/packages/create-testers/src/diffCreatedDirectory.test.ts b/packages/create-testers/src/diffCreatedDirectory.test.ts index 2775b8dc..40daef4d 100644 --- a/packages/create-testers/src/diffCreatedDirectory.test.ts +++ b/packages/create-testers/src/diffCreatedDirectory.test.ts @@ -34,30 +34,42 @@ describe("diffCreatedDirectory", () => { ], [{ a: "b\n" }, {}, undefined], [{ a: "" }, { a: [""] }, undefined], - [{ a: "" }, { a: ["", { mode: undefined }] }, undefined], - [{ a: "" }, { a: ["", { mode: 123 }] }, undefined], - [{ a: [""] }, { a: ["", { mode: 123 }] }, undefined], + [{ a: "" }, { a: ["", { executable: undefined }] }, undefined], + [{ a: "" }, { a: ["", { executable: true }] }, undefined], + [{ a: [""] }, { a: ["", { executable: true }] }, undefined], [ - { a: ["", { mode: undefined }] }, - { a: ["", { mode: undefined }] }, + { a: ["", { executable: undefined }] }, + { a: ["", { executable: 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: ["", { executable: undefined }] }, + { a: ["", { executable: true }] }, + undefined, + ], + [ + { a: ["", { executable: true }] }, + { a: ["", { executable: true }] }, + undefined, + ], + [{ a: ["", { executable: true }] }, { a: [""] }, undefined], + [{ a: ["", { executable: true }] }, { a: ["", {}] }, undefined], + [ + { a: ["", { executable: true }] }, + { a: ["", { executable: undefined }] }, + undefined, + ], + [ + { a: ["", { executable: true }] }, + { a: ["", { executable: false }] }, { a: [ undefined, { - mode: `@@ -1,1 +1,1 @@ --7b + executable: `@@ -1,1 +1,1 @@ +-true \\ No newline at end of file -+1c8 ++false \\ No newline at end of file `, }, diff --git a/packages/create-testers/src/diffCreatedDirectory.ts b/packages/create-testers/src/diffCreatedDirectory.ts index 172f3b77..8978a54c 100644 --- a/packages/create-testers/src/diffCreatedDirectory.ts +++ b/packages/create-testers/src/diffCreatedDirectory.ts @@ -19,7 +19,7 @@ export type DiffedCreatedFileEntry = | string; export interface DiffedCreatedFileOptions { - mode?: string; + executable?: string; } export type ProcessText = (text: string, filePath: string) => string; @@ -177,17 +177,17 @@ function diffCreatedFileOptions( pathToFile: string, ): DiffedCreatedFileOptions | undefined { if ( - actual?.mode === undefined || - created?.mode === undefined || - actual.mode === created.mode + actual?.executable === undefined || + created?.executable === undefined || + actual.executable === created.executable ) { return undefined; } return { - mode: diffCreatedFileText( - actual.mode.toString(16), - created.mode.toString(16), + executable: diffCreatedFileText( + actual.executable.toString(), + created.executable.toString(), pathToFile, (text) => text, ), diff --git a/packages/create/src/mergers/mergeFileEntries.test.ts b/packages/create/src/mergers/mergeFileEntries.test.ts index 2bda5c65..66304df5 100644 --- a/packages/create/src/mergers/mergeFileEntries.test.ts +++ b/packages/create/src/mergers/mergeFileEntries.test.ts @@ -26,14 +26,14 @@ describe("mergeFileEntries", () => { [["a", {}], ["a", {}], "a"], [["a", {}], ["a", {}], "a"], [ - ["a", { mode: 0o123 }], - ["a", { mode: 0o123 }], - ["a", { mode: 0o123 }], + ["a", { executable: true }], + ["a", { executable: true }], + ["a", { executable: true }], ], [ - ["a", { mode: 0o123 }], - ["a", { mode: 0o456 }], - new Error(`Conflicting created file modes at path: 'test/path'.`), + ["a", { executable: true }], + ["a", { executable: false }], + new Error(`Conflicting created file executable at path: 'test/path'.`), ], ["a", "b", new Error("Conflicting created files at path: 'test/path'.")], [ diff --git a/packages/create/src/mergers/mergeFileEntries.ts b/packages/create/src/mergers/mergeFileEntries.ts index 3336ea38..c73311db 100644 --- a/packages/create/src/mergers/mergeFileEntries.ts +++ b/packages/create/src/mergers/mergeFileEntries.ts @@ -22,15 +22,15 @@ export function mergeFileEntries( throw new Error(`Conflicting created files at path: '${path.join("/")}'.`); } - if (firstSettings?.mode !== secondSettings?.mode) { + if (firstSettings?.executable !== secondSettings?.executable) { throw new Error( - `Conflicting created file modes at path: '${path.join("/")}'.`, + `Conflicting created file executable at path: '${path.join("/")}'.`, ); } - const mode = firstSettings?.mode ?? secondSettings?.mode; + const executable = firstSettings?.executable ?? secondSettings?.executable; - return mode ? [firstFile as string, { mode }] : firstFile; + return executable ? [firstFile as string, { executable }] : firstFile; } function isBlankEntry(entry: CreatedFileEntry | undefined) { diff --git a/packages/site/src/content/docs/engine/runtime/creations.mdx b/packages/site/src/content/docs/engine/runtime/creations.mdx index 1890823d..0201efc3 100644 --- a/packages/site/src/content/docs/engine/runtime/creations.mdx +++ b/packages/site/src/content/docs/engine/runtime/creations.mdx @@ -79,7 +79,7 @@ Each property in a `files` object may be one of the following: - `object`: A directory, whose properties recursively are file creations - `string`: A file to be created - `[string, CreatedFileOptions]`: A file to be created with [`fsPromises.writeFile` options](https://nodejs.org/api/fs.html#fspromiseswritefilefile-data-options): - - `mode`: Integer mode, such as `0o777` to make executable + - `executable`: Whether to add permissions to make the file executable For example, this Block generates an executable `.husky/pre-commit` file: @@ -90,7 +90,7 @@ export const blockPreCommit = base.createBlock({ produce() { return { ".husky": { - "pre-commit": ["npx lint-staged\n", { mode: 0o777 }], + "pre-commit": ["npx lint-staged\n", { executable: true }], }, }; },