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/deno.lock b/deno.lock index bc76fc024..a7201c1ee 100644 --- a/deno.lock +++ b/deno.lock @@ -81,7 +81,6 @@ "npm:@nurodev/astro-bun@^2.1.2": "2.1.2_astro@5.18.1__@types+node@24.12.0__ioredis@5.10.1__tsx@4.21.0__typescript@6.0.2__yaml@2.8.3", "npm:@nuxt/kit@4": "4.4.2", "npm:@nuxt/schema@4": "4.4.2", - "npm:@nuxt/schema@^4.4.0": "4.4.2", "npm:@opentelemetry/api@^1.9.0": "1.9.1", "npm:@opentelemetry/context-async-hooks@^2.5.0": "2.6.1_@opentelemetry+api@1.9.1", "npm:@opentelemetry/core@^2.5.0": "2.6.1_@opentelemetry+api@1.9.1", @@ -9503,12 +9502,7 @@ "npm:@nuxt/kit@4", "npm:@nuxt/schema@4", "npm:h3@^1.15.0" - ], - "packageJson": { - "dependencies": [ - "npm:@nuxt/schema@^4.4.0" - ] - } + ] }, "packages/relay": { "dependencies": [ diff --git a/docs/cli.md b/docs/cli.md index 907cdb10b..67ee8d0c0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -339,6 +339,28 @@ 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 can remain in the target directory only when +the selected framework scaffolder accepts them. Some scaffolders, such as +*create-next-app*, still reject unrelated files even if `fedify init` skips its +own confirmation prompt, while a freshly initialized *.git* directory remains +acceptable. + `fedify lookup`: Looking up an ActivityPub object ------------------------------------------------- diff --git a/packages/fedify/src/federation/mq.test.ts b/packages/fedify/src/federation/mq.test.ts index 0247b8a61..8c7adc3de 100644 --- a/packages/fedify/src/federation/mq.test.ts +++ b/packages/fedify/src/federation/mq.test.ts @@ -13,6 +13,20 @@ import { ParallelMessageQueue, } from "./mq.ts"; +async function disposeMessageQueue(mq: object): Promise { + if (Symbol.asyncDispose in mq) { + const dispose = mq[Symbol.asyncDispose]; + if (typeof dispose === "function") { + await dispose.call(mq); + return; + } + } + if (Symbol.dispose in mq) { + const dispose = mq[Symbol.dispose]; + if (typeof dispose === "function") dispose.call(mq); + } +} + test("InProcessMessageQueue", async (t) => { const mq = new InProcessMessageQueue(); @@ -189,10 +203,7 @@ test("MessageQueue.nativeRetrial", async (t) => { await globalThis.Deno.openKv(":memory:"), ); assert(mq.nativeRetrial); - if (Symbol.dispose in mq) { - const dispose = mq[Symbol.dispose]; - if (typeof dispose === "function") dispose.call(mq); - } + await disposeMessageQueue(mq); }); } @@ -321,10 +332,7 @@ for (const mqName in queues) { controller.abort(); await listening; - if (Symbol.dispose in mq) { - const dispose = mq[Symbol.dispose]; - if (typeof dispose === "function") dispose.call(mq); - } + await disposeMessageQueue(mq); }, }); } 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..77bd6c2da --- /dev/null +++ b/packages/init/src/action/patch.test.ts @@ -0,0 +1,115 @@ +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, + getJsonsCacheKey, +} 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)) + ); + }); +}); + +test("getJsonsCacheKey stays stable across pipeline clones", () => { + const data = createInitData("/tmp/example", true); + const cloned = { + ...data, + files: { "src/main.ts": "" }, + jsons: { "package.json": {} }, + }; + + assert.equal(getJsonsCacheKey(cloned), getJsonsCacheKey(data)); +}); + +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..036242462 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"; @@ -18,6 +19,45 @@ import { import { getImports, loadFederation, loadLogging } from "./templates.ts"; import { joinDir, stringifyEnvs } from "./utils.ts"; +const jsonsCache = new Map>(); + +type JsonConfigData = Pick< + InitCommandData, + | "dir" + | "dryRun" + | "env" + | "initializer" + | "kv" + | "mq" + | "packageManager" + | "testMode" +>; + +export const getJsonsCacheKey = (data: JsonConfigData): string => + JSON.stringify({ + dir: data.dir, + packageManager: data.packageManager, + dryRun: data.dryRun, + testMode: data.testMode, + env: data.env, + initializer: { + compilerOptions: data.initializer.compilerOptions ?? {}, + dependencies: data.initializer.dependencies ?? {}, + devDependencies: data.initializer.devDependencies ?? {}, + tasks: data.initializer.tasks ?? {}, + }, + kv: { + dependencies: data.kv.dependencies ?? {}, + denoUnstable: data.kv.denoUnstable ?? [], + devDependencies: data.kv.devDependencies ?? {}, + }, + mq: { + dependencies: data.mq.dependencies ?? {}, + denoUnstable: data.mq.denoUnstable ?? [], + devDependencies: data.mq.devDependencies ?? {}, + }, + }); + /** * Main function that initializes the project by creating necessary files and configurations. * Handles both dry-run mode (recommending files) and actual file creation. @@ -43,6 +83,29 @@ export const recommendPatchFiles = (data: InitCommandData) => recommendFiles, ); +/** + * Verifies that `--allow-non-empty` will not modify files that already + * existed before any framework scaffolding command runs. This only covers + * files that Fedify writes itself; framework scaffolders may still reject + * unrelated pre-existing files independently. + */ +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 @@ -75,8 +138,12 @@ const getFiles = async < */ const getJsons = < T extends InitCommandData, ->(data: T): Record => - data.packageManager === "deno" +>(data: T): Record => { + const cacheKey = getJsonsCacheKey(data); + const cached = jsonsCache.get(cacheKey); + if (cached != null) return cached; + + const jsons: Record = data.packageManager === "deno" ? { "deno.json": loadDenoConfig(data).data, [devToolConfigs["vscSetDeno"].path]: devToolConfigs["vscSetDeno"].data, @@ -91,6 +158,53 @@ const getJsons = < [devToolConfigs["vscSet"].path]: devToolConfigs["vscSet"].data, [devToolConfigs["vscExt"].path]: devToolConfigs["vscExt"].data, }; + jsonsCache.set(cacheKey, jsons); + return jsons; +}; + +/** + * Returns only the file paths written directly by Fedify after any framework + * scaffolding command finishes. Files created by + * `WebFrameworkInitializer.command` are intentionally excluded. + */ +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 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..e2b610648 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 when the selected framework scaffolder supports it, 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..b8eb1d0e5 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -46,19 +46,18 @@ }, "files": [ "dist/", - "src/runtime/", "package.json" ], "peerDependencies": { "@fedify/fedify": "workspace:^", - "@nuxt/kit": "^4.4.0", + "@nuxt/kit": "catalog:", + "@nuxt/schema": "catalog:", "h3": "catalog:", "nuxt": "catalog:" }, "devDependencies": { "@fedify/fixture": "workspace:^", "@types/node": "catalog:", - "@nuxt/schema": "^4.4.0", "tsdown": "catalog:", "typescript": "catalog:" }, @@ -67,6 +66,7 @@ "build": "pnpm --filter @fedify/nuxt... run build:self", "prepack": "pnpm build", "prepublish": "pnpm build", + "pretest": "pnpm build:self", "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", diff --git a/packages/postgres/src/mq.ts b/packages/postgres/src/mq.ts index d187705c4..4fed2db8d 100644 --- a/packages/postgres/src/mq.ts +++ b/packages/postgres/src/mq.ts @@ -50,6 +50,12 @@ function isInitializationRaceError(error: unknown): boolean { ); } +function isConnectionDestroyedError(error: unknown): boolean { + if (typeof error !== "object" || error == null) return false; + return ("code" in error && error.code === "CONNECTION_DESTROYED") || + ("errno" in error && error.errno === "CONNECTION_DESTROYED"); +} + function getCreatedIndexName(tableName: string): string { // Keep identifier short and deterministic to avoid PostgreSQL's 63-byte // identifier truncation collisions for long/UUID-based table names. @@ -401,28 +407,36 @@ export class PostgresMessageQueue implements MessageQueue { }, () => safeSerializedPoll("subscribe"), ); - signal?.addEventListener("abort", () => { - listen.unlisten(); + const clearTimeouts = () => { for (const timeout of timeouts) clearTimeout(timeout); timeouts.clear(); - }); - while (!signal?.aborted) { - let timeout: ReturnType | undefined; - await new Promise((resolve) => { - signal?.addEventListener("abort", resolve); - timeout = setTimeout(() => { - signal?.removeEventListener("abort", resolve); - resolve(0); - }, this.#pollIntervalMs); - timeouts.add(timeout); - }); - if (timeout != null) timeouts.delete(timeout); - await safeSerializedPoll("interval"); + }; + signal?.addEventListener("abort", clearTimeouts, { once: true }); + let unlistenError: unknown; + try { + while (!signal?.aborted) { + let timeout: ReturnType | undefined; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + timeout = setTimeout(() => { + signal?.removeEventListener("abort", resolve); + resolve(0); + }, this.#pollIntervalMs); + timeouts.add(timeout); + }); + if (timeout != null) timeouts.delete(timeout); + await safeSerializedPoll("interval"); + } + } finally { + signal?.removeEventListener("abort", clearTimeouts); + clearTimeouts(); + try { + await listen.unlisten(); + } catch (error) { + if (!isConnectionDestroyedError(error)) unlistenError = error; + } } - await new Promise((resolve) => { - signal?.addEventListener("abort", () => resolve()); - if (signal?.aborted) return resolve(); - }); + if (unlistenError != null) throw unlistenError; } /** diff --git a/packages/redis/src/mq.race.test.ts b/packages/redis/src/mq.race.test.ts index 6de5121cf..ac3e96ad4 100644 --- a/packages/redis/src/mq.race.test.ts +++ b/packages/redis/src/mq.race.test.ts @@ -5,6 +5,20 @@ import { Buffer } from "node:buffer"; import { EventEmitter } from "node:events"; import { RedisMessageQueue } from "@fedify/redis/mq"; +async function disposeMessageQueue(mq: object): Promise { + if (Symbol.asyncDispose in mq) { + const dispose = mq[Symbol.asyncDispose]; + if (typeof dispose === "function") { + await dispose.call(mq); + return; + } + } + if (Symbol.dispose in mq) { + const dispose = mq[Symbol.dispose]; + if (typeof dispose === "function") dispose.call(mq); + } +} + /** * Mock Redis client that allows manual control of subscribe callback timing. * @@ -277,6 +291,6 @@ test("Regression: RedisMessageQueue handler attached before yield", async () => controller.abort(); await listening; } finally { - mq[Symbol.dispose](); + await disposeMessageQueue(mq); } }); diff --git a/packages/redis/src/mq.test.ts b/packages/redis/src/mq.test.ts index 4c76bb93b..fad3a7dc3 100644 --- a/packages/redis/src/mq.test.ts +++ b/packages/redis/src/mq.test.ts @@ -7,6 +7,20 @@ import { Redis } from "ioredis"; const dbUrl = process.env.REDIS_URL; +async function disposeMessageQueue(mq: object): Promise { + if (Symbol.asyncDispose in mq) { + const dispose = mq[Symbol.asyncDispose]; + if (typeof dispose === "function") { + await dispose.call(mq); + return; + } + } + if (Symbol.dispose in mq) { + const dispose = mq[Symbol.dispose]; + if (typeof dispose === "function") dispose.call(mq); + } +} + test("RedisMessageQueue", { ignore: dbUrl == null }, () => { const channelKey = getRandomKey("channel"); const queueKey = getRandomKey("queue"); @@ -19,10 +33,10 @@ test("RedisMessageQueue", { ignore: dbUrl == null }, () => { queueKey, lockKey, }), - ({ mq1, mq2, controller }) => { + async ({ mq1, mq2, controller }) => { controller.abort(); - mq1[Symbol.dispose](); - mq2[Symbol.dispose](); + await disposeMessageQueue(mq1); + await disposeMessageQueue(mq2); }, { testOrderingKey: true }, ); diff --git a/packages/redis/src/mq.ts b/packages/redis/src/mq.ts index f7e867c86..32e4e352a 100644 --- a/packages/redis/src/mq.ts +++ b/packages/redis/src/mq.ts @@ -10,6 +10,31 @@ import { type Codec, JsonCodec } from "./codec.ts"; const logger = getLogger(["fedify", "redis", "mq"]); +function isRedisClosedError(error: unknown): boolean { + return error instanceof Error && + /connection is (already )?closed/i.test(error.message); +} + +async function quitRedisGracefully(redis: Redis | Cluster): Promise { + const client = redis as Redis & { + readonly status?: string; + quit?: () => Promise; + }; + if (client.status === "wait" || client.status === "end") { + client.disconnect(); + return; + } + if (typeof client.quit !== "function") { + client.disconnect(); + return; + } + try { + await client.quit(); + } catch (error) { + if (!isRedisClosedError(error)) throw error; + } +} + /** * Options for {@link RedisMessageQueue} class. */ @@ -331,7 +356,17 @@ export class RedisMessageQueue implements MessageQueue, Disposable { [Symbol.dispose](): void { clearInterval(this.#loopHandle); + this.#loopHandle = undefined; this.#redis.disconnect(); this.#subRedis.disconnect(); } + + async [Symbol.asyncDispose](): Promise { + clearInterval(this.#loopHandle); + this.#loopHandle = undefined; + await Promise.all([ + quitRedisGracefully(this.#redis), + quitRedisGracefully(this.#subRedis), + ]); + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 645641ba7..094690b0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1405,8 +1405,11 @@ importers: specifier: workspace:^ version: link:../fedify '@nuxt/kit': - specifier: ^4.4.0 + specifier: 'catalog:' version: 4.4.2(magicast@0.5.2) + '@nuxt/schema': + specifier: 'catalog:' + version: 4.4.2 h3: specifier: 'catalog:' version: 1.15.3 @@ -1417,9 +1420,6 @@ importers: '@fedify/fixture': specifier: workspace:^ version: link:../fixture - '@nuxt/schema': - specifier: ^4.4.0 - version: 4.4.2 '@types/node': specifier: 'catalog:' version: 22.19.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a6a19173d..de1bad4f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -70,6 +70,8 @@ catalog: "@types/koa": ^2.15.0 "@types/node": ^22.17.0 "@nestjs/common": ^11.0.1 + "@nuxt/kit": ^4.4.2 + "@nuxt/schema": ^4.4.2 astro: ^5.0.0 amqplib: ^0.10.9 asn1js: ^3.0.6