Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/quiet-scaffold-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@marko/create": patch
"create-marko": patch
---

Run the scaffold's install and git steps quietly behind the progress spinner. The noisy internals — pnpm's ignored-builds notice, esbuild's postinstall, the confirmation re-install, husky's `.git can't be found`, and git probes like `fatal: not a git repository` — are no longer printed. Install output is surfaced only when it genuinely fails.
39 changes: 19 additions & 20 deletions packages/create/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,31 +98,30 @@ export async function run(options: CliOptions): Promise<void> {
}

const spin = p.spinner();
let spinning = false;
spin.start("Setting up project");
let installLog = "";

const result = createProject({ ...options, name, template });
result.on("download", () => {
spin.start("Downloading app");
spinning = true;
});
result.on("install", (installer: string) => {
if (spinning) {
spin.stop("Downloaded app");
spinning = false;
}
p.log.step(`Installing dependencies with ${color.cyan(installer)}`);
});
result.on("install-error", (installer: string) =>
p.log.warn(
`${color.cyan(`${installer} install`)} did not finish cleanly. Your ` +
"project was still created — you may need to install dependencies manually.",
),
result.on("download", () => spin.message("Downloading app"));
result.on("install", (installer: string) =>
spin.message(`Installing dependencies with ${installer}`),
);
result.on("init", () => p.log.step("Initializing git repository"));
result.on("install-error", (_installer: string, log?: string) => {
installLog = log ?? "";
});
result.on("init", () => spin.message("Setting up git repository"));

try {
const { projectPath, installer, installed, scripts } = await result;
if (spinning) spin.stop("Downloaded app");
spin.stop("Project created");

if (!installed) {
p.log.warn(
`${color.cyan(`${installer} install`)} did not finish cleanly — you ` +
"may need to run it yourself.",
);
if (installLog.trim()) p.log.message(installLog.trim());
}

// `<pm> run <script>` is valid for npm/pnpm/yarn/bun alike.
const script = scripts.dev ? "dev" : scripts.start ? "start" : undefined;
Expand All @@ -138,7 +137,7 @@ export async function run(options: CliOptions): Promise<void> {
.join("\n")}`,
);
} catch (err) {
if (spinning) spin.stop("Failed to create project", 1);
spin.stop("Failed to create project", 1);
p.cancel((err as Error).message);
process.exitCode = 1;
}
Expand Down
43 changes: 26 additions & 17 deletions packages/create/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { join, resolve } from "node:path";
import { downloadTemplate } from "giget";

import { detectInstaller } from "./env.js";
import { exec } from "./exec.js";
import { exec, type ExecError } from "./exec.js";
import { initGitRepo } from "./git.js";

export const DEFAULT_TEMPLATE = "basic";
Expand Down Expand Up @@ -78,8 +78,8 @@ async function create(
const { scripts } = await rewritePackageJson(projectPath, name);

emitter.emit("install", installer);
const installed = await install(installer, projectPath);
if (!installed) emitter.emit("install-error", installer);
const { installed, log } = await install(installer, projectPath);
if (!installed) emitter.emit("install-error", installer, log);

await initGitRepo(projectPath, emitter);

Expand Down Expand Up @@ -191,27 +191,36 @@ async function rewritePackageJson(
return { scripts: pkg.scripts ?? {} };
}

async function install(installer: string, cwd: string): Promise<boolean> {
async function install(
installer: string,
cwd: string,
): Promise<{ installed: boolean; log?: string }> {
const run = () =>
exec(cwd, installer, ["install"], { shell: true, capture: true });

try {
await exec(cwd, installer, ["install"], { shell: true });
return true;
} catch {
await run();
return { installed: true };
} catch (error) {
// pnpm exits non-zero when it blocks a dependency's build scripts. The
// vite-based templates rely on esbuild's, so approve just esbuild — pnpm
// writes nothing and no-ops if esbuild wasn't actually installed — then
// re-install to confirm that was the only problem. Any other package
// manager (or a genuine pnpm failure) is a real error.
if (installer !== "pnpm") return false;

try {
await exec(cwd, installer, ["approve-builds", "esbuild"], {
shell: true,
});
await exec(cwd, installer, ["install"], { shell: true });
return true;
} catch {
return false;
if (installer === "pnpm") {
try {
await exec(cwd, installer, ["approve-builds", "esbuild"], {
shell: true,
capture: true,
});
await run();
return { installed: true };
} catch {
// Fall through and report the original install failure.
}
}

return { installed: false, log: (error as ExecError).output };
}
}

Expand Down
33 changes: 27 additions & 6 deletions packages/create/src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,52 @@ export interface ExecOptions {
* use it with internally-controlled arguments.
*/
shell?: boolean;
/**
* Capture stdout/stderr instead of inheriting them. The combined output is
* attached to the rejection as `ExecError.output` so callers can surface it
* only when something actually goes wrong.
*/
capture?: boolean;
}

export interface ExecError extends Error {
output?: string;
}

/**
* Run a command, inheriting stdio so its output is visible to the user.
* Resolves on a `0` exit code and rejects otherwise.
* Run a command to completion. Resolves on a `0` exit code and rejects
* otherwise. Inherits stdio by default; pass `capture` to buffer it instead.
*/
export function exec(
cwd: string,
bin: string,
args: string[],
{ shell = false }: ExecOptions = {},
{ shell = false, capture = false }: ExecOptions = {},
): Promise<void> {
return new Promise((resolve, reject) => {
const stdio = capture ? "pipe" : "inherit";
const child = shell
? spawn([bin, ...args].join(" "), {
cwd,
shell: true,
stdio: "inherit",
stdio,
windowsHide: true,
})
: spawn(bin, args, { cwd, stdio: "inherit", windowsHide: true });
: spawn(bin, args, { cwd, stdio, windowsHide: true });

let output = "";
if (capture) {
child.stdout?.on("data", (chunk) => (output += chunk));
child.stderr?.on("data", (chunk) => (output += chunk));
}

child.once("error", reject).once("close", (code) => {
if (code) {
reject(new Error(`${bin} ${args.join(" ")} exited with code ${code}`));
const error: ExecError = new Error(
`${bin} ${args.join(" ")} exited with code ${code}`,
);
error.output = output;
reject(error);
} else {
resolve();
}
Expand Down
5 changes: 4 additions & 1 deletion packages/create/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ export async function initGitRepo(
}
}

const git = (cwd: string, args: string[]) => exec(cwd, "git", args);
// Capture git's output so internal probes (e.g. `rev-parse` printing
// "fatal: not a git repository") don't leak to the user.
const git = (cwd: string, args: string[]) =>
exec(cwd, "git", args, { capture: true });

async function tryGit(cwd: string, args: string[]): Promise<boolean> {
try {
Expand Down