Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
dahlia marked this conversation as resolved.
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
Expand All @@ -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

Expand Down
19 changes: 19 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------------------------------------
Expand Down
97 changes: 97 additions & 0 deletions packages/init/src/action/configs.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -13,6 +19,7 @@ function createInitData(): InitCommandData {
kvStore: "denokv",
messageQueue: "denokv",
dryRun: false,
allowNonEmpty: false,
testMode: false,
dir: "/tmp/example",
initializer: {
Expand Down Expand Up @@ -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<string, string>;
};
const biomeConfig = JSON.parse(
await readFile(join(dir, "biome.json"), "utf8"),
) as Record<string, unknown>;

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<InitCommandData> {
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<string, unknown>): 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<string, unknown> {
return typeof value === "object" && value != null;
}
7 changes: 6 additions & 1 deletion packages/init/src/action/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -69,6 +73,7 @@ const handleHydRun = (data: InitCommandData) =>
pipe(
data,
tap(makeDirIfHyd),
tap(assertNoGeneratedFileConflicts),
tap(when(hasCommand, runPrecommand)),
tap(patchFiles),
tap(installDependencies),
Expand Down
103 changes: 103 additions & 0 deletions packages/init/src/action/patch.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), "fedify-init-patch-"));
try {
await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
63 changes: 62 additions & 1 deletion packages/init/src/action/patch.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void> {
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
Expand Down Expand Up @@ -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<string[]> => {
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);
};
Comment thread
dahlia marked this conversation as resolved.

const pathExists = async (path: string): Promise<boolean> => {
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.
Expand Down
3 changes: 2 additions & 1 deletion packages/init/src/ask/dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <T extends { dir?: string }>(
const fillDir: <T extends { allowNonEmpty: boolean; dir?: string }>(
options: T,
) => Promise<T & { dir: string }> = async (options) => {
const dir = options.dir ?? await askDir(getCwd());
if (options.allowNonEmpty) return { ...options, dir };
return await askIfNonEmpty(dir)
? { ...options, dir }
: await fillDir(options);
Expand Down
Loading