Skip to content

Commit 39bb2df

Browse files
committed
Suppress framework scaffolder installs under --skip-install
1 parent c5dd734 commit 39bb2df

6 files changed

Lines changed: 117 additions & 14 deletions

File tree

packages/init/src/action/notice.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
printMessage,
99
type RequiredNotNull,
1010
} from "../utils.ts";
11+
import { withSkipInstallArgs } from "./utils.ts";
1112

1213
/** Prints the Feddy ASCII art banner to stderr. */
1314
export function drawDinosaur() {
@@ -49,13 +50,10 @@ export const noticeDry = () =>
4950
* Prints the precommand that would be run in dry-run mode,
5051
* showing the directory and command to execute.
5152
*/
52-
export function noticePrecommand({
53-
initializer: { command },
54-
dir,
55-
}: InitCommandData) {
53+
export function noticePrecommand(data: InitCommandData) {
5654
printMessage`📦 Would run command:`;
57-
printMessage` cd ${dir}`;
58-
printMessage` ${command!.join(" ")}\n`;
55+
printMessage` cd ${data.dir}`;
56+
printMessage` ${withSkipInstallArgs(data).join(" ")}\n`;
5957
}
6058

6159
/** Prints a header indicating that text files would be created. */

packages/init/src/action/utils.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import $ from "@david/dax";
22
import { join as joinPath } from "node:path";
3-
import type { InitCommandData } from "../types.ts";
3+
import type { InitCommandData, WebFrameworkInitializer } from "../types.ts";
44

55
/** Returns `true` if the current run is in dry-run mode. */
66
export const isDry = ({ dryRun }: InitCommandData) => dryRun;
@@ -97,14 +97,34 @@ export const installDependencies = ({ packageManager, dir }: InitCommandData) =>
9797
* @param data - The initialization command data containing the initializer command and directory
9898
* @returns A promise that resolves when the precommand has been executed
9999
*/
100-
export const runPrecommand = async (
101-
{ initializer: { command }, dir }: InitCommandData,
102-
) =>
100+
export const runPrecommand = async (data: InitCommandData) =>
103101
await Array.fromAsync(
104-
splitOnOperator(command!),
105-
(cmd) => $`${cmd}`.cwd(dir).spawn(),
102+
splitOnOperator(withSkipInstallArgs(data)),
103+
(cmd) => $`${cmd}`.cwd(data.dir).spawn(),
106104
);
107105

106+
/**
107+
* Returns the precommand with `skipInstallArgs` injected before the first
108+
* `&&` operator when `--skip-install` is requested. Returns the original
109+
* command otherwise.
110+
*/
111+
export const withSkipInstallArgs = (
112+
{ initializer: { command, skipInstallArgs }, skipInstall }: {
113+
initializer: Pick<WebFrameworkInitializer, "command" | "skipInstallArgs">;
114+
skipInstall: boolean;
115+
},
116+
): string[] => {
117+
if (!command || !skipInstall || !skipInstallArgs?.length) {
118+
return command ?? [];
119+
}
120+
const operatorIndex = command.indexOf("&&");
121+
return operatorIndex === -1 ? [...command, ...skipInstallArgs] : [
122+
...command.slice(0, operatorIndex),
123+
...skipInstallArgs,
124+
...command.slice(operatorIndex),
125+
];
126+
};
127+
108128
function* splitOnOperator(command: string[]): Generator<string[]> {
109129
let current: string[] = [];
110130
for (const arg of command) {

packages/init/src/skip-install.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { parse } from "@optique/core/parser";
2-
import { ok, strictEqual } from "node:assert/strict";
2+
import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
33
import test from "node:test";
4-
import { isSkipInstall } from "./action/utils.ts";
4+
import { isSkipInstall, withSkipInstallArgs } from "./action/utils.ts";
55
import { initOptions } from "./command.ts";
66

77
test("initOptions parses --skip-install as true", () => {
@@ -24,3 +24,62 @@ test("isSkipInstall mirrors the skipInstall field", () => {
2424
strictEqual(isSkipInstall({ skipInstall: false }), false);
2525
strictEqual(isSkipInstall({ skipInstall: true }), true);
2626
});
27+
28+
test("withSkipInstallArgs returns command unchanged when skipInstall is false", () => {
29+
deepStrictEqual(
30+
withSkipInstallArgs({
31+
initializer: {
32+
command: ["npx", "create-next-app", "."],
33+
skipInstallArgs: ["--skip-install"],
34+
},
35+
skipInstall: false,
36+
}),
37+
["npx", "create-next-app", "."],
38+
);
39+
});
40+
41+
test("withSkipInstallArgs returns command unchanged when args are absent", () => {
42+
deepStrictEqual(
43+
withSkipInstallArgs({
44+
initializer: { command: ["npx", "create-next-app", "."] },
45+
skipInstall: true,
46+
}),
47+
["npx", "create-next-app", "."],
48+
);
49+
deepStrictEqual(
50+
withSkipInstallArgs({
51+
initializer: {
52+
command: ["npx", "create-next-app", "."],
53+
skipInstallArgs: [],
54+
},
55+
skipInstall: true,
56+
}),
57+
["npx", "create-next-app", "."],
58+
);
59+
});
60+
61+
test("withSkipInstallArgs appends args when command has no `&&`", () => {
62+
deepStrictEqual(
63+
withSkipInstallArgs({
64+
initializer: {
65+
command: ["npx", "create-next-app", ".", "--yes"],
66+
skipInstallArgs: ["--skip-install"],
67+
},
68+
skipInstall: true,
69+
}),
70+
["npx", "create-next-app", ".", "--yes", "--skip-install"],
71+
);
72+
});
73+
74+
test("withSkipInstallArgs injects args before the first `&&`", () => {
75+
deepStrictEqual(
76+
withSkipInstallArgs({
77+
initializer: {
78+
command: ["npx", "create-foo", ".", "&&", "rm", "foo.config.ts"],
79+
skipInstallArgs: ["--no-install"],
80+
},
81+
skipInstall: true,
82+
}),
83+
["npx", "create-foo", ".", "--no-install", "&&", "rm", "foo.config.ts"],
84+
);
85+
});

packages/init/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export interface PackageManagerDescription {
7070
export interface WebFrameworkInitializer {
7171
/** Optional shell command to run before scaffolding (e.g., `create-next-app`). */
7272
command?: string[];
73+
/**
74+
* Args injected into `command` before the first `&&` when `--skip-install`
75+
* is set—e.g., `["--skip-install"]` for `create-next-app`. Ignored when
76+
* `command` is absent.
77+
*/
78+
skipInstallArgs?: string[];
7379
/** Runtime dependencies to install (package name to version). */
7480
dependencies?: Record<string, string>;
7581
/** Development-only dependencies to install (package name to version). */

packages/init/src/webframeworks.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,25 @@ test("Next.js template loads LogTape through instrumentation", async () => {
5151
ok(instrumentation.includes('await import("./logging")'));
5252
});
5353

54+
test("Next.js declares skipInstallArgs so the precommand opts out of installation", async () => {
55+
const { skipInstallArgs } = await nextDescription.init({
56+
projectName: "test-app",
57+
dir: ".",
58+
command: "init",
59+
packageManager: "npm",
60+
kvStore: "in-memory",
61+
messageQueue: "in-process",
62+
webFramework: "next",
63+
testMode: false,
64+
dryRun: true,
65+
allowNonEmpty: false,
66+
skipInstall: false,
67+
});
68+
69+
ok(skipInstallArgs);
70+
ok(skipInstallArgs.includes("--skip-install"));
71+
});
72+
5473
test("Astro template loads LogTape through middleware", async () => {
5574
const { files } = await astroDescription.init({
5675
projectName: "test-app",

packages/init/src/webframeworks/next.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const nextDescription: WebFrameworkDescription = {
1111
defaultPort: 3000,
1212
init: async ({ packageManager: pm }) => ({
1313
command: getNextInitCommand(pm),
14+
skipInstallArgs: ["--skip-install"],
1415
dependencies: {
1516
"@fedify/next": PACKAGE_VERSION,
1617
...(pm === "deno" ? defaultDenoDependencies : {}),

0 commit comments

Comments
 (0)