diff --git a/CHANGES.md b/CHANGES.md index 9398d5286..d5344a401 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -265,6 +265,17 @@ To be released. - Added `SqliteMessageQueue.getDepth()` for reporting queued, ready, and delayed message counts. [[#735], [#748]] +### @fedify/init + + - Added a `--skip-install` option to `fedify init` that skips automatic + dependency installation after scaffolding. This is useful for CI + environments, monorepo workspaces that install dependencies from the + root, or when you want to inspect the generated files before + installing. [[#720], [#776] by fru1tworld] + +[#720]: https://github.com/fedify-dev/fedify/issues/720 +[#776]: https://github.com/fedify-dev/fedify/pull/776 + ### Claude Code plugin - Added a Claude Code plugin at *claude-plugin/*, installable with: diff --git a/docs/cli.md b/docs/cli.md index a724338f2..394ebf845 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -372,6 +372,28 @@ the selected framework scaffolder accepts them. Some scaffolders, such as own confirmation prompt, while a freshly initialized *.git* directory remains acceptable. +### `--skip-install`: Skip installing dependencies + +*This option is available since Fedify 2.3.0.* + +By default, `fedify init` runs the selected package manager's install command +right after scaffolding the project. The `--skip-install` option scaffolds the +files without running install, which is useful when: + + - installation is handled separately in a CI pipeline; + - the new project lives inside a monorepo whose dependencies are installed + from the workspace root; or + - you want to inspect the generated files before installing. + +~~~~ sh +fedify init my-fedify-project --skip-install +~~~~ + +After scaffolding, `fedify init` prints the command to run to install +dependencies later. Other steps such as creating files, applying patches, and +running the framework-specific scaffolder (e.g., *create-next-app*) still +happen as usual; only the final install step is skipped. + `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 ef6190fcd..00ecba5c8 100644 --- a/packages/init/src/action/configs.test.ts +++ b/packages/init/src/action/configs.test.ts @@ -21,6 +21,7 @@ function createInitData(): InitCommandData { messageQueue: "denokv", dryRun: false, allowNonEmpty: false, + skipInstall: false, testMode: false, dir: "/tmp/example", initializer: { @@ -166,6 +167,7 @@ async function createNpmInitData(dir: string): Promise { messageQueue: "in-process", dryRun: false, allowNonEmpty: false, + skipInstall: false, testMode: false, dir, }); @@ -179,6 +181,7 @@ async function createNpmInitData(dir: string): Promise { messageQueue: "in-process", dryRun: false, allowNonEmpty: false, + skipInstall: false, testMode: false, dir, initializer, @@ -199,6 +202,7 @@ async function createNuxtNpmInitData(dir: string): Promise { messageQueue: "in-process", dryRun: false, allowNonEmpty: false, + skipInstall: false, testMode: false, dir, }); @@ -212,6 +216,7 @@ async function createNuxtNpmInitData(dir: string): Promise { messageQueue: "in-process", dryRun: false, allowNonEmpty: false, + skipInstall: false, testMode: false, dir, initializer, diff --git a/packages/init/src/action/mod.ts b/packages/init/src/action/mod.ts index 79ce01550..e11906fd9 100644 --- a/packages/init/src/action/mod.ts +++ b/packages/init/src/action/mod.ts @@ -11,6 +11,7 @@ import { noticeHowToRun, noticeOptions, noticePrecommand, + noticeSkippedInstall, } from "./notice.ts"; import { assertNoGeneratedFileConflicts, @@ -23,6 +24,7 @@ import { hasCommand, installDependencies, isDry, + isSkipInstall, runPrecommand, } from "./utils.ts"; @@ -39,7 +41,9 @@ import { * - If dry run, executes `handleDryRun`. * - Otherwise, executes `handleHydRun`. * 7. Recommends configuration environment via `recommendConfigEnv`. - * 8. Shows how to run the project via `noticeHowToRun`. + * 8. If installation was skipped and not a dry run, prints how to install + * dependencies manually via `noticeSkippedInstall`. + * 9. Shows how to run the project via `noticeHowToRun`. */ const runInit = (options: InitCommand) => pipe( @@ -52,6 +56,7 @@ const runInit = (options: InitCommand) => when(isDry, handleDryRun), unless(isDry, handleHydRun), tap(recommendConfigEnv), + tap(unless(isDry, when(isSkipInstall, noticeSkippedInstall))), tap(noticeHowToRun), ); @@ -76,5 +81,5 @@ const handleHydRun = (data: InitCommandData) => tap(assertNoGeneratedFileConflicts), tap(when(hasCommand, runPrecommand)), tap(patchFiles), - tap(installDependencies), + tap(unless(isSkipInstall, installDependencies)), ); diff --git a/packages/init/src/action/notice.ts b/packages/init/src/action/notice.ts index 4fe343069..679543941 100644 --- a/packages/init/src/action/notice.ts +++ b/packages/init/src/action/notice.ts @@ -8,6 +8,7 @@ import { printMessage, type RequiredNotNull, } from "../utils.ts"; +import { withSkipInstallArgs } from "./utils.ts"; /** Prints the Feddy ASCII art banner to stderr. */ export function drawDinosaur() { @@ -49,13 +50,10 @@ export const noticeDry = () => * Prints the precommand that would be run in dry-run mode, * showing the directory and command to execute. */ -export function noticePrecommand({ - initializer: { command }, - dir, -}: InitCommandData) { +export function noticePrecommand(data: InitCommandData) { printMessage`📦 Would run command:`; - printMessage` cd ${dir}`; - printMessage` ${command!.join(" ")}\n`; + printMessage` cd ${data.dir}`; + printMessage` ${withSkipInstallArgs(data).join(" ")}\n`; } /** Prints a header indicating that text files would be created. */ @@ -117,6 +115,16 @@ ${instruction} Start by editing the ${text(federationFile)} file to define your federation! `; +/** Prints a notice that dependency installation was skipped and how to install them manually. */ +export const noticeSkippedInstall = ( + { packageManager }: InitCommandData, +) => + printMessage` +Dependencies were not installed. Run ${ + text(`${packageManager} install`) + } in the project directory to install them. +`; + /** * Returns an error handler that prints a formatted error message when * a dependency installation command fails, then throws. diff --git a/packages/init/src/action/patch.test.ts b/packages/init/src/action/patch.test.ts index 77bd6c2da..3c6c7d4a2 100644 --- a/packages/init/src/action/patch.test.ts +++ b/packages/init/src/action/patch.test.ts @@ -74,6 +74,7 @@ function createInitData( messageQueue: "in-process", dryRun: false, allowNonEmpty, + skipInstall: false, testMode: false, dir, initializer: { diff --git a/packages/init/src/action/utils.ts b/packages/init/src/action/utils.ts index 019dddb71..bc5037103 100644 --- a/packages/init/src/action/utils.ts +++ b/packages/init/src/action/utils.ts @@ -1,10 +1,15 @@ import $ from "@david/dax"; import { join as joinPath } from "node:path"; -import type { InitCommandData } from "../types.ts"; +import type { InitCommandData, WebFrameworkInitializer } from "../types.ts"; /** Returns `true` if the current run is in dry-run mode. */ export const isDry = ({ dryRun }: InitCommandData) => dryRun; +/** Returns `true` if the current run skips dependency installation. */ +export const isSkipInstall = ( + { skipInstall }: Pick, +) => skipInstall; + /** Returns `true` if the framework initializer has a precommand to execute. */ export const hasCommand = (data: InitCommandData) => !!data.initializer.command; @@ -92,14 +97,34 @@ export const installDependencies = ({ packageManager, dir }: InitCommandData) => * @param data - The initialization command data containing the initializer command and directory * @returns A promise that resolves when the precommand has been executed */ -export const runPrecommand = async ( - { initializer: { command }, dir }: InitCommandData, -) => +export const runPrecommand = async (data: InitCommandData) => await Array.fromAsync( - splitOnOperator(command!), - (cmd) => $`${cmd}`.cwd(dir).spawn(), + splitOnOperator(withSkipInstallArgs(data)), + (cmd) => $`${cmd}`.cwd(data.dir).spawn(), ); +/** + * Returns the precommand with `skipInstallArgs` injected before the first + * `&&` operator when `--skip-install` is requested. Returns the original + * command otherwise. + */ +export const withSkipInstallArgs = ( + { initializer: { command, skipInstallArgs }, skipInstall }: { + initializer: Pick; + skipInstall: boolean; + }, +): string[] => { + if (!command || !skipInstall || !skipInstallArgs?.length) { + return command ?? []; + } + const operatorIndex = command.indexOf("&&"); + return operatorIndex === -1 ? [...command, ...skipInstallArgs] : [ + ...command.slice(0, operatorIndex), + ...skipInstallArgs, + ...command.slice(operatorIndex), + ]; +}; + function* splitOnOperator(command: string[]): Generator { let current: string[] = []; for (const arg of command) { diff --git a/packages/init/src/command.ts b/packages/init/src/command.ts index e2b610648..374340c64 100644 --- a/packages/init/src/command.ts +++ b/packages/init/src/command.ts @@ -77,6 +77,10 @@ export const initOptions = object("Initialization options", { description: message`Allow initializing in a non-empty directory when the selected framework scaffolder supports it, failing if any generated file already exists.`, }), + skipInstall: option("--skip-install", { + description: + message`Skip installing dependencies after scaffolding the project.`, + }), }); /** diff --git a/packages/init/src/package.test.ts b/packages/init/src/package.test.ts index 6e545b7fe..97a33247b 100644 --- a/packages/init/src/package.test.ts +++ b/packages/init/src/package.test.ts @@ -46,6 +46,7 @@ test( dir: packageDir, dryRun: true, allowNonEmpty: false, + skipInstall: false, kvStore: "in-memory", messageQueue: "in-process", packageManager: "bun", diff --git a/packages/init/src/skip-install.test.ts b/packages/init/src/skip-install.test.ts new file mode 100644 index 000000000..8f7c287c5 --- /dev/null +++ b/packages/init/src/skip-install.test.ts @@ -0,0 +1,85 @@ +import { parse } from "@optique/core/parser"; +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import test from "node:test"; +import { isSkipInstall, withSkipInstallArgs } from "./action/utils.ts"; +import { initOptions } from "./command.ts"; + +test("initOptions parses --skip-install as true", () => { + const result = parse(initOptions, ["--skip-install"]); + ok(result.success); + if (result.success) { + strictEqual(result.value.skipInstall, true); + } +}); + +test("initOptions defaults skipInstall to false when the flag is absent", () => { + const result = parse(initOptions, []); + ok(result.success); + if (result.success) { + strictEqual(result.value.skipInstall, false); + } +}); + +test("isSkipInstall mirrors the skipInstall field", () => { + strictEqual(isSkipInstall({ skipInstall: false }), false); + strictEqual(isSkipInstall({ skipInstall: true }), true); +}); + +test("withSkipInstallArgs returns command unchanged when skipInstall is false", () => { + deepStrictEqual( + withSkipInstallArgs({ + initializer: { + command: ["npx", "create-next-app", "."], + skipInstallArgs: ["--skip-install"], + }, + skipInstall: false, + }), + ["npx", "create-next-app", "."], + ); +}); + +test("withSkipInstallArgs returns command unchanged when args are absent", () => { + deepStrictEqual( + withSkipInstallArgs({ + initializer: { command: ["npx", "create-next-app", "."] }, + skipInstall: true, + }), + ["npx", "create-next-app", "."], + ); + deepStrictEqual( + withSkipInstallArgs({ + initializer: { + command: ["npx", "create-next-app", "."], + skipInstallArgs: [], + }, + skipInstall: true, + }), + ["npx", "create-next-app", "."], + ); +}); + +test("withSkipInstallArgs appends args when command has no `&&`", () => { + deepStrictEqual( + withSkipInstallArgs({ + initializer: { + command: ["npx", "create-next-app", ".", "--yes"], + skipInstallArgs: ["--skip-install"], + }, + skipInstall: true, + }), + ["npx", "create-next-app", ".", "--yes", "--skip-install"], + ); +}); + +test("withSkipInstallArgs injects args before the first `&&`", () => { + deepStrictEqual( + withSkipInstallArgs({ + initializer: { + command: ["npx", "create-foo", ".", "&&", "rm", "foo.config.ts"], + skipInstallArgs: ["--no-install"], + }, + skipInstall: true, + }), + ["npx", "create-foo", ".", "--no-install", "&&", "rm", "foo.config.ts"], + ); +}); diff --git a/packages/init/src/types.ts b/packages/init/src/types.ts index 2e61c2ebe..8cf61b581 100644 --- a/packages/init/src/types.ts +++ b/packages/init/src/types.ts @@ -70,6 +70,12 @@ export interface PackageManagerDescription { export interface WebFrameworkInitializer { /** Optional shell command to run before scaffolding (e.g., `create-next-app`). */ command?: string[]; + /** + * Args injected into `command` before the first `&&` when `--skip-install` + * is set—e.g., `["--skip-install"]` for `create-next-app`. Ignored when + * `command` is absent. + */ + skipInstallArgs?: string[]; /** Runtime dependencies to install (package name to version). */ dependencies?: Record; /** Development-only dependencies to install (package name to version). */ diff --git a/packages/init/src/webframeworks.test.ts b/packages/init/src/webframeworks.test.ts index 4ce04fdc5..d2f8a2760 100644 --- a/packages/init/src/webframeworks.test.ts +++ b/packages/init/src/webframeworks.test.ts @@ -17,6 +17,7 @@ test("Nitro template loads LogTape during server startup", async () => { testMode: false, dryRun: true, allowNonEmpty: false, + skipInstall: false, }); ok(files); @@ -38,6 +39,7 @@ test("Next.js template loads LogTape through instrumentation", async () => { testMode: false, dryRun: true, allowNonEmpty: false, + skipInstall: false, }); ok(files); @@ -49,6 +51,25 @@ test("Next.js template loads LogTape through instrumentation", async () => { ok(instrumentation.includes('await import("./logging")')); }); +test("Next.js declares skipInstallArgs so the precommand opts out of installation", async () => { + const { skipInstallArgs } = await nextDescription.init({ + projectName: "test-app", + dir: ".", + command: "init", + packageManager: "npm", + kvStore: "in-memory", + messageQueue: "in-process", + webFramework: "next", + testMode: false, + dryRun: true, + allowNonEmpty: false, + skipInstall: false, + }); + + ok(skipInstallArgs); + ok(skipInstallArgs.includes("--skip-install")); +}); + test("Astro template loads LogTape through middleware", async () => { const { files } = await astroDescription.init({ projectName: "test-app", @@ -61,6 +82,7 @@ test("Astro template loads LogTape through middleware", async () => { testMode: false, dryRun: true, allowNonEmpty: false, + skipInstall: false, }); ok(files); @@ -81,6 +103,7 @@ test("SolidStart template loads LogTape through middleware", async () => { testMode: false, dryRun: true, allowNonEmpty: false, + skipInstall: false, }); ok(files); diff --git a/packages/init/src/webframeworks/next.ts b/packages/init/src/webframeworks/next.ts index 1432697fe..f7d160a41 100644 --- a/packages/init/src/webframeworks/next.ts +++ b/packages/init/src/webframeworks/next.ts @@ -11,6 +11,7 @@ const nextDescription: WebFrameworkDescription = { defaultPort: 3000, init: async ({ packageManager: pm }) => ({ command: getNextInitCommand(pm), + skipInstallArgs: ["--skip-install"], dependencies: { "@fedify/next": PACKAGE_VERSION, ...(pm === "deno" ? defaultDenoDependencies : {}),