@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
33import { join } from "path" ;
44import { tmpdir } from "os" ;
55import { env } from "process" ;
6- import { applyPluginPlanners , runPluginDoctorChecks , tryRunPluginCommand } from "../src/plugins/runtime.js" ;
6+ import { applyPluginPlanners , loadEnabledPlugins , runPluginDoctorChecks , tryRunPluginCommand } from "../src/plugins/runtime.js" ;
77import { saveConfig } from "../src/state/config.js" ;
88import type { ScanResult } from "../src/scanner/index.js" ;
99import type { SetupStep } from "../src/ai/planner.js" ;
@@ -96,4 +96,95 @@ describe("plugin runtime", () => {
9696 expect ( commandHandled ) . toBe ( true ) ;
9797 expect ( logs . join ( "\n" ) ) . toContain ( "team command one" ) ;
9898 } ) ;
99+
100+ it ( "loads plugins that expose an exports object entrypoint" , async ( ) => {
101+ const pluginDir = join ( tempDir , ".setupr" , "plugins" , "exported" ) ;
102+ await mkdir ( join ( pluginDir , "build" ) , { recursive : true } ) ;
103+ await writeFile ( join ( pluginDir , "package.json" ) , JSON . stringify ( {
104+ name : "setupr-plugin-exported" ,
105+ version : "0.1.0" ,
106+ type : "module" ,
107+ exports : { "." : { import : "./build/plugin.js" } } ,
108+ setupr : { apiVersion : "1" } ,
109+ } ) ) ;
110+ await writeFile ( join ( pluginDir , "build" , "plugin.js" ) , `
111+ export default {
112+ name: "setupr-plugin-exported",
113+ apiVersion: "1",
114+ commands: [{ name: "exported-ok", summary: "OK", run(context) { context.log("exported command"); } }],
115+ };
116+ ` ) ;
117+ await saveConfig ( configWithPlugins ( [
118+ { name : "setupr-plugin-exported" , version : "0.1.0" , enabled : true , source : ".setupr/plugins/exported" } ,
119+ ] ) ) ;
120+
121+ const loaded = await loadEnabledPlugins ( tempDir ) ;
122+
123+ expect ( loaded . diagnostics ) . toContainEqual ( expect . objectContaining ( { name : "setupr-plugin-exported" , status : "loaded" } ) ) ;
124+ expect ( loaded . plugins . map ( ( item ) => item . plugin . name ) ) . toContain ( "setupr-plugin-exported" ) ;
125+ } ) ;
126+
127+ it ( "rejects plugin entrypoints that escape the plugin directory" , async ( ) => {
128+ const pluginDir = join ( tempDir , ".setupr" , "plugins" , "escape" ) ;
129+ await mkdir ( pluginDir , { recursive : true } ) ;
130+ await writeFile ( join ( tempDir , ".setupr" , "plugins" , "evil.js" ) , "export default { name: 'evil', apiVersion: '1' };\n" ) ;
131+ await writeFile ( join ( pluginDir , "package.json" ) , JSON . stringify ( {
132+ name : "setupr-plugin-escape" ,
133+ version : "0.1.0" ,
134+ type : "module" ,
135+ main : "../evil.js" ,
136+ setupr : { apiVersion : "1" } ,
137+ } ) ) ;
138+ await saveConfig ( configWithPlugins ( [
139+ { name : "setupr-plugin-escape" , version : "0.1.0" , enabled : true , source : ".setupr/plugins/escape" } ,
140+ ] ) ) ;
141+
142+ const loaded = await loadEnabledPlugins ( tempDir ) ;
143+
144+ expect ( loaded . plugins ) . toHaveLength ( 0 ) ;
145+ expect ( loaded . diagnostics [ 0 ] ) . toMatchObject ( { name : "setupr-plugin-escape" , status : "failed" } ) ;
146+ expect ( loaded . diagnostics [ 0 ] . message ) . toContain ( "escapes plugin directory" ) ;
147+ } ) ;
148+
149+ it ( "turns throwing plugin commands into structured plugin errors" , async ( ) => {
150+ const pluginDir = join ( tempDir , ".setupr" , "plugins" , "thrower" ) ;
151+ await mkdir ( pluginDir , { recursive : true } ) ;
152+ await writeFile ( join ( pluginDir , "package.json" ) , JSON . stringify ( {
153+ name : "setupr-plugin-thrower" ,
154+ version : "0.1.0" ,
155+ type : "module" ,
156+ main : "index.js" ,
157+ setupr : { apiVersion : "1" } ,
158+ } ) ) ;
159+ await writeFile ( join ( pluginDir , "index.js" ) , `
160+ export default {
161+ name: "setupr-plugin-thrower",
162+ apiVersion: "1",
163+ commands: [{ name: "explode", summary: "Fail", run() { throw new Error("plugin boom"); } }],
164+ };
165+ ` ) ;
166+ await saveConfig ( configWithPlugins ( [
167+ { name : "setupr-plugin-thrower" , version : "0.1.0" , enabled : true , source : ".setupr/plugins/thrower" } ,
168+ ] ) ) ;
169+
170+ await expect ( tryRunPluginCommand ( { cwd : tempDir , command : "explode" , args : [ ] } ) )
171+ . rejects . toMatchObject ( { code : "PLUGIN_LOAD_FAILED" , details : expect . arrayContaining ( [ "Plugin: setupr-plugin-thrower" , "plugin boom" ] ) } ) ;
172+ } ) ;
99173} ) ;
174+
175+ function configWithPlugins ( plugins : Array < { name : string ; version : string ; enabled : boolean ; source : string } > ) {
176+ return {
177+ ai : { enabled : true , timeoutMs : 30000 , maxRetries : 3 , retryDelayMs : 1000 , rateLimitPerMinute : 20 } ,
178+ preferences : {
179+ theme : "dark" ,
180+ confirmBeforeInstall : true ,
181+ autoUpdate : false ,
182+ telemetry : false ,
183+ defaultBranch : "main" ,
184+ commitConvention : "conventional" ,
185+ ciPlatform : "auto" ,
186+ } ,
187+ plugins,
188+ remembered : { } ,
189+ } ;
190+ }
0 commit comments