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
11 changes: 11 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------------------------------------
Expand Down
5 changes: 5 additions & 0 deletions packages/init/src/action/configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function createInitData(): InitCommandData {
messageQueue: "denokv",
dryRun: false,
allowNonEmpty: false,
skipInstall: false,
testMode: false,
dir: "/tmp/example",
initializer: {
Expand Down Expand Up @@ -166,6 +167,7 @@ async function createNpmInitData(dir: string): Promise<InitCommandData> {
messageQueue: "in-process",
dryRun: false,
allowNonEmpty: false,
skipInstall: false,
testMode: false,
dir,
});
Expand All @@ -179,6 +181,7 @@ async function createNpmInitData(dir: string): Promise<InitCommandData> {
messageQueue: "in-process",
dryRun: false,
allowNonEmpty: false,
skipInstall: false,
testMode: false,
dir,
initializer,
Expand All @@ -199,6 +202,7 @@ async function createNuxtNpmInitData(dir: string): Promise<InitCommandData> {
messageQueue: "in-process",
dryRun: false,
allowNonEmpty: false,
skipInstall: false,
testMode: false,
dir,
});
Expand All @@ -212,6 +216,7 @@ async function createNuxtNpmInitData(dir: string): Promise<InitCommandData> {
messageQueue: "in-process",
dryRun: false,
allowNonEmpty: false,
skipInstall: false,
testMode: false,
dir,
initializer,
Expand Down
9 changes: 7 additions & 2 deletions packages/init/src/action/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
noticeHowToRun,
noticeOptions,
noticePrecommand,
noticeSkippedInstall,
} from "./notice.ts";
import {
assertNoGeneratedFileConflicts,
Expand All @@ -23,6 +24,7 @@ import {
hasCommand,
installDependencies,
isDry,
isSkipInstall,
runPrecommand,
} from "./utils.ts";

Expand All @@ -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(
Expand All @@ -52,6 +56,7 @@ const runInit = (options: InitCommand) =>
when(isDry, handleDryRun),
unless(isDry, handleHydRun),
tap(recommendConfigEnv),
tap(unless(isDry, when(isSkipInstall, noticeSkippedInstall))),
tap(noticeHowToRun),
);

Expand All @@ -76,5 +81,5 @@ const handleHydRun = (data: InitCommandData) =>
tap(assertNoGeneratedFileConflicts),
tap(when(hasCommand, runPrecommand)),
tap(patchFiles),
tap(installDependencies),
tap(unless(isSkipInstall, installDependencies)),
);
20 changes: 14 additions & 6 deletions packages/init/src/action/notice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/init/src/action/patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function createInitData(
messageQueue: "in-process",
dryRun: false,
allowNonEmpty,
skipInstall: false,
testMode: false,
dir,
initializer: {
Expand Down
37 changes: 31 additions & 6 deletions packages/init/src/action/utils.ts
Original file line number Diff line number Diff line change
@@ -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<InitCommandData, "skipInstall">,
) => skipInstall;

/** Returns `true` if the framework initializer has a precommand to execute. */
export const hasCommand = (data: InitCommandData) => !!data.initializer.command;

Expand Down Expand Up @@ -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<WebFrameworkInitializer, "command" | "skipInstallArgs">;
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<string[]> {
let current: string[] = [];
for (const arg of command) {
Expand Down
4 changes: 4 additions & 0 deletions packages/init/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
}),
});

/**
Expand Down
1 change: 1 addition & 0 deletions packages/init/src/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ test(
dir: packageDir,
dryRun: true,
allowNonEmpty: false,
skipInstall: false,
kvStore: "in-memory",
messageQueue: "in-process",
packageManager: "bun",
Expand Down
85 changes: 85 additions & 0 deletions packages/init/src/skip-install.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
);
});
6 changes: 6 additions & 0 deletions packages/init/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** Development-only dependencies to install (package name to version). */
Expand Down
Loading