Skip to content

Commit 7353bf6

Browse files
Merge pull request #405 from paritytech/feat/mod-package-manager-autoinstall
feat: auto-install the project's package manager during mod/deploy
2 parents 90ad096 + 54a0213 commit 7353bf6

16 files changed

Lines changed: 1013 additions & 154 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"playground-cli": minor
3+
---
4+
5+
`playground mod` now detects the package manager a project uses (pnpm/yarn/bun/npm)
6+
and, when it (or Node.js) is missing, offers to install it with one confirmation
7+
before running the project's setup, instead of failing with a confusing error.
8+
`playground deploy` and `playground build` install a missing manager automatically
9+
as part of the build step. macOS and Linux are supported; non-interactive runs
10+
install without prompting.

src/commands/mod/SetupScreen.tsx

Lines changed: 137 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// See the License for the specific language governing permissions and
1414
// limitations under the License.
1515

16-
import { useRef, useState } from "react";
16+
import { useEffect, useRef, useState } from "react";
1717
import { Box, Text } from "ink";
1818
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
1919
import { resolve } from "node:path";
@@ -31,9 +31,12 @@ import { runCommand } from "../../utils/git.js";
3131
import { createOptionalGitBaseline } from "../../utils/mod/git-baseline.js";
3232
import { downloadGitHubTarball, parseGitHubRepoUrl } from "../../utils/mod/source.js";
3333
import {
34-
findUnsatisfiedPackageManagers,
35-
missingPackageManagerMessage,
36-
} from "../../utils/mod/packageManager.js";
34+
ensurePackageManager,
35+
planPackageManager,
36+
type InstallPlan,
37+
} from "../../utils/packageManagers.js";
38+
import { decidePmPhase, pmConfirmLabel } from "./setupFlow.js";
39+
import { Select } from "../../utils/ui/theme/Select.js";
3740
import { VERSION_LABEL } from "../../utils/version.js";
3841
import { getNetworkLabel } from "../../config.js";
3942
import { fetchBulletinJson, getBulletinGateway } from "../../utils/bulletinGateway.js";
@@ -71,10 +74,18 @@ export function SetupScreen({ domain, metadata: initial, registry, targetDir, on
7174
// sourceUnavailable.ts) — the publisher made the repo private/deleted it
7275
// after publishing, which we can't undo and the user can't fix.
7376
const [unavailable, setUnavailable] = useState(false);
77+
// Package-manager-aware phase machine. After the source is downloaded we
78+
// detect the project's PM and, when it (or Node) is missing, ask once to
79+
// install it before running setup.sh. See setupFlow.ts for the decision.
80+
const [phase, setPhase] = useState<"pre" | "confirm" | "install" | "post" | "halt">("pre");
81+
const [pmPlan, setPmPlan] = useState<InstallPlan | null>(null);
7482
const setupLogFile = resolve(targetDir, ".dot-mod-setup.log");
7583
const sourceLogFile = resolve(targetDir, ".dot-mod-source.log");
7684

77-
const steps: Step[] = [
85+
// Steps that always run first: fetch metadata + download source. `meta` is
86+
// populated by the first step and read by the second, so they must stay
87+
// together in this array (StepRunner runs them sequentially).
88+
const preSteps: Step[] = [
7889
{
7990
name: "fetch app metadata",
8091
run: async (log) => {
@@ -148,33 +159,44 @@ export function SetupScreen({ domain, metadata: initial, registry, targetDir, on
148159
ignoreModLogs(targetDir);
149160
},
150161
},
151-
{
152-
name: "run setup.sh",
153-
keepLogOnSuccess: true,
154-
run: async (log) => {
155-
const setupPath = resolve(targetDir, "setup.sh");
156-
if (!existsSync(setupPath)) {
157-
// Most moddable apps have no setup.sh, and that's normal —
158-
// surfacing it as a warning row alarmed users. Skip the step
159-
// silently so nothing is shown; the parent still prints the
160-
// generic "Next steps" footer because `setupRan` stays false.
161-
throw new SilentSkip("no setup.sh found");
162-
}
163-
const missing = await findUnsatisfiedPackageManagers(
164-
readFileSync(setupPath, "utf8"),
165-
);
166-
if (missing.length > 0) {
167-
throw new Error(missingPackageManagerMessage(missing));
168-
}
169-
await runCommand("bash setup.sh", { cwd: targetDir, log, logFile: setupLogFile });
170-
setupRanRef.current = true;
171-
setSetupRanVisible(true);
172-
},
173-
},
174162
];
175163

164+
// Final on-disk work: run the app's setup.sh. The package manager is
165+
// guaranteed present by the install phase that runs before this, so the
166+
// old "missing package manager" gate is gone.
167+
const runSetupSh: Step = {
168+
name: "run setup.sh",
169+
keepLogOnSuccess: true,
170+
run: async (log) => {
171+
const setupPath = resolve(targetDir, "setup.sh");
172+
if (!existsSync(setupPath)) {
173+
// Most moddable apps have no setup.sh, and that's normal —
174+
// surfacing it as a warning row alarmed users. Skip the step
175+
// silently so nothing is shown; the parent still prints the
176+
// generic "Next steps" footer because `setupRan` stays false.
177+
throw new SilentSkip("no setup.sh found");
178+
}
179+
await runCommand("bash setup.sh", { cwd: targetDir, log, logFile: setupLogFile });
180+
setupRanRef.current = true;
181+
setSetupRanVisible(true);
182+
},
183+
};
184+
176185
const [error, setError] = useState<string | null>(null);
177186

187+
// Declining the install (or having no installable PM path) is a SOFT
188+
// outcome: exit cleanly with `setupRan: false` and let the halt Callout
189+
// explain the manual remedy. Fire `onDone` exactly once on entering halt.
190+
const haltReportedRef = useRef(false);
191+
useEffect(() => {
192+
if (phase === "halt" && !haltReportedRef.current) {
193+
haltReportedRef.current = true;
194+
onDone({ ok: true, setupRan: false });
195+
}
196+
// onDone is captured once on mount by the parent; gate purely on phase.
197+
// eslint-disable-next-line react-hooks/exhaustive-deps
198+
}, [phase]);
199+
178200
return (
179201
<Box flexDirection="column">
180202
<Header
@@ -193,14 +215,93 @@ export function SetupScreen({ domain, metadata: initial, registry, targetDir, on
193215
</Callout>
194216
)}
195217

196-
<StepRunner
197-
title={`modding ${domain}`}
198-
steps={steps}
199-
onDone={(result) => {
200-
if (result.error) setError(result.error);
201-
onDone({ ok: result.ok, setupRan: setupRanRef.current });
202-
}}
203-
/>
218+
{phase === "pre" && (
219+
<StepRunner
220+
title={`modding ${domain}`}
221+
steps={preSteps}
222+
onDone={async (result) => {
223+
if (!result.ok) {
224+
if (result.error) setError(result.error);
225+
onDone({ ok: false, setupRan: false });
226+
return;
227+
}
228+
// Planning is best-effort: if PM detection throws, fall
229+
// through to setup.sh and let any real failure surface
230+
// there rather than blocking the mod on a probe error.
231+
// This callback is fire-and-forget (StepRunner doesn't
232+
// await it), but the success path only ever advances the
233+
// phase — it never calls the terminal onDone — so the
234+
// component stays mounted and the post-await setState
235+
// lands on a live component.
236+
try {
237+
const plan = await planPackageManager(targetDir);
238+
setPmPlan(plan);
239+
const next = decidePmPhase({
240+
missing: plan.toolsToInstall,
241+
isTTY: Boolean(process.stdin.isTTY),
242+
});
243+
setPhase(next === "setup" ? "post" : next);
244+
} catch {
245+
setPhase("post");
246+
}
247+
}}
248+
/>
249+
)}
250+
251+
{phase === "confirm" && pmPlan && (
252+
<Select
253+
label={pmConfirmLabel(pmPlan.pm, pmPlan.toolsToInstall)}
254+
options={[
255+
{ value: "yes", label: "Install now" },
256+
{ value: "no", label: "Cancel — I'll install it myself" },
257+
]}
258+
onSelect={(v) => setPhase(v === "yes" ? "install" : "halt")}
259+
/>
260+
)}
261+
262+
{phase === "install" && (
263+
<StepRunner
264+
title="installing package manager"
265+
steps={[
266+
{
267+
name: `install ${pmPlan?.pm ?? "package manager"}`,
268+
run: async (log) => {
269+
// `confirm` omitted on purpose — the user already
270+
// confirmed (TTY) or we auto-proceed (non-TTY).
271+
await ensurePackageManager(targetDir, { onData: log });
272+
},
273+
},
274+
]}
275+
onDone={(result) => {
276+
if (!result.ok) {
277+
setError(result.error ?? "package manager install failed");
278+
onDone({ ok: false, setupRan: false });
279+
return;
280+
}
281+
setPhase("post");
282+
}}
283+
/>
284+
)}
285+
286+
{phase === "post" && (
287+
<StepRunner
288+
title={`finishing ${domain}`}
289+
steps={[runSetupSh]}
290+
onDone={(result) => {
291+
if (result.error) setError(result.error);
292+
onDone({ ok: result.ok, setupRan: setupRanRef.current });
293+
}}
294+
/>
295+
)}
296+
297+
{phase === "halt" && pmPlan && (
298+
<Callout tone="warning" title="package manager not installed">
299+
<Text>
300+
This project uses {pmPlan.pm}. Install it, then re-run playground mod (or
301+
the build).
302+
</Text>
303+
</Callout>
304+
)}
204305

205306
{!unavailable && <Hint>{targetDir}</Hint>}
206307
{setupRanVisible && <Hint>full setup log: {setupLogFile}</Hint>}

src/commands/mod/setupFlow.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import { describe, it, expect } from "vitest";
17+
import { decidePmPhase, pmConfirmLabel } from "./setupFlow.js";
18+
19+
describe("decidePmPhase", () => {
20+
it("goes straight to setup when nothing is missing", () => {
21+
expect(decidePmPhase({ missing: [], isTTY: true })).toBe("setup");
22+
});
23+
24+
it("prompts for confirmation in a TTY when tools are missing", () => {
25+
expect(decidePmPhase({ missing: ["Node.js", "pnpm"], isTTY: true })).toBe("confirm");
26+
});
27+
28+
it("auto-installs without a prompt when there is no TTY", () => {
29+
expect(decidePmPhase({ missing: ["bun"], isTTY: false })).toBe("install");
30+
});
31+
});
32+
33+
describe("pmConfirmLabel", () => {
34+
it("names the PM and exactly what will be installed", () => {
35+
expect(pmConfirmLabel("pnpm", ["Node.js", "pnpm"])).toBe(
36+
"This project uses pnpm, which isn't installed. Install it now? (Node.js + pnpm)",
37+
);
38+
});
39+
});

src/commands/mod/setupFlow.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
/** Pure decision + text helpers for SetupScreen's package-manager phase. */
17+
18+
import type { PackageManager } from "../../utils/packageManagers.js";
19+
20+
/** Phase to enter after the source has been downloaded and the PM planned. */
21+
export type PmPhase = "setup" | "confirm" | "install";
22+
23+
export function decidePmPhase(opts: { missing: string[]; isTTY: boolean }): PmPhase {
24+
if (opts.missing.length === 0) return "setup";
25+
return opts.isTTY ? "confirm" : "install";
26+
}
27+
28+
/** Confirmation prompt label, naming the PM and the exact tools to install. */
29+
export function pmConfirmLabel(pm: PackageManager, toolsToInstall: string[]): string {
30+
return `This project uses ${pm}, which isn't installed. Install it now? (${toolsToInstall.join(" + ")})`;
31+
}

src/utils/build/detect.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ describe("detectPackageManager", () => {
4949
it("picks bun when only bun.lockb is present", () => {
5050
expect(detectPackageManager(new Set(["bun.lockb"]))).toBe("bun");
5151
});
52+
53+
it("picks bun when only bun.lock (bun 1.2+ text lockfile) is present", () => {
54+
expect(detectPackageManager(new Set(["bun.lock"]))).toBe("bun");
55+
});
5256
});
5357

5458
describe("detectBuildConfig", () => {

src/utils/build/detect.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,21 @@ import { DEFAULT_BUILD_DIR } from "../../config.js";
2424

2525
export type PackageManager = "pnpm" | "yarn" | "bun" | "npm";
2626

27-
/** Files we inspect on disk to infer the package manager. */
27+
/** Canonical lockfile basename per package manager. */
2828
export const PM_LOCKFILES: Record<PackageManager, string> = {
2929
pnpm: "pnpm-lock.yaml",
3030
yarn: "yarn.lock",
3131
bun: "bun.lockb",
3232
npm: "package-lock.json",
3333
};
3434

35+
// Bun 1.2+ defaults to a TEXT lockfile (`bun.lock`); older bun wrote the binary
36+
// `bun.lockb`. Detect either so modern bun projects aren't mis-detected as npm.
37+
const BUN_TEXT_LOCKFILE = "bun.lock";
38+
39+
/** Every lockfile basename to probe on disk (some PMs have more than one). */
40+
export const PM_LOCKFILES_ALL: string[] = [...Object.values(PM_LOCKFILES), BUN_TEXT_LOCKFILE];
41+
3542
export interface BuildConfig {
3643
/** Binary + args to spawn. */
3744
cmd: string;
@@ -80,7 +87,7 @@ export class BuildDetectError extends Error {
8087
export function detectPackageManager(lockfiles: Set<string>): PackageManager {
8188
if (lockfiles.has(PM_LOCKFILES.pnpm)) return "pnpm";
8289
if (lockfiles.has(PM_LOCKFILES.yarn)) return "yarn";
83-
if (lockfiles.has(PM_LOCKFILES.bun)) return "bun";
90+
if (lockfiles.has(PM_LOCKFILES.bun) || lockfiles.has(BUN_TEXT_LOCKFILE)) return "bun";
8491
return "npm";
8592
}
8693

0 commit comments

Comments
 (0)