1- import { execFileSync } from "node:child_process" ;
1+ import { execFile , execFileSync } from "node:child_process" ;
22import crypto from "node:crypto" ;
33import fs from "node:fs/promises" ;
44import http from "node:http" ;
55import os from "node:os" ;
66import path from "node:path" ;
7+ import { promisify } from "node:util" ;
78import { afterEach , describe , expect , it } from "vitest" ;
89import { installPluginFromNpmSpec } from "./install.js" ;
910
1011type PackedVersion = {
1112 archive : Buffer ;
1213 integrity : string ;
14+ peerDependencies ?: Record < string , string > ;
15+ peerDependenciesMeta ?: Record < string , { optional ?: boolean } > ;
1316 shasum : string ;
1417 tarballName : string ;
1518 version : string ;
@@ -19,6 +22,7 @@ const tempDirs: string[] = [];
1922const servers : http . Server [ ] = [ ] ;
2023const envKeys = [ "NPM_CONFIG_REGISTRY" , "npm_config_registry" ] as const ;
2124const originalEnv = Object . fromEntries ( envKeys . map ( ( key ) => [ key , process . env [ key ] ] ) ) ;
25+ const execFileAsync = promisify ( execFile ) ;
2226
2327afterEach ( async ( ) => {
2428 for ( const server of servers . splice ( 0 ) ) {
@@ -43,11 +47,19 @@ async function makeTempDir(label: string): Promise<string> {
4347
4448async function packPlugin ( params : {
4549 packageName : string ;
50+ peerDependencies ?: Record < string , string > ;
51+ peerDependenciesMeta ?: Record < string , { optional ?: boolean } > ;
4652 pluginId : string ;
4753 version : string ;
4854 rootDir : string ;
4955} ) : Promise < PackedVersion > {
50- const packageDir = path . join ( params . rootDir , `package-${ params . version } ` ) ;
56+ const packageDir = path . join ( params . rootDir , `package-${ params . packageName } -${ params . version } ` ) ;
57+ const peerDependenciesMeta = params . peerDependencies
58+ ? ( params . peerDependenciesMeta ??
59+ Object . fromEntries (
60+ Object . keys ( params . peerDependencies ) . map ( ( name ) => [ name , { optional : true } ] ) ,
61+ ) )
62+ : undefined ;
5163 await fs . mkdir ( path . join ( packageDir , "dist" ) , { recursive : true } ) ;
5264 await fs . writeFile (
5365 path . join ( packageDir , "package.json" ) ,
@@ -57,6 +69,12 @@ async function packPlugin(params: {
5769 version : params . version ,
5870 type : "module" ,
5971 openclaw : { extensions : [ "./dist/index.js" ] } ,
72+ ...( params . peerDependencies
73+ ? {
74+ peerDependencies : params . peerDependencies ,
75+ ...( peerDependenciesMeta ? { peerDependenciesMeta } : { } ) ,
76+ }
77+ : { } ) ,
6078 } ,
6179 null ,
6280 2 ,
@@ -92,12 +110,90 @@ async function packPlugin(params: {
92110 return {
93111 archive,
94112 integrity : `sha512-${ crypto . createHash ( "sha512" ) . update ( archive ) . digest ( "base64" ) } ` ,
113+ ...( params . peerDependencies ? { peerDependencies : params . peerDependencies } : { } ) ,
114+ ...( peerDependenciesMeta ? { peerDependenciesMeta } : { } ) ,
95115 shasum : crypto . createHash ( "sha1" ) . update ( archive ) . digest ( "hex" ) ,
96116 tarballName,
97117 version : params . version ,
98118 } ;
99119}
100120
121+ async function startStaticRegistry (
122+ packages : Array < {
123+ latest : string ;
124+ packageName : string ;
125+ versions : PackedVersion [ ] ;
126+ } > ,
127+ ) : Promise < string > {
128+ const packageEntries = packages . map ( ( pkg ) => ( {
129+ ...pkg ,
130+ encodedPackageName : encodeURIComponent ( pkg . packageName ) . replace ( "%40" , "@" ) ,
131+ versionsByVersion : new Map ( pkg . versions . map ( ( entry ) => [ entry . version , entry ] ) ) ,
132+ } ) ) ;
133+ const server = http . createServer ( ( request , response ) => {
134+ const url = new URL ( request . url ?? "/" , "http://127.0.0.1" ) ;
135+ const baseUrl = `http://127.0.0.1:${ ( server . address ( ) as { port : number } ) . port } ` ;
136+ if ( request . method !== "GET" ) {
137+ response . writeHead ( 405 , { "content-type" : "text/plain" } ) ;
138+ response . end ( "method not allowed" ) ;
139+ return ;
140+ }
141+
142+ for ( const pkg of packageEntries ) {
143+ if ( url . pathname === `/${ pkg . encodedPackageName } ` ) {
144+ response . writeHead ( 200 , { "content-type" : "application/json" } ) ;
145+ response . end (
146+ `${ JSON . stringify ( {
147+ name : pkg . packageName ,
148+ "dist-tags" : { latest : pkg . latest } ,
149+ versions : Object . fromEntries (
150+ [ ...pkg . versionsByVersion . entries ( ) ] . map ( ( [ version , entry ] ) => [
151+ version ,
152+ {
153+ name : pkg . packageName ,
154+ version,
155+ ...( entry . peerDependencies ? { peerDependencies : entry . peerDependencies } : { } ) ,
156+ ...( entry . peerDependenciesMeta
157+ ? { peerDependenciesMeta : entry . peerDependenciesMeta }
158+ : { } ) ,
159+ dist : {
160+ integrity : entry . integrity ,
161+ shasum : entry . shasum ,
162+ tarball : `${ baseUrl } /${ pkg . encodedPackageName } /-/${ entry . tarballName } ` ,
163+ } ,
164+ } ,
165+ ] ) ,
166+ ) ,
167+ } ) } \n`,
168+ ) ;
169+ return ;
170+ }
171+
172+ const tarballPrefix = `/${ pkg . encodedPackageName } /-/` ;
173+ if ( url . pathname . startsWith ( tarballPrefix ) ) {
174+ const entry = [ ...pkg . versionsByVersion . values ( ) ] . find ( ( candidate ) =>
175+ url . pathname . endsWith ( `/${ candidate . tarballName } ` ) ,
176+ ) ;
177+ if ( entry ) {
178+ response . writeHead ( 200 , {
179+ "content-length" : String ( entry . archive . length ) ,
180+ "content-type" : "application/octet-stream" ,
181+ } ) ;
182+ response . end ( entry . archive ) ;
183+ return ;
184+ }
185+ }
186+ }
187+
188+ response . writeHead ( 404 , { "content-type" : "text/plain" } ) ;
189+ response . end ( `not found: ${ url . pathname } ` ) ;
190+ } ) ;
191+
192+ await new Promise < void > ( ( resolve ) => server . listen ( 0 , "127.0.0.1" , resolve ) ) ;
193+ servers . push ( server ) ;
194+ return `http://127.0.0.1:${ ( server . address ( ) as { port : number } ) . port } ` ;
195+ }
196+
101197async function startMutableRegistry ( params : {
102198 packageName : string ;
103199 initialLatest : string ;
@@ -135,6 +231,10 @@ async function startMutableRegistry(params: {
135231 {
136232 name : params . packageName ,
137233 version,
234+ ...( entry . peerDependencies ? { peerDependencies : entry . peerDependencies } : { } ) ,
235+ ...( entry . peerDependenciesMeta
236+ ? { peerDependenciesMeta : entry . peerDependenciesMeta }
237+ : { } ) ,
138238 dist : {
139239 integrity : entry . integrity ,
140240 shasum : entry . shasum ,
@@ -173,6 +273,119 @@ async function startMutableRegistry(params: {
173273}
174274
175275describe ( "installPluginFromNpmSpec e2e" , ( ) => {
276+ it ( "repairs npm-installed root openclaw for required plugin peers" , async ( ) => {
277+ const rootDir = await makeTempDir ( "npm-plugin-required-peer-e2e" ) ;
278+ const npmRoot = path . join ( rootDir , "managed-npm" ) ;
279+ const packageName = `required-peer-plugin-${ crypto . randomUUID ( ) . replace ( / - / g, "" ) . slice ( 0 , 12 ) } ` ;
280+ const versions = [
281+ await packPlugin ( {
282+ packageName,
283+ peerDependencies : { openclaw : ">=2026.0.0" } ,
284+ peerDependenciesMeta : { } ,
285+ pluginId : packageName ,
286+ version : "1.0.0" ,
287+ rootDir,
288+ } ) ,
289+ ] ;
290+ const openClawVersions = [
291+ await packPlugin ( {
292+ packageName : "openclaw" ,
293+ pluginId : "registry-openclaw-copy" ,
294+ version : "2026.0.0" ,
295+ rootDir,
296+ } ) ,
297+ ] ;
298+ const registry = await startStaticRegistry ( [
299+ { packageName, latest : "1.0.0" , versions } ,
300+ { packageName : "openclaw" , latest : "2026.0.0" , versions : openClawVersions } ,
301+ ] ) ;
302+ process . env . NPM_CONFIG_REGISTRY = registry ;
303+ process . env . npm_config_registry = registry ;
304+
305+ const rawNpmRoot = path . join ( rootDir , "raw-managed-npm" ) ;
306+ await fs . mkdir ( rawNpmRoot , { recursive : true } ) ;
307+ await fs . writeFile (
308+ path . join ( rawNpmRoot , "package.json" ) ,
309+ `${ JSON . stringify (
310+ {
311+ private : true ,
312+ dependencies : { [ packageName ] : "1.0.0" } ,
313+ } ,
314+ null ,
315+ 2 ,
316+ ) } \n`,
317+ "utf8" ,
318+ ) ;
319+ await execFileAsync (
320+ "npm" ,
321+ [ "install" , "--ignore-scripts" , "--no-audit" , "--no-fund" , "--loglevel=error" ] ,
322+ {
323+ cwd : rawNpmRoot ,
324+ encoding : "utf8" ,
325+ env : {
326+ ...process . env ,
327+ NPM_CONFIG_REGISTRY : registry ,
328+ npm_config_registry : registry ,
329+ } ,
330+ timeout : 120_000 ,
331+ } ,
332+ ) ;
333+ const rawManifest = JSON . parse (
334+ await fs . readFile ( path . join ( rawNpmRoot , "package.json" ) , "utf8" ) ,
335+ ) as {
336+ dependencies ?: Record < string , string > ;
337+ } ;
338+ expect ( rawManifest . dependencies ) . toEqual ( { [ packageName ] : "1.0.0" } ) ;
339+ const rawLock = JSON . parse (
340+ await fs . readFile ( path . join ( rawNpmRoot , "package-lock.json" ) , "utf8" ) ,
341+ ) as {
342+ packages ?: Record < string , unknown > ;
343+ } ;
344+ expect ( rawLock . packages ?. [ "node_modules/openclaw" ] ) . toMatchObject ( {
345+ peer : true ,
346+ version : "2026.0.0" ,
347+ } ) ;
348+ await expect (
349+ fs
350+ . lstat ( path . join ( rawNpmRoot , "node_modules" , "openclaw" ) )
351+ . then ( ( stat ) => stat . isDirectory ( ) ) ,
352+ ) . resolves . toBe ( true ) ;
353+
354+ const result = await installPluginFromNpmSpec ( {
355+ spec : `${ packageName } @1.0.0` ,
356+ npmDir : npmRoot ,
357+ trustedManagedNpmRoot : true ,
358+ logger : { info : ( ) => { } , warn : ( ) => { } } ,
359+ timeoutMs : 120_000 ,
360+ } ) ;
361+
362+ if ( ! result . ok ) {
363+ throw new Error ( result . error ) ;
364+ }
365+
366+ const manifest = JSON . parse ( await fs . readFile ( path . join ( npmRoot , "package.json" ) , "utf8" ) ) as {
367+ dependencies ?: Record < string , string > ;
368+ } ;
369+ expect ( manifest . dependencies ) . toEqual ( { [ packageName ] : "1.0.0" } ) ;
370+
371+ const lock = JSON . parse ( await fs . readFile ( path . join ( npmRoot , "package-lock.json" ) , "utf8" ) ) as {
372+ packages ?: Record < string , unknown > ;
373+ } ;
374+ expect ( lock . packages ?. [ "" ] as { dependencies ?: Record < string , string > } ) . toMatchObject ( {
375+ dependencies : { [ packageName ] : "1.0.0" } ,
376+ } ) ;
377+ expect ( lock . packages ?. [ "node_modules/openclaw" ] ) . toBeUndefined ( ) ;
378+
379+ await expect ( fs . lstat ( path . join ( npmRoot , "node_modules" , "openclaw" ) ) ) . rejects . toMatchObject ( {
380+ code : "ENOENT" ,
381+ } ) ;
382+ await expect (
383+ fs
384+ . lstat ( path . join ( result . targetDir , "node_modules" , "openclaw" ) )
385+ . then ( ( stat ) => stat . isSymbolicLink ( ) ) ,
386+ ) . resolves . toBe ( true ) ;
387+ } ) ;
388+
176389 it ( "pins a mutable npm tag to the version resolved before install" , async ( ) => {
177390 const rootDir = await makeTempDir ( "npm-plugin-e2e" ) ;
178391 const npmRoot = path . join ( rootDir , "managed-npm" ) ;
0 commit comments