@@ -168,7 +168,10 @@ describe("runGatewayUpdate", () => {
168168 buildCommand : string ;
169169 uiBuildCommand : string ;
170170 doctorCommand : string ;
171- onCommand ?: ( key : string ) => Promise < CommandResponse | undefined > | CommandResponse | undefined ;
171+ onCommand ?: (
172+ key : string ,
173+ options ?: { env ?: NodeJS . ProcessEnv ; cwd ?: string ; timeoutMs ?: number } ,
174+ ) => Promise < CommandResponse | undefined > | CommandResponse | undefined ;
172175 } ) {
173176 const calls : string [ ] = [ ] ;
174177 const responses = {
@@ -179,10 +182,13 @@ describe("runGatewayUpdate", () => {
179182 [ params . doctorCommand ] : { stdout : "" } ,
180183 } satisfies Record < string , CommandResponse > ;
181184
182- const runCommand = async ( argv : string [ ] ) => {
185+ const runCommand = async (
186+ argv : string [ ] ,
187+ options ?: { env ?: NodeJS . ProcessEnv ; cwd ?: string ; timeoutMs ?: number } ,
188+ ) => {
183189 const key = argv . join ( " " ) ;
184190 calls . push ( key ) ;
185- const override = await params . onCommand ?.( key ) ;
191+ const override = await params . onCommand ?.( key , options ) ;
186192 if ( override ) {
187193 return toCommandResult ( override ) ;
188194 }
@@ -201,7 +207,7 @@ describe("runGatewayUpdate", () => {
201207 argv : string [ ] ,
202208 options ?: { env ?: NodeJS . ProcessEnv ; cwd ?: string ; timeoutMs ?: number } ,
203209 ) => Promise < CommandResult > ,
204- options ?: { channel ?: "stable" | "beta" ; tag ?: string ; cwd ?: string } ,
210+ options ?: { channel ?: "stable" | "beta" | "dev" ; tag ?: string ; cwd ?: string } ,
205211 ) {
206212 return runGatewayUpdate ( {
207213 cwd : options ?. cwd ?? tempDir ,
@@ -214,7 +220,7 @@ describe("runGatewayUpdate", () => {
214220
215221 async function runWithRunner (
216222 runner : ( argv : string [ ] ) => Promise < CommandResult > ,
217- options ?: { channel ?: "stable" | "beta" ; tag ?: string ; cwd ?: string } ,
223+ options ?: { channel ?: "stable" | "beta" | "dev" ; tag ?: string ; cwd ?: string } ,
218224 ) {
219225 return runWithCommand ( runner , options ) ;
220226 }
@@ -376,39 +382,44 @@ describe("runGatewayUpdate", () => {
376382 expect ( calls ) . not . toContain ( `git -C ${ tempDir } checkout --detach ${ betaTag } ` ) ;
377383 } ) ;
378384
379- it ( "falls back to npm when pnpm is unavailable for git installs " , async ( ) => {
385+ it ( "bootstraps pnpm via npm when pnpm and corepack are unavailable " , async ( ) => {
380386 await setupGitPackageManagerFixture ( ) ;
381387 const stableTag = "v1.0.1-1" ;
382388 const { calls, runCommand } = createGitInstallRunner ( {
383389 stableTag,
384- installCommand : "npm install --no-package-lock --legacy-peer-deps " ,
385- buildCommand : "npm run build" ,
386- uiBuildCommand : "npm run ui:build" ,
390+ installCommand : "pnpm install" ,
391+ buildCommand : "pnpm build" ,
392+ uiBuildCommand : "pnpm ui:build" ,
387393 doctorCommand : `${ process . execPath } ${ path . join ( tempDir , "openclaw.mjs" ) } doctor --non-interactive` ,
388- onCommand : ( key ) => {
394+ onCommand : ( key , options ) => {
389395 if ( key === "pnpm --version" ) {
396+ const envPath = options ?. env ?. PATH ?? options ?. env ?. Path ?? "" ;
397+ if ( envPath . includes ( "openclaw-update-pnpm-" ) ) {
398+ return { stdout : "10.0.0" } ;
399+ }
390400 throw new Error ( "spawn pnpm ENOENT" ) ;
391401 }
402+ if ( key === "corepack --version" ) {
403+ throw new Error ( "spawn corepack ENOENT" ) ;
404+ }
392405 if ( key === "npm --version" ) {
393406 return { stdout : "10.0.0" } ;
394407 }
408+ if ( key . startsWith ( "npm install --prefix " ) && key . endsWith ( " pnpm@10" ) ) {
409+ return { stdout : "added 1 package" } ;
410+ }
395411 return undefined ;
396412 } ,
397413 } ) ;
398414
399- const result = await runGatewayUpdate ( {
400- cwd : tempDir ,
401- runCommand : async ( argv , _options ) => runCommand ( argv ) ,
402- timeoutMs : 5000 ,
403- channel : "stable" ,
404- } ) ;
415+ const result = await runWithCommand ( runCommand , { channel : "stable" } ) ;
405416
406417 expect ( result . status ) . toBe ( "ok" ) ;
407418 expect ( calls ) . toContain ( "pnpm --version" ) ;
408- expect ( calls ) . toContain ( "corepack --version" ) ;
419+ expect ( calls . some ( ( call ) => call . startsWith ( "npm install --prefix " ) ) ) . toBe ( true ) ;
409420 expect ( calls ) . toContain ( "npm --version" ) ;
410- expect ( calls ) . toContain ( "npm install --no-package-lock --legacy-peer-deps " ) ;
411- expect ( calls ) . not . toContain ( "pnpm install" ) ;
421+ expect ( calls ) . toContain ( "pnpm install" ) ;
422+ expect ( calls ) . not . toContain ( "npm install --no-package-lock --legacy-peer-deps " ) ;
412423 } ) ;
413424
414425 it ( "bootstraps pnpm via corepack when pnpm is missing" , async ( ) => {
@@ -452,6 +463,116 @@ describe("runGatewayUpdate", () => {
452463 expect ( calls ) . not . toContain ( "npm install --no-package-lock --legacy-peer-deps" ) ;
453464 } ) ;
454465
466+ it ( "uses npm-bootstrapped pnpm for dev preflight when pnpm and corepack are missing" , async ( ) => {
467+ await setupGitPackageManagerFixture ( ) ;
468+ const calls : string [ ] = [ ] ;
469+ const pnpmEnvPaths : string [ ] = [ ] ;
470+ const upstreamSha = "upstream123" ;
471+ const doctorNodePath = await resolveStableNodePath ( process . execPath ) ;
472+ const doctorCommand = `${ doctorNodePath } ${ path . join ( tempDir , "openclaw.mjs" ) } doctor --non-interactive --fix` ;
473+
474+ const runCommand = async (
475+ argv : string [ ] ,
476+ options ?: { env ?: NodeJS . ProcessEnv ; cwd ?: string ; timeoutMs ?: number } ,
477+ ) => {
478+ const key = argv . join ( " " ) ;
479+ calls . push ( key ) ;
480+
481+ if ( key === `git -C ${ tempDir } rev-parse --show-toplevel` ) {
482+ return { stdout : tempDir , stderr : "" , code : 0 } ;
483+ }
484+ if ( key === `git -C ${ tempDir } rev-parse HEAD` ) {
485+ return { stdout : "abc123" , stderr : "" , code : 0 } ;
486+ }
487+ if ( key === `git -C ${ tempDir } rev-parse --abbrev-ref HEAD` ) {
488+ return { stdout : "main" , stderr : "" , code : 0 } ;
489+ }
490+ if ( key === `git -C ${ tempDir } status --porcelain -- :!dist/control-ui/` ) {
491+ return { stdout : "" , stderr : "" , code : 0 } ;
492+ }
493+ if ( key === `git -C ${ tempDir } rev-parse --abbrev-ref --symbolic-full-name @{upstream}` ) {
494+ return { stdout : "origin/main" , stderr : "" , code : 0 } ;
495+ }
496+ if ( key === `git -C ${ tempDir } fetch --all --prune --tags` ) {
497+ return { stdout : "" , stderr : "" , code : 0 } ;
498+ }
499+ if ( key === `git -C ${ tempDir } rev-parse @{upstream}` ) {
500+ return { stdout : upstreamSha , stderr : "" , code : 0 } ;
501+ }
502+ if ( key === `git -C ${ tempDir } rev-list --max-count=10 ${ upstreamSha } ` ) {
503+ return { stdout : `${ upstreamSha } \n` , stderr : "" , code : 0 } ;
504+ }
505+ if ( key === "pnpm --version" ) {
506+ const envPath = options ?. env ?. PATH ?? options ?. env ?. Path ?? "" ;
507+ if ( envPath . includes ( "openclaw-update-pnpm-" ) ) {
508+ pnpmEnvPaths . push ( envPath ) ;
509+ return { stdout : "10.0.0" , stderr : "" , code : 0 } ;
510+ }
511+ throw new Error ( "spawn pnpm ENOENT" ) ;
512+ }
513+ if ( key === "corepack --version" ) {
514+ throw new Error ( "spawn corepack ENOENT" ) ;
515+ }
516+ if ( key === "npm --version" ) {
517+ return { stdout : "10.0.0" , stderr : "" , code : 0 } ;
518+ }
519+ if ( key . startsWith ( "npm install --prefix " ) && key . endsWith ( " pnpm@10" ) ) {
520+ return { stdout : "added 1 package" , stderr : "" , code : 0 } ;
521+ }
522+ if (
523+ key . startsWith ( `git -C ${ tempDir } worktree add --detach /tmp/openclaw-update-preflight-` ) &&
524+ key . endsWith ( ` /worktree ${ upstreamSha } ` )
525+ ) {
526+ return { stdout : `HEAD is now at ${ upstreamSha } ` , stderr : "" , code : 0 } ;
527+ }
528+ if (
529+ key . startsWith ( "git -C /tmp/openclaw-update-preflight-" ) &&
530+ key . includes ( "/worktree checkout --detach " ) &&
531+ key . endsWith ( upstreamSha )
532+ ) {
533+ return { stdout : "" , stderr : "" , code : 0 } ;
534+ }
535+ if ( key === "pnpm install" || key === "pnpm build" || key === "pnpm lint" ) {
536+ const envPath = options ?. env ?. PATH ?? options ?. env ?. Path ?? "" ;
537+ pnpmEnvPaths . push ( envPath ) ;
538+ return { stdout : "" , stderr : "" , code : 0 } ;
539+ }
540+ if (
541+ key . startsWith ( `git -C ${ tempDir } worktree remove --force /tmp/openclaw-update-preflight-` )
542+ ) {
543+ return { stdout : "" , stderr : "" , code : 0 } ;
544+ }
545+ if ( key === `git -C ${ tempDir } worktree prune` ) {
546+ return { stdout : "" , stderr : "" , code : 0 } ;
547+ }
548+ if ( key === `git -C ${ tempDir } rebase ${ upstreamSha } ` ) {
549+ return { stdout : "" , stderr : "" , code : 0 } ;
550+ }
551+ if ( key === "pnpm ui:build" ) {
552+ const envPath = options ?. env ?. PATH ?? options ?. env ?. Path ?? "" ;
553+ pnpmEnvPaths . push ( envPath ) ;
554+ return { stdout : "" , stderr : "" , code : 0 } ;
555+ }
556+ if ( key === doctorCommand ) {
557+ return { stdout : "" , stderr : "" , code : 0 } ;
558+ }
559+ if ( key === `git -C ${ tempDir } rev-parse HEAD` ) {
560+ return { stdout : upstreamSha , stderr : "" , code : 0 } ;
561+ }
562+ return { stdout : "" , stderr : "" , code : 0 } ;
563+ } ;
564+
565+ const result = await runWithCommand ( runCommand , { channel : "dev" } ) ;
566+
567+ expect ( result . status ) . toBe ( "ok" ) ;
568+ expect ( calls . some ( ( call ) => call . startsWith ( "npm install --prefix " ) ) ) . toBe ( true ) ;
569+ expect ( calls ) . toContain ( "pnpm install" ) ;
570+ expect ( calls ) . toContain ( "pnpm build" ) ;
571+ expect ( calls ) . toContain ( "pnpm lint" ) ;
572+ expect ( calls ) . toContain ( "pnpm ui:build" ) ;
573+ expect ( pnpmEnvPaths . some ( ( value ) => value . includes ( "openclaw-update-pnpm-" ) ) ) . toBe ( true ) ;
574+ } ) ;
575+
455576 it ( "skips update when no git root" , async ( ) => {
456577 await fs . writeFile (
457578 path . join ( tempDir , "package.json" ) ,
0 commit comments