diff --git a/CHANGES.md b/CHANGES.md index 77b424aaf..7307dd7c9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -148,6 +148,25 @@ To be released. ### @fedify/init + - Added a `--allow-non-empty` option to `fedify init` for automated + scaffolding in directories that already contain unrelated files. The + command still fails before making changes if any file that Fedify would + generate already exists, avoiding accidental merges or appends. + [[#716], [#717]] + + - Fixed `fedify init` so that a directory containing only a freshly + initialized Git repository is treated as empty. Directories whose Git + `HEAD` already resolves to a commit, whose Git metadata contains loose or + packed refs, stored objects, an index, or reflogs, or that contain any + files besides *.git*, still require the existing non-empty-directory + confirmation. [[#716], [#717]] + + - Fixed generated *biome.json* files to use Biome 2 configuration syntax, + matching the `@biomejs/biome` version that `fedify init` installs. + Generated projects now enable import organization through Biome's + `assist.actions.source.organizeImports` setting instead of the removed + top-level `organizeImports` option. [[#716], [#717]] + - Fixed errors when using `fedify init` with certain web framework integration packages (Astro, ElysiaJS, Nitro) alongside `@fedify/mysql`. Environment variables are now properly loaded at runtime, resolving the @@ -161,6 +180,8 @@ To be released. [#649]: https://github.com/fedify-dev/fedify/issues/649 [#656]: https://github.com/fedify-dev/fedify/pull/656 [#675]: https://github.com/fedify-dev/fedify/pull/675 +[#716]: https://github.com/fedify-dev/fedify/issues/716 +[#717]: https://github.com/fedify-dev/fedify/pull/717 ### Docs diff --git a/docs/cli.md b/docs/cli.md index 907cdb10b..b8dd4dcff 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -339,6 +339,25 @@ When using `--dry-run`, the command will: This option works with all other initialization options, allowing you to preview different configurations before making a decision. +### `--allow-non-empty`: Initialize in a non-empty directory + +*This option is available since Fedify 2.2.0.* + +By default, `fedify init` asks for confirmation before using a directory that +already contains files. This prompt protects you from accidentally +initializing a project in the wrong directory. In non-interactive scripts or +CI jobs, use the `--allow-non-empty` option to allow a non-empty target +directory: + +~~~~ sh +fedify init . --allow-non-empty +~~~~ + +This option does not overwrite existing project files. Before making changes, +`fedify init` checks the files it would generate and fails if any of them +already exist. Unrelated files, such as *README.md* or a freshly initialized +*.git* directory, can remain in the target directory. + `fedify lookup`: Looking up an ActivityPub object ------------------------------------------------- diff --git a/packages/init/src/action/configs.test.ts b/packages/init/src/action/configs.test.ts index 51ba1cb46..c63d31730 100644 --- a/packages/init/src/action/configs.test.ts +++ b/packages/init/src/action/configs.test.ts @@ -1,8 +1,14 @@ import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { message } from "@optique/core"; +import { kvStores, messageQueues } from "../lib.ts"; import type { InitCommandData } from "../types.ts"; +import bareBonesDescription from "../webframeworks/bare-bones.ts"; import { loadDenoConfig } from "./configs.ts"; +import { patchFiles } from "./patch.ts"; function createInitData(): InitCommandData { const data = { @@ -13,6 +19,7 @@ function createInitData(): InitCommandData { kvStore: "denokv", messageQueue: "denokv", dryRun: false, + allowNonEmpty: false, testMode: false, dir: "/tmp/example", initializer: { @@ -94,3 +101,93 @@ test("loadDenoConfig keeps unstable.temporal before Deno 2.7.0", () => { restoreDeno(originalDeno); } }); + +test("patchFiles creates a Biome config matching the npm package version", async () => { + const dir = await mkdtemp(join(tmpdir(), "fedify-init-biome-")); + + try { + const data = await createNpmInitData(dir); + await patchFiles(data); + + const packageJson = JSON.parse( + await readFile(join(dir, "package.json"), "utf8"), + ) as { + devDependencies?: Record; + }; + const biomeConfig = JSON.parse( + await readFile(join(dir, "biome.json"), "utf8"), + ) as Record; + + const biomeVersion = packageJson.devDependencies?.["@biomejs/biome"]; + const schema = biomeConfig.$schema; + assert.ok(typeof biomeVersion === "string"); + assert.ok(typeof schema === "string"); + assert.equal(getSchemaVersion(schema), getPackageVersion(biomeVersion)); + assert.equal(getOrganizeImportsSetting(biomeConfig), "on"); + assert.equal( + "organizeImports" in biomeConfig, + false, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function createNpmInitData(dir: string): Promise { + const initializer = await bareBonesDescription.init({ + command: "init", + projectName: "example", + packageManager: "npm", + webFramework: "bare-bones", + kvStore: "in-memory", + messageQueue: "in-process", + dryRun: false, + allowNonEmpty: false, + testMode: false, + dir, + }); + + const data = { + command: "init", + projectName: "example", + packageManager: "npm", + webFramework: "bare-bones", + kvStore: "in-memory", + messageQueue: "in-process", + dryRun: false, + allowNonEmpty: false, + testMode: false, + dir, + initializer, + kv: kvStores["in-memory"], + mq: messageQueues["in-process"], + env: {}, + } satisfies InitCommandData; + return data; +} + +function getSchemaVersion(schema: string): string { + const match = schema.match(/\/schemas\/(\d+\.\d+\.\d+)\//); + assert.ok(match, `Unexpected Biome schema URL: ${schema}`); + return match[1]; +} + +function getPackageVersion(version: string): string { + const match = version.match(/\d+\.\d+\.\d+/); + assert.ok(match, `Unexpected Biome package version: ${version}`); + return match[0]; +} + +function getOrganizeImportsSetting(config: Record): unknown { + const assist = config.assist; + assert.ok(isRecord(assist)); + const actions = assist.actions; + assert.ok(isRecord(actions)); + const source = actions.source; + assert.ok(isRecord(source)); + return source.organizeImports; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null; +} diff --git a/packages/init/src/action/mod.ts b/packages/init/src/action/mod.ts index 0e6b44f70..79ce01550 100644 --- a/packages/init/src/action/mod.ts +++ b/packages/init/src/action/mod.ts @@ -12,7 +12,11 @@ import { noticeOptions, noticePrecommand, } from "./notice.ts"; -import { patchFiles, recommendPatchFiles } from "./patch.ts"; +import { + assertNoGeneratedFileConflicts, + patchFiles, + recommendPatchFiles, +} from "./patch.ts"; import recommendDependencies from "./recommend.ts"; import setData from "./set.ts"; import { @@ -69,6 +73,7 @@ const handleHydRun = (data: InitCommandData) => pipe( data, tap(makeDirIfHyd), + tap(assertNoGeneratedFileConflicts), tap(when(hasCommand, runPrecommand)), tap(patchFiles), tap(installDependencies), diff --git a/packages/init/src/action/patch.test.ts b/packages/init/src/action/patch.test.ts new file mode 100644 index 000000000..3a606f2bd --- /dev/null +++ b/packages/init/src/action/patch.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { message } from "@optique/core"; +import type { InitCommandData } from "../types.ts"; +import { + assertNoGeneratedFileConflicts, + GeneratedFileConflictError, +} from "./patch.ts"; + +test("assertNoGeneratedFileConflicts allows unrelated files", async () => { + await withTempDir(async (dir) => { + await writeFile(join(dir, "README.md"), "# Example\n"); + + await assert.doesNotReject(() => + assertNoGeneratedFileConflicts(createInitData(dir, true)) + ); + }); +}); + +test("assertNoGeneratedFileConflicts rejects existing generated files", async () => { + await withTempDir(async (dir) => { + await mkdir(join(dir, "src"), { recursive: true }); + await writeFile(join(dir, "package.json"), "{}\n"); + await writeFile(join(dir, "src", "main.ts"), ""); + + await assert.rejects( + () => assertNoGeneratedFileConflicts(createInitData(dir, true)), + (error) => { + assert.ok(error instanceof GeneratedFileConflictError); + assert.deepEqual(error.conflicts, ["src/main.ts", "package.json"]); + assert.match(error.message, /src\/main\.ts/); + assert.match(error.message, /package\.json/); + return true; + }, + ); + }); +}); + +test("assertNoGeneratedFileConflicts skips checks without allowNonEmpty", async () => { + await withTempDir(async (dir) => { + await writeFile(join(dir, "package.json"), "{}\n"); + + await assert.doesNotReject(() => + assertNoGeneratedFileConflicts(createInitData(dir, false)) + ); + }); +}); + +function createInitData( + dir: string, + allowNonEmpty: boolean, +): InitCommandData { + const data = { + command: "init", + projectName: "example", + packageManager: "npm", + webFramework: "bare-bones", + kvStore: "in-memory", + messageQueue: "in-process", + dryRun: false, + allowNonEmpty, + testMode: false, + dir, + initializer: { + federationFile: "src/federation.ts", + loggingFile: "src/logging.ts", + instruction: message`done`, + tasks: {}, + compilerOptions: {}, + files: { + "src/main.ts": "", + }, + }, + kv: { + label: "In-Memory", + packageManagers: ["npm"], + imports: {}, + object: "new MemoryKvStore()", + }, + mq: { + label: "In-Process", + packageManagers: ["npm"], + imports: {}, + object: "new InProcessMessageQueue()", + }, + env: {}, + } satisfies InitCommandData; + return data; +} + +async function withTempDir( + fn: (dir: string) => Promise, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "fedify-init-patch-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/packages/init/src/action/patch.ts b/packages/init/src/action/patch.ts index 764722b50..377a184bf 100644 --- a/packages/init/src/action/patch.ts +++ b/packages/init/src/action/patch.ts @@ -1,6 +1,7 @@ import { always, apply, entries, map, pipe, pipeLazy, tap } from "@fxts/core"; import { toMerged } from "es-toolkit"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; +import { join as joinPath } from "node:path"; import { createFile, throwUnlessNotExists } from "../lib.ts"; import type { InitCommandData } from "../types.ts"; import { formatJson, merge, replaceAll, set } from "../utils.ts"; @@ -43,6 +44,27 @@ export const recommendPatchFiles = (data: InitCommandData) => recommendFiles, ); +/** + * Verifies that `--allow-non-empty` will not modify files that already + * existed before any framework scaffolding command runs. + */ +export async function assertNoGeneratedFileConflicts( + data: InitCommandData, +): Promise { + if (!data.allowNonEmpty) return; + const conflicts = await getExistingGeneratedFiles(data); + if (conflicts.length > 0) { + throw new GeneratedFileConflictError(conflicts); + } +} + +export class GeneratedFileConflictError extends Error { + constructor(public readonly conflicts: readonly string[]) { + super(formatConflictMessage(conflicts)); + this.name = "GeneratedFileConflictError"; + } +} + /** * Generates text-based files (TypeScript, environment files) for the project. * Creates federation configuration, logging setup, environment variables, and @@ -92,6 +114,45 @@ const getJsons = < [devToolConfigs["vscExt"].path]: devToolConfigs["vscExt"].data, }; +const getGeneratedFilePaths = (data: InitCommandData): string[] => [ + data.initializer.federationFile, + data.initializer.loggingFile, + ".env", + ...Object.keys(data.initializer.files ?? {}), + ...Object.keys(getJsons(data)), +]; + +const getExistingGeneratedFiles = async ( + data: InitCommandData, +): Promise => { + const paths = [...new Set(getGeneratedFilePaths(data))]; + const results = await Promise.all( + paths.map(async (path) => { + const exists = await pathExists(joinPath(data.dir, path)); + return exists ? path : null; + }), + ); + return results.filter((path): path is string => path != null); +}; + +const pathExists = async (path: string): Promise => { + try { + await access(path); + return true; + } catch (e) { + throwUnlessNotExists(e); + return false; + } +}; + +const formatConflictMessage = (conflicts: readonly string[]): string => + [ + "Cannot initialize in a non-empty directory because these generated files", + "already exist:", + ...conflicts.map((path) => ` - ${path}`), + "Remove the conflicting files or choose another directory.", + ].join("\n"); + /** * Handles dry-run mode by recommending files to be created without actually * creating them. diff --git a/packages/init/src/ask/dir.ts b/packages/init/src/ask/dir.ts index d09e10136..bd8b81fac 100644 --- a/packages/init/src/ask/dir.ts +++ b/packages/init/src/ask/dir.ts @@ -16,10 +16,11 @@ import { getCwd, getOsType } from "../utils.ts"; * @param options - Initialization options possibly containing a directory * @returns A promise resolving to options with a guaranteed directory */ -const fillDir: ( +const fillDir: ( options: T, ) => Promise = async (options) => { const dir = options.dir ?? await askDir(getCwd()); + if (options.allowNonEmpty) return { ...options, dir }; return await askIfNonEmpty(dir) ? { ...options, dir } : await fillDir(options); diff --git a/packages/init/src/command.ts b/packages/init/src/command.ts index 2d8c9d700..6c21c973b 100644 --- a/packages/init/src/command.ts +++ b/packages/init/src/command.ts @@ -59,7 +59,7 @@ const messageQueue = optional(option( /** * The `@optique/core` option schema for the `fedify init` command. * Defines `dir`, `webFramework`, `packageManager`, `kvStore`, `messageQueue`, - * and `dryRun` options that the CLI parser will accept. + * `dryRun`, and `allowNonEmpty` options that the CLI parser will accept. */ export const initOptions = object("Initialization options", { dir: optional(argument(path({ metavar: "DIR" }), { @@ -73,6 +73,10 @@ export const initOptions = object("Initialization options", { dryRun: option("--dry-run", { description: message`Perform a trial run with no changes made.`, }), + allowNonEmpty: option("--allow-non-empty", { + description: + message`Allow initializing in a non-empty directory, failing if any generated file already exists.`, + }), }); /** diff --git a/packages/init/src/json/biome.json b/packages/init/src/json/biome.json index 902345891..2753ca99f 100644 --- a/packages/init/src/json/biome.json +++ b/packages/init/src/json/biome.json @@ -1,7 +1,12 @@ { - "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", - "organizeImports": { - "enabled": true + "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } }, "formatter": { "enabled": true, diff --git a/packages/init/src/lib.test.ts b/packages/init/src/lib.test.ts new file mode 100644 index 000000000..4c1f40673 --- /dev/null +++ b/packages/init/src/lib.test.ts @@ -0,0 +1,180 @@ +import { strictEqual } from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { isDirectoryEmpty } from "./lib.ts"; +import { runSubCommand } from "./utils.ts"; + +test("isDirectoryEmpty allows an unborn Git repository", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + + strictEqual(await isDirectoryEmpty(dir), true); + }); +}); + +test("isDirectoryEmpty allows a freshly initialized Git repository", async (t) => { + if (!await isGitAvailable()) { + t.skip("git is not installed"); + return; + } + + await withTempDir(async (dir) => { + await runGit(dir, ["init"]); + + strictEqual(await isDirectoryEmpty(dir), true); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with a branch ref", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile( + join(dir, ".git", "refs", "heads", "main"), + "0000000000000000000000000000000000000000\n", + ); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with another ref", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await mkdir(join(dir, ".git", "refs", "remotes", "origin"), { + recursive: true, + }); + await writeFile( + join(dir, ".git", "refs", "remotes", "origin", "main"), + "0000000000000000000000000000000000000000\n", + ); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with a HEAD commit", async (t) => { + if (!await isGitAvailable()) { + t.skip("git is not installed"); + return; + } + + await withTempDir(async (dir) => { + await runGit(dir, ["init"]); + await runGit(dir, [ + "-c", + "user.name=Fedify Test", + "-c", + "user.email=fedify@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "Initial commit", + ]); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with a packed ref", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile( + join(dir, ".git", "packed-refs"), + "0000000000000000000000000000000000000000 refs/tags/v1.0.0\n", + ); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with stored objects", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await mkdir(join(dir, ".git", "objects", "01"), { recursive: true }); + await writeFile(join(dir, ".git", "objects", "01", "2345"), "object"); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with an index", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile(join(dir, ".git", "index"), ""); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with reflogs", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await mkdir(join(dir, ".git", "logs"), { recursive: true }); + await writeFile(join(dir, ".git", "logs", "HEAD"), ""); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a detached Git HEAD", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile( + join(dir, ".git", "HEAD"), + "0000000000000000000000000000000000000000\n", + ); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects additional files beside .git", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile(join(dir, "package.json"), "{}\n"); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a .git file", async () => { + await withTempDir(async (dir) => { + await writeFile(join(dir, ".git"), "gitdir: ../.git/worktrees/example\n"); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +async function createUnbornGitRepository(dir: string): Promise { + await mkdir(join(dir, ".git", "objects"), { recursive: true }); + await mkdir(join(dir, ".git", "refs", "heads"), { recursive: true }); + await writeFile(join(dir, ".git", "HEAD"), "ref: refs/heads/main\n"); +} + +async function isGitAvailable(): Promise { + try { + await runSubCommand(["git", "--version"], {}); + return true; + } catch { + return false; + } +} + +async function runGit(dir: string, args: string[]): Promise { + await runSubCommand(["git", "-C", dir, ...args], {}); +} + +async function withTempDir( + fn: (dir: string) => Promise, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "fedify-init-dir-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index 8133093a1..cd7a08f59 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -11,8 +11,8 @@ import { } from "@fxts/core"; import { getLogger } from "@logtape/logtape"; import { toMerged } from "es-toolkit"; -import { readFileSync } from "node:fs"; -import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { type Dirent, readFileSync } from "node:fs"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { dirname, join as joinPath } from "node:path"; import process from "node:process"; import metadata from "../deno.json" with { type: "json" }; @@ -27,7 +27,7 @@ import type { PackageManagers, Runtimes, } from "./types.ts"; -import { isNotFoundError } from "./utils.ts"; +import { CommandError, isNotFoundError, runSubCommand } from "./utils.ts"; /** The current `@fedify/init` package version, read from *deno.json*. */ export const PACKAGE_VERSION = metadata.version; @@ -191,21 +191,198 @@ const isNotExistsError = (e: unknown) => export const throwUnlessNotExists = throwIf(negate(isNotExistsError)); /** - * Checks whether a directory is empty or does not exist. - * Returns `true` if the directory has no entries or does not exist yet. + * Checks whether a directory is safe to initialize as an empty project. + * Returns `true` if the directory does not exist, has no entries, or only + * contains an unborn Git repository created by `git init`. */ export const isDirectoryEmpty = async ( path: string, ): Promise => { try { const files = await readdir(path); - return files.length === 0; + if (files.length === 0) return true; + if (files.length === 1 && files[0] === ".git") { + return await isUnbornGitRepository(path); + } + return false; } catch (e) { throwUnlessNotExists(e); return true; } }; +const isUnbornGitRepository = async (path: string): Promise => { + if (await hasGitHeadCommit(path)) return false; + return await looksLikeUnbornGitRepository(path); +}; + +const hasGitHeadCommit = async (path: string): Promise => { + try { + await runSubCommand([ + "git", + "-C", + path, + "rev-parse", + "--verify", + "HEAD^{commit}", + ], {}); + return true; + } catch (e) { + if (isNotFoundError(e) || e instanceof CommandError) return false; + logger.debug( + "Failed to resolve Git HEAD in {path}: {error}", + { path, error: e }, + ); + return false; + } +}; + +const looksLikeUnbornGitRepository = async ( + path: string, +): Promise => { + const gitDir = joinPath(path, ".git"); + if (!await isDirectory(gitDir)) return false; + if (!await isDirectory(joinPath(gitDir, "objects"))) return false; + if (!await isDirectory(joinPath(gitDir, "refs"))) return false; + + const head = await readGitFile(joinPath(gitDir, "HEAD")); + if (head == null) return false; + if (!isValidHeadRef(head)) return false; + if (await hasAnyLooseRef(gitDir)) return false; + if (await hasAnyPackedRef(gitDir)) return false; + if (await hasAnyObjectFile(gitDir)) return false; + if (await hasAnyGitStatePath(gitDir)) return false; + return true; +}; + +const isValidHeadRef = (head: string): boolean => { + const match = head.trim().match(/^ref: (refs\/heads\/\S+)$/); + if (match == null) return false; + return !match[1].includes(".."); +}; + +const hasAnyLooseRef = async (gitDir: string): Promise => + await hasAnyFile(joinPath(gitDir, "refs"), "Git refs"); + +const hasAnyObjectFile = async (gitDir: string): Promise => + await hasAnyFile(joinPath(gitDir, "objects"), "Git objects"); + +const hasAnyFile = async ( + dir: string, + description: string, +): Promise => { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (e) { + if (isNotFoundError(e)) return false; + logger.debug( + "Failed to read {description} in {path}: {error}", + { description, path: dir, error: e }, + ); + return true; + } + + for (const entry of entries) { + const path = joinPath(dir, entry.name); + if (entry.isDirectory()) { + if (await hasAnyFile(path, description)) return true; + } else { + return true; + } + } + return false; +}; + +const GIT_STATE_PATHS = [ + "AUTO_MERGE", + "BISECT_LOG", + "CHERRY_PICK_HEAD", + "FETCH_HEAD", + "MERGE_HEAD", + "MERGE_MODE", + "MERGE_MSG", + "ORIG_HEAD", + "REBASE_HEAD", + "REVERT_HEAD", + "SQUASH_MSG", + "index", + "logs", + "modules", + "rebase-apply", + "rebase-merge", + "sequencer", + "shallow", + "worktrees", +] as const; + +const hasAnyGitStatePath = async (gitDir: string): Promise => { + for (const path of GIT_STATE_PATHS) { + if (await pathExists(joinPath(gitDir, path))) return true; + } + return false; +}; + +const pathExists = async (path: string): Promise => { + try { + await stat(path); + return true; + } catch (e) { + if (isNotFoundError(e)) return false; + logger.debug( + "Failed to stat Git state path {path}: {error}", + { path, error: e }, + ); + return true; + } +}; + +const hasAnyPackedRef = async ( + gitDir: string, +): Promise => { + let packedRefs: string; + try { + packedRefs = await readFile(joinPath(gitDir, "packed-refs"), "utf8"); + } catch (e) { + if (isNotFoundError(e)) return false; + logger.debug( + "Failed to read Git packed refs in {path}: {error}", + { path: gitDir, error: e }, + ); + return true; + } + + return packedRefs.split(/\r?\n/).some((line) => { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("^")) { + return false; + } + return true; + }); +}; + +const readGitFile = async (path: string): Promise => { + try { + return await readFile(path, "utf8"); + } catch (e) { + if (!isNotFoundError(e)) { + logger.debug( + "Failed to read Git file {path}: {error}", + { path, error: e }, + ); + } + return null; + } +}; + +const isDirectory = async (path: string): Promise => { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +}; + /** Returns `true` if the current run is in test mode. */ export const isTest: < T extends { testMode: boolean }, diff --git a/packages/init/src/package.test.ts b/packages/init/src/package.test.ts index cd178f4a9..6e545b7fe 100644 --- a/packages/init/src/package.test.ts +++ b/packages/init/src/package.test.ts @@ -45,6 +45,7 @@ test( command: "init", dir: packageDir, dryRun: true, + allowNonEmpty: false, kvStore: "in-memory", messageQueue: "in-process", packageManager: "bun", diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index a5918cd60..41aa113dd 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -46,7 +46,6 @@ }, "files": [ "dist/", - "src/runtime/", "package.json" ], "peerDependencies": { @@ -67,6 +66,7 @@ "build": "pnpm --filter @fedify/nuxt... run build:self", "prepack": "pnpm build", "prepublish": "pnpm build", + "pretest": "pnpm build", "test": "node --experimental-transform-types --test" } } diff --git a/packages/nuxt/src/module.test.ts b/packages/nuxt/src/module.test.ts index 77d024c3c..31440435b 100644 --- a/packages/nuxt/src/module.test.ts +++ b/packages/nuxt/src/module.test.ts @@ -1,7 +1,11 @@ import { test } from "@fedify/fixture"; -import { equal, ok, throws } from "node:assert/strict"; +import { deepEqual, equal, ok, throws } from "node:assert/strict"; import { isAbsolute } from "node:path"; -import { buildContextFactoryResolver, resolveModulePath } from "./module.ts"; +import { + buildContextFactoryResolver, + resolveModulePath, + resolveRuntimeServerPath, +} from "./module.ts"; test( "relative module path must resolve to absolute path", @@ -47,6 +51,32 @@ test( }, ); +test( + "runtime server files must resolve to compiled JavaScript output", + () => { + const requestedPaths: string[] = []; + const resolver = { + resolve(path: string): string { + requestedPaths.push(path); + return `/package/${path}`; + }, + }; + + equal( + resolveRuntimeServerPath(resolver, "middleware.js"), + "/package/../dist/runtime/server/middleware.js", + ); + equal( + resolveRuntimeServerPath(resolver, "plugin.js"), + "/package/../dist/runtime/server/plugin.js", + ); + deepEqual(requestedPaths, [ + "../dist/runtime/server/middleware.js", + "../dist/runtime/server/plugin.js", + ]); + }, +); + test( "missing exports must throw, not silently return undefined", () => { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 958ba79c1..956a94d4e 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -54,6 +54,17 @@ export function resolveModulePath( return resolved; } +interface RuntimeResolver { + resolve(path: string): string; +} + +export function resolveRuntimeServerPath( + resolver: RuntimeResolver, + fileName: "middleware.js" | "plugin.js", +): string { + return resolver.resolve(`../dist/runtime/server/${fileName}`); +} + export function buildContextFactoryResolver( contextDataFactoryModule: string | null, ): string { @@ -106,6 +117,11 @@ const fedifyNuxtModule: NuxtModule = ); const middlewareFilename = "fedify-nuxt-options.mjs"; + const middlewareModule = resolveRuntimeServerPath( + resolver, + "middleware.js", + ); + const pluginModule = resolveRuntimeServerPath(resolver, "plugin.js"); addServerTemplate({ filename: middlewareFilename, @@ -115,9 +131,7 @@ const fedifyNuxtModule: NuxtModule = JSON.stringify(federationModule) };`, `import { createFedifyMiddleware } from ${ - JSON.stringify( - resolver.resolve("../src/runtime/server/middleware.ts"), - ) + JSON.stringify(middlewareModule) };`, ]; @@ -145,7 +159,7 @@ const fedifyNuxtModule: NuxtModule = handler: middlewareFilename, }); - addServerPlugin(resolver.resolve("../src/runtime/server/plugin.ts")); + addServerPlugin(pluginModule); }, }); diff --git a/packages/nuxt/src/package.test.ts b/packages/nuxt/src/package.test.ts new file mode 100644 index 000000000..1775dc4af --- /dev/null +++ b/packages/nuxt/src/package.test.ts @@ -0,0 +1,81 @@ +import { test } from "@fedify/fixture"; +import fedifyNuxtModule from "@fedify/nuxt"; +import { runWithNuxtContext } from "@nuxt/kit"; +import type { Nuxt } from "@nuxt/schema"; +import { equal, match, ok } from "node:assert/strict"; + +interface TestNuxt { + options: { + rootDir: string; + alias: Record; + _requiredModules?: Record; + experimental: Record; + serverHandlers: Array<{ + route: string; + middleware: boolean; + handler: string; + method?: string; + }>; + devServerHandlers: unknown[]; + nitro: { + virtual?: Record string>; + plugins?: string[]; + }; + }; + hooks: { + addHooks(): void; + callHook(): void; + }; + hook(): void; + callHook(): void; +} + +function createNuxtFixture(): TestNuxt { + return { + options: { + rootDir: "/app", + alias: { "~": "/app", "@": "/app" }, + experimental: {}, + serverHandlers: [], + devServerHandlers: [], + nitro: {}, + }, + hooks: { + addHooks: () => undefined, + callHook: () => undefined, + }, + hook: () => undefined, + callHook: () => undefined, + }; +} + +test("package import registers built runtime files", async () => { + const nuxt = createNuxtFixture(); + const nuxtContext = nuxt as unknown as Nuxt; + + await runWithNuxtContext( + nuxtContext, + () => + fedifyNuxtModule({ federationModule: "#server/federation" }, nuxtContext), + ); + + equal(nuxt.options.serverHandlers.length, 1); + equal(nuxt.options.serverHandlers[0].handler, "fedify-nuxt-options.mjs"); + + const getContents = nuxt.options.nitro.virtual?.["fedify-nuxt-options.mjs"]; + if (getContents == null) { + throw new TypeError("Expected fedify-nuxt-options.mjs to be registered."); + } + + const contents = getContents(); + match( + contents, + /import \{ createFedifyMiddleware \} from ".+\/dist\/runtime\/server\/middleware\.js";/, + ); + ok(!contents.includes("src/runtime")); + ok(!contents.includes("middleware.ts")); + + const [plugin] = nuxt.options.nitro.plugins ?? []; + ok(plugin != null); + match(plugin, /\/dist\/runtime\/server\/plugin\.js$/); +}); diff --git a/packages/nuxt/tsdown.config.ts b/packages/nuxt/tsdown.config.ts index bf33f512d..cbe9fa72b 100644 --- a/packages/nuxt/tsdown.config.ts +++ b/packages/nuxt/tsdown.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/mod.ts"], + entry: [ + "src/mod.ts", + "src/runtime/server/middleware.ts", + "src/runtime/server/plugin.ts", + ], dts: true, format: ["esm", "cjs"], platform: "node",