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
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ run = "deno run -A examples/test-examples/mod.ts"
[tasks."test:init"]
description = "Run tests for the init package"
run = "deno task -f @fedify/init test-init"
depends = ["prepare"]

# Snapshot updates
# Note: vocab uses @std/testing/snapshot which only works on Deno
Expand Down
8 changes: 5 additions & 3 deletions packages/init/src/action/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { PACKAGE_VERSION } from "../lib.ts";
import type { InitCommandData, PackageManager } from "../types.ts";
import { merge, replace } from "../utils.ts";
import { getPackagesPath } from "./const.ts";
import { isDeno } from "./utils.ts";
import { isDeno, needsDenoDotenv } from "./utils.ts";

type Deps = Record<string, string>;

Expand All @@ -26,16 +26,18 @@ type Deps = Record<string, string>;
* @returns A record of dependencies with their versions
*/
export const getDependencies = (
{ initializer, kv, mq, testMode, packageManager }: Pick<
{ initializer, kv, mq, env, testMode, packageManager }: Pick<
InitCommandData,
"initializer" | "kv" | "mq" | "packageManager" | "testMode"
"initializer" | "kv" | "mq" | "env" | "packageManager" | "testMode"
>,
): Deps =>
pipe(
{
"@fedify/fedify": PACKAGE_VERSION,
"@fedify/vocab": PACKAGE_VERSION,
"@logtape/logtape": deps["@logtape/logtape"],
...(needsDenoDotenv({ packageManager, env }) &&
{ "@std/dotenv": deps["@std/dotenv"] }),
},
merge(initializer.dependencies),
merge(kv.dependencies),
Expand Down
2 changes: 1 addition & 1 deletion packages/init/src/action/patch.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { always, apply, entries, map, pipe, pipeLazy, tap } from "@fxts/core";
import { toMerged } from "es-toolkit";
import { readFile } from "node:fs/promises";
import { formatJson, merge, replaceAll, set } from "../utils.ts";
import { createFile, throwUnlessNotExists } from "../lib.ts";
import type { InitCommandData } from "../types.ts";
import { formatJson, merge, replaceAll, set } from "../utils.ts";
import {
devToolConfigs,
loadDenoConfig,
Expand Down
10 changes: 8 additions & 2 deletions packages/init/src/action/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
MessageQueue,
MessageQueueDescription,
WebFrameworkDescription,
WebFrameworkInitializer,
} from "../types.ts";
import webFrameworks from "../webframeworks/mod.ts";

Expand Down Expand Up @@ -58,6 +59,11 @@ const setMq = set(
const setEnv = set(
"env",
<
T extends { kv: KvStoreDescription; mq: MessageQueueDescription },
>({ kv, mq }: T) => merge(kv.env)(mq.env),
T extends {
initializer: WebFrameworkInitializer;
kv: KvStoreDescription;
mq: MessageQueueDescription;
},
>({ initializer, kv, mq }: T) =>
merge(initializer.env)(merge(kv.env)(mq.env)),
);
43 changes: 31 additions & 12 deletions packages/init/src/action/templates.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { entries, join, map, pipe } from "@fxts/core";
import { concat, entries, join, map, pipe, when } from "@fxts/core";
import { toMerged } from "es-toolkit";
import { replace } from "../utils.ts";
import { readTemplate } from "../lib.ts";
import type { InitCommandData, PackageManager } from "../types.ts";
import { replace } from "../utils.ts";
import { needsDenoDotenv } from "./utils.ts";

/**
* Loads the federation configuration file content from template.
* Reads the default federation template and replaces placeholders with actual configuration values.
* Reads the default federation template and replaces placeholders with actual
* configuration values.
*
* @param param0 - Configuration object containing imports, project name, KV store, message queue, and package manager
* @param param0 - Configuration object containing imports, project name,
* KV store, message queue, and package manager
* @returns The complete federation configuration file content as a string
*/
export const loadFederation = async (
Expand Down Expand Up @@ -43,12 +46,19 @@ export const loadLogging = async ({ projectName }: InitCommandData) =>

/**
* Generates import statements for KV store and message queue dependencies.
* Merges imports from both KV and MQ configurations and creates proper ES module import syntax.
* Merges imports from both KV and MQ configurations and creates proper
* ES module import syntax.
*
* @param param0 - Destructured object containing kv and mq configurations
* Destructured parameters:
* - kv: KV store configuration, including module import mappings
* - mq: Message queue configuration, including module import mappings
* - packageManager: Package manager used for environment-specific handling
* - env: Environment variable setup used to determine loading requirements
*
* @param param0 - InitCommandData containing kv, mq, packageManager, and env
* @returns A multi-line string containing all necessary import statements
*/
export const getImports = ({ kv, mq }: InitCommandData) =>
export const getImports = ({ kv, mq, packageManager, env }: InitCommandData) =>
pipe(
toMerged(kv.imports, mq.imports),
entries,
Expand All @@ -61,12 +71,17 @@ export const getImports = ({ kv, mq }: InitCommandData) =>
.join(", ")
} from ${JSON.stringify(module)};`
),
when(
() => needsDenoDotenv({ packageManager, env }),
concat(['import "@std/dotenv/load";']),
),
join("\n"),
);

/**
* Converts import mappings to named import string with aliases.
* Creates proper ES module named import syntax, using aliases when the import name differs from the local name.
* Creates proper ES module named import syntax, using aliases when the import
* name differs from the local name.
*
* @param imports - A record mapping import names to their local aliases
* @returns A comma-separated string of named imports with aliases where needed
Expand All @@ -81,12 +96,16 @@ export const getAlias = (imports: Record<string, string>) =>

const ENV_REG_EXP = /process\.env\.(\w+)/g;
/**
* Converts Node.js environment variable access to Deno-compatible syntax when needed.
* Transforms `process.env.VAR_NAME` to `Deno.env.get("VAR_NAME")` for Deno projects.
* Converts Node.js environment variable access to Deno-compatible syntax when
* needed.
* Transforms `process.env.VAR_NAME` to `Deno.env.get("VAR_NAME")` for Deno
* projects.
*
* @param obj - The object string containing potential environment variable references
* @param obj - The object string containing potential environment variable
* references
* @param pm - The package manager (runtime) being used
* @returns The converted object string with appropriate environment variable access syntax
* @returns The converted object string with appropriate environment variable
* access syntax
*/
export const convertEnv = (obj: string, pm: PackageManager) =>
pm === "deno" && ENV_REG_EXP.test(obj)
Expand Down
8 changes: 8 additions & 0 deletions packages/init/src/action/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const isDeno = (
{ packageManager }: Pick<InitCommandData, "packageManager">,
) => packageManager === "deno";

/**
* Returns `true` when the `@std/dotenv` dependency should be included:
* the project uses Deno and has at least one environment variable to load.
*/
export const needsDenoDotenv = (
{ packageManager, env }: Pick<InitCommandData, "packageManager" | "env">,
) => packageManager === "deno" && Object.keys(env).length > 0;

/**
* Returns a function that prepends the project directory to a
* `[filename, content]` tuple, resolving the filename into an absolute path.
Expand Down
1 change: 0 additions & 1 deletion packages/init/src/templates/bare-bones/main/deno.ts.tpl
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import "@std/dotenv/load";
import { behindProxy } from "@hongminhee/x-forwarded-fetch";
import federation from "./federation.ts";
import "./logging.ts";
Expand Down
1 change: 0 additions & 1 deletion packages/init/src/templates/hono/index/deno.ts.tpl
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { behindProxy } from "@hongminhee/x-forwarded-fetch";
import "@std/dotenv/load";
import app from "./app.tsx";
import "./logging.ts";

Expand Down
15 changes: 11 additions & 4 deletions packages/init/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export type Runtimes = Record<PackageManager, RuntimeDescription>;
* whether it is installed.
*/
export interface RuntimeDescription {
/** Human-readable name of the runtime (e.g., `"Deno"`, `"Node.js"`). */
label: string;
/** Shell command to run for checking availability (e.g., `["deno", "--version"]`). */
checkCommand: [string, ...string[]];
Expand Down Expand Up @@ -79,12 +78,20 @@ export interface WebFrameworkInitializer {
federationFile: string;
/** Relative path where the logging configuration file will be created. */
loggingFile: string;
/** Additional files to create, keyed by relative path to file content. */
files?: Record<string, string>;
/**
* Additional files to create, keyed by relative path to file content.
* Do not use `".env"` as a key — use the {@link env} property instead so
* that environment variables are properly merged with KV/MQ env vars.
*/
files?: Record<string, string> & { ".env"?: never };
/** Environment variables required by this framework, keyed by name to
* default value. Merged together with KV store and message queue env vars
* into the generated `.env` file. */
env?: object;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The env property should be typed as Record<string, string> instead of object to provide better type safety and ensure that environment variables are always key-value pairs of strings.

Suggested change
env?: object;
env?: Record<string, string>;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The properties for this option can vary depending on other conditions. Using a Record<string, string> type could cause type errors and confuse contributors, so I used object instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What kind of types are these properties filled with?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, the HOST property is used in nitroDescription when in test mode. It is set as testMode ? { HOST: "127.0.0.1" } : {}. If that property type is defined as Record<string, string>, a type error occurs on the entire result of the init method. This requires unnecessary type assertions. It could also cause confusion for contributors. I left a mention about a similar issue in another comment.

2026-04-05.17.53.55.mov

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, could you show the whole type error message? I guess we could solve this somehow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TS2322 [ERROR]: Type 'Promise<{ command: string[]; dependencies: { "@fedify/lint"?: string | undefined; "@fedify/h3": string; }; devDependencies: { "@fedify/lint": string; eslint: string; "@biomejs/biome": string; }; federationFile: string; ... 4 more ...; instruction: Message; }>' is not assignable to type 'WebFrameworkInitializer | Promise<WebFrameworkInitializer>'.
  Type 'Promise<{ command: string[]; dependencies: { "@fedify/lint"?: string | undefined; "@fedify/h3": string; }; devDependencies: { "@fedify/lint": string; eslint: string; "@biomejs/biome": string; }; federationFile: string; ... 4 more ...; instruction: Message; }>' is not assignable to type 'Promise<WebFrameworkInitializer>'.
    Type '{ command: string[]; dependencies: { "@fedify/lint"?: string | undefined; "@fedify/h3": string; }; devDependencies: { "@fedify/lint": string; eslint: string; "@biomejs/biome": string; }; federationFile: string; ... 4 more ...; instruction: Message; }' is not assignable to type 'WebFrameworkInitializer'.
      Types of property 'env' are incompatible.
        Type '{ HOST: string; } | { HOST?: undefined; }' is not assignable to type 'Record<string, string> | undefined'.
          Type '{ HOST?: undefined; }' is not assignable to type 'Record<string, string>'.
            Property 'HOST' is incompatible with index signature.
              Type 'undefined' is not assignable to type 'string'.
  init: async ({ packageManager: pm, testMode }) => ({
                                                    ^
    at file:///workspaces/fedify/packages/init/src/webframeworks/nitro.ts:11:53

    The expected type comes from the return type of this signature.
      init(
      ^
        at file:///workspaces/fedify/packages/init/src/types.ts:114:3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about passing undefined instead of {}?

testMode ? { HOST: "127.0.0.1" } : undefined

Or using type assertion?

testMode ? { HOST: "127.0.0.1" } : ({} as Record<string, string>)

Copy link
Copy Markdown
Contributor Author

@2chanhaeng 2chanhaeng Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered the future use of this property in deinitions of WebFrameworkDescription object of other framework. Since using undefined can be only used in cases exist or not, using type assertion would be appropriate. However, when the required properties differ, the type must be explicitly specified as as Record<string, string> every time. Doing this unnecessary type definition is a very tedious task.
For env, since it is not widely used yet, one might think it's fine to specify it manually each time. However, for tasks, this explicit specification is required every time. For tasks in a framework that supports both Deno and Node.js, at least the following properties are needed: pm === "deno" ? { dev: "deno task" } : { dev: "node script", lint: "eslint ." } Deno does not require lint task cause it has its own lint command. However, Node.js requires lint script definition. Therefore, since the properties of the two objects are different, specifying as Record<string, string> is always necessary.
If env also gets used in many WebFrameworkDescription object definitions like tasks, as Record<string, string> will be required every time. There is no need to confuse contributors by forcing such unnecessary type specifications. For this reason, I defined the types of the two properties as object instead of Record<string, string>.

/** TypeScript compiler options to include in `tsconfig.json`. */
compilerOptions?: Record<string, string | boolean | number | string[] | null>;
/** Task scripts keyed by task name (e.g., `"dev"`, `"prod"`, `"lint"`). */
tasks?: Record<string, string>;
tasks?: object;
/** Instructions shown to the user after project initialization is complete. */
instruction: Message;
}
Expand Down
54 changes: 27 additions & 27 deletions packages/init/src/webframeworks/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import deps from "../json/deps.json" with { type: "json" };
import { PACKAGE_VERSION, readTemplate } from "../lib.ts";
import type { PackageManager, WebFrameworkDescription } from "../types.ts";
import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts";
import { getInstruction } from "./utils.ts";
import { getInstruction, pmToRt } from "./utils.ts";

const astroDescription: WebFrameworkDescription = {
label: "Astro",
Expand All @@ -22,6 +22,8 @@ const astroDescription: WebFrameworkDescription = {
: {
"@astrojs/node": deps["npm:@astrojs/node"],
"@fedify/astro": PACKAGE_VERSION,
...(pm !== "bun" &&
{ "@dotenvx/dotenvx": deps["npm:@dotenvx/dotenvx"] }),
},
devDependencies: {
...defaultDevDependencies,
Expand All @@ -39,33 +41,11 @@ const astroDescription: WebFrameworkDescription = {
`astro/astro.config.${pm === "deno" ? "deno" : "node"}.ts`,
),
"src/middleware.ts": await readTemplate("astro/src/middleware.ts"),
...(pm !== "deno"
? {
"eslint.config.ts": await readTemplate("defaults/eslint.config.ts"),
}
: {}),
},
compilerOptions: undefined,
tasks: {
...(pm === "deno"
? {
dev: "deno run -A npm:astro dev",
build: "deno run -A npm:astro build",
preview: "deno run -A npm:astro preview",
}
: pm === "bun"
? {
dev: "bunx astro dev",
build: "bunx astro build",
preview: "bunx astro preview",
}
: {
dev: "astro dev",
build: "astro build",
preview: "astro preview",
}),
...(pm !== "deno" ? { lint: "eslint ." } : {}),
...(pm !== "deno" && {
"eslint.config.ts": await readTemplate("defaults/eslint.config.ts"),
}),
},
tasks: TASKS[pmToRt(pm)],
instruction: getInstruction(pm, 4321),
}),
};
Expand Down Expand Up @@ -97,3 +77,23 @@ function* getAstroInitCommand(

const createAstroAppCommand = (pm: PackageManager): string[] =>
pm === "deno" ? ["deno", "init", "-y", "--npm"] : [pm, "create"];

const TASKS = {
"deno": {
dev: "deno run -A npm:astro dev",
build: "deno run -A npm:astro build",
preview: "deno run -A npm:astro preview",
},
"bun": {
dev: "bunx astro dev",
build: "bunx astro build",
preview: "bunx astro preview",
lint: "eslint .",
},
"node": {
dev: "dotenvx run -- astro dev",
build: "dotenvx run -- astro build",
preview: "dotenvx run -- astro preview",
lint: "eslint .",
},
};
35 changes: 20 additions & 15 deletions packages/init/src/webframeworks/bare-bones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import deps from "../json/deps.json" with { type: "json" };
import { readTemplate } from "../lib.ts";
import type { WebFrameworkDescription } from "../types.ts";
import { defaultDenoDependencies, defaultDevDependencies } from "./const.ts";
import { getInstruction, packageManagerToRuntime } from "./utils.ts";
import { getInstruction, pmToRt } from "./utils.ts";

const bareBonesDescription: WebFrameworkDescription = {
label: "Bare-bones",
Expand All @@ -13,7 +13,6 @@ const bareBonesDescription: WebFrameworkDescription = {
dependencies: pm === "deno"
? {
...defaultDenoDependencies,
"@std/dotenv": deps["@std/dotenv"],
"@hongminhee/x-forwarded-fetch": deps["@hongminhee/x-forwarded-fetch"],
}
: pm === "bun"
Expand All @@ -34,7 +33,7 @@ const bareBonesDescription: WebFrameworkDescription = {
loggingFile: "src/logging.ts",
files: {
"src/main.ts": await readTemplate(
`bare-bones/main/${packageManagerToRuntime(pm)}.ts`,
`bare-bones/main/${pmToRt(pm)}.ts`,
),
...(pm !== "deno"
? {
Expand All @@ -57,20 +56,26 @@ const bareBonesDescription: WebFrameworkDescription = {
"noEmit": true,
"strict": true,
}) as Record<string, string | boolean | number | string[] | null>,
tasks: {
"dev": pm === "deno"
? "deno run -A --watch ./src/main.ts"
: pm === "bun"
? "bun run --hot ./src/main.ts"
: "dotenvx run -- tsx watch ./src/main.ts",
"prod": pm === "deno"
? "deno run -A ./src/main.ts"
: pm === "bun"
? "bun run ./src/main.ts"
: "dotenvx run -- node --import tsx ./src/main.ts",
},
tasks: TASKS[pmToRt(pm)],
instruction: getInstruction(pm, 8000),
}),
};

export default bareBonesDescription;

const TASKS = {
deno: {
dev: "deno run -A --watch ./src/main.ts",
prod: "deno run -A ./src/main.ts",
},
bun: {
dev: "bun run --hot ./src/main.ts",
prod: "bun run ./src/main.ts",
lint: "eslint .",
},
node: {
dev: "dotenvx run -- tsx watch ./src/main.ts",
prod: "dotenvx run -- node --import tsx ./src/main.ts",
lint: "eslint .",
},
};
Loading
Loading