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" ;
1717import { Box , Text } from "ink" ;
1818import { existsSync , readFileSync , writeFileSync , appendFileSync } from "node:fs" ;
1919import { resolve } from "node:path" ;
@@ -31,9 +31,12 @@ import { runCommand } from "../../utils/git.js";
3131import { createOptionalGitBaseline } from "../../utils/mod/git-baseline.js" ;
3232import { downloadGitHubTarball , parseGitHubRepoUrl } from "../../utils/mod/source.js" ;
3333import {
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" ;
3740import { VERSION_LABEL } from "../../utils/version.js" ;
3841import { getNetworkLabel } from "../../config.js" ;
3942import { 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 > }
0 commit comments