11import { spawn , spawnSync } from 'child_process' ;
22import { createInterface } from 'readline' ;
3- import { existsSync , mkdirSync , readFileSync , writeFileSync , chmodSync } from 'fs' ;
3+ import { existsSync , mkdirSync , readFileSync , writeFileSync , chmodSync , rmSync , statSync } from 'fs' ;
4+ import { createHash } from 'crypto' ;
45import { homedir } from 'os' ;
5- import { join , dirname } from 'path' ;
6+ import { join , dirname , resolve , basename } from 'path' ;
67import { RegistryClient } from '../../lib/api/registry-client.js' ;
78import { ConfigManager } from '../../utils/config-manager.js' ;
89
910export interface RunOptions {
1011 update ?: boolean ;
12+ local ?: string ; // Path to local .mcpb file
1113}
1214
1315interface McpConfig {
@@ -169,6 +171,33 @@ export function substituteEnvVars(
169171 return result ;
170172}
171173
174+ /**
175+ * Get cache directory for a local bundle.
176+ * Uses hash of absolute path to avoid collisions.
177+ */
178+ export function getLocalCacheDir ( bundlePath : string ) : string {
179+ const absolutePath = resolve ( bundlePath ) ;
180+ const hash = createHash ( 'md5' ) . update ( absolutePath ) . digest ( 'hex' ) . slice ( 0 , 12 ) ;
181+ return join ( homedir ( ) , '.mpak' , 'cache' , '_local' , hash ) ;
182+ }
183+
184+ /**
185+ * Check if local bundle needs re-extraction.
186+ * Returns true if cache doesn't exist or bundle was modified after extraction.
187+ */
188+ export function localBundleNeedsExtract ( bundlePath : string , cacheDir : string ) : boolean {
189+ const metaPath = join ( cacheDir , '.mpak-meta.json' ) ;
190+ if ( ! existsSync ( metaPath ) ) return true ;
191+
192+ try {
193+ const meta = JSON . parse ( readFileSync ( metaPath , 'utf8' ) ) ;
194+ const bundleStat = statSync ( bundlePath ) ;
195+ return bundleStat . mtimeMs > new Date ( meta . extractedAt ) . getTime ( ) ;
196+ } catch {
197+ return true ;
198+ }
199+ }
200+
172201/**
173202 * Prompt user for a config value (interactive terminal input)
174203 */
@@ -295,69 +324,125 @@ function findPythonCommand(): string {
295324}
296325
297326/**
298- * Run a package from the registry
327+ * Run a package from the registry or a local bundle file
299328 */
300329export async function handleRun (
301330 packageSpec : string ,
302331 options : RunOptions = { }
303332) : Promise < void > {
304- const { name, version : requestedVersion } = parsePackageSpec ( packageSpec ) ;
305- const client = new RegistryClient ( ) ;
306- const platform = RegistryClient . detectPlatform ( ) ;
307- const cacheDir = getCacheDir ( name ) ;
308-
309- let needsPull = true ;
310- let cachedMeta = getCacheMetadata ( cacheDir ) ;
311-
312- // Check if we have a cached version
313- if ( cachedMeta && ! options . update ) {
314- if ( requestedVersion ) {
315- // Specific version requested - check if cached version matches
316- needsPull = cachedMeta . version !== requestedVersion ;
317- } else {
318- // Latest requested - use cache (user can --update to refresh)
319- needsPull = false ;
320- }
333+ // Validate that either --local or package spec is provided
334+ if ( ! options . local && ! packageSpec ) {
335+ process . stderr . write ( `=> Error: Either provide a package name or use --local <path>\n` ) ;
336+ process . exit ( 1 ) ;
321337 }
322338
323- if ( needsPull ) {
324- // Fetch download info
325- const downloadInfo = await client . getDownloadInfo ( name , requestedVersion , platform ) ;
326- const bundle = downloadInfo . bundle ;
339+ let cacheDir : string ;
340+ let packageName : string ;
341+
342+ if ( options . local ) {
343+ // === LOCAL BUNDLE MODE ===
344+ const bundlePath = resolve ( options . local ) ;
327345
328- // Check if cached version is already the latest
329- if ( cachedMeta && cachedMeta . version === bundle . version && ! options . update ) {
330- needsPull = false ;
346+ // Validate bundle exists
347+ if ( ! existsSync ( bundlePath ) ) {
348+ process . stderr . write ( `=> Error: Bundle not found: ${ bundlePath } \n` ) ;
349+ process . exit ( 1 ) ;
331350 }
332351
333- if ( needsPull ) {
334- // Download to temp file
335- const tempPath = join ( homedir ( ) , '.mpak' , 'tmp' , `${ Date . now ( ) } .mcpb` ) ;
336- mkdirSync ( dirname ( tempPath ) , { recursive : true } ) ;
352+ // Validate .mcpb extension
353+ if ( ! bundlePath . endsWith ( '.mcpb' ) ) {
354+ process . stderr . write ( `=> Error: Not an MCPB bundle: ${ bundlePath } \n` ) ;
355+ process . exit ( 1 ) ;
356+ }
337357
338- process . stderr . write ( `=> Pulling ${ name } @ ${ bundle . version } ...\n` ) ;
339- await client . downloadBundle ( downloadInfo . url , tempPath ) ;
358+ cacheDir = getLocalCacheDir ( bundlePath ) ;
359+ const needsExtract = options . update || localBundleNeedsExtract ( bundlePath , cacheDir ) ;
340360
341- // Clear old cache and extract
342- const { rmSync } = await import ( 'fs' ) ;
361+ if ( needsExtract ) {
362+ // Clear old extraction
343363 if ( existsSync ( cacheDir ) ) {
344364 rmSync ( cacheDir , { recursive : true , force : true } ) ;
345365 }
346366 mkdirSync ( cacheDir , { recursive : true } ) ;
347367
348- await extractZip ( tempPath , cacheDir ) ;
368+ process . stderr . write ( `=> Extracting ${ basename ( bundlePath ) } ...\n` ) ;
369+ await extractZip ( bundlePath , cacheDir ) ;
370+
371+ // Write local metadata
372+ writeFileSync (
373+ join ( cacheDir , '.mpak-meta.json' ) ,
374+ JSON . stringify ( {
375+ localPath : bundlePath ,
376+ extractedAt : new Date ( ) . toISOString ( ) ,
377+ } )
378+ ) ;
379+ }
349380
350- // Write metadata
351- writeCacheMetadata ( cacheDir , {
352- version : bundle . version ,
353- pulledAt : new Date ( ) . toISOString ( ) ,
354- platform : bundle . platform ,
355- } ) ;
381+ // Read manifest to get package name for config lookup
382+ const manifest = readManifest ( cacheDir ) ;
383+ packageName = manifest . name ;
384+ process . stderr . write ( `=> Running ${ packageName } (local)\n` ) ;
385+
386+ } else {
387+ // === REGISTRY MODE ===
388+ const { name, version : requestedVersion } = parsePackageSpec ( packageSpec ) ;
389+ packageName = name ;
390+ const client = new RegistryClient ( ) ;
391+ const platform = RegistryClient . detectPlatform ( ) ;
392+ cacheDir = getCacheDir ( name ) ;
393+
394+ let needsPull = true ;
395+ let cachedMeta = getCacheMetadata ( cacheDir ) ;
396+
397+ // Check if we have a cached version
398+ if ( cachedMeta && ! options . update ) {
399+ if ( requestedVersion ) {
400+ // Specific version requested - check if cached version matches
401+ needsPull = cachedMeta . version !== requestedVersion ;
402+ } else {
403+ // Latest requested - use cache (user can --update to refresh)
404+ needsPull = false ;
405+ }
406+ }
407+
408+ if ( needsPull ) {
409+ // Fetch download info
410+ const downloadInfo = await client . getDownloadInfo ( name , requestedVersion , platform ) ;
411+ const bundle = downloadInfo . bundle ;
356412
357- // Cleanup temp file
358- rmSync ( tempPath , { force : true } ) ;
413+ // Check if cached version is already the latest
414+ if ( cachedMeta && cachedMeta . version === bundle . version && ! options . update ) {
415+ needsPull = false ;
416+ }
359417
360- process . stderr . write ( `=> Cached ${ name } @${ bundle . version } \n` ) ;
418+ if ( needsPull ) {
419+ // Download to temp file
420+ const tempPath = join ( homedir ( ) , '.mpak' , 'tmp' , `${ Date . now ( ) } .mcpb` ) ;
421+ mkdirSync ( dirname ( tempPath ) , { recursive : true } ) ;
422+
423+ process . stderr . write ( `=> Pulling ${ name } @${ bundle . version } ...\n` ) ;
424+ await client . downloadBundle ( downloadInfo . url , tempPath ) ;
425+
426+ // Clear old cache and extract
427+ if ( existsSync ( cacheDir ) ) {
428+ rmSync ( cacheDir , { recursive : true , force : true } ) ;
429+ }
430+ mkdirSync ( cacheDir , { recursive : true } ) ;
431+
432+ await extractZip ( tempPath , cacheDir ) ;
433+
434+ // Write metadata
435+ writeCacheMetadata ( cacheDir , {
436+ version : bundle . version ,
437+ pulledAt : new Date ( ) . toISOString ( ) ,
438+ platform : bundle . platform ,
439+ } ) ;
440+
441+ // Cleanup temp file
442+ rmSync ( tempPath , { force : true } ) ;
443+
444+ process . stderr . write ( `=> Cached ${ name } @${ bundle . version } \n` ) ;
445+ }
361446 }
362447 }
363448
@@ -369,7 +454,7 @@ export async function handleRun(
369454 let userConfigValues : Record < string , string > = { } ;
370455 if ( manifest . user_config && Object . keys ( manifest . user_config ) . length > 0 ) {
371456 const configManager = new ConfigManager ( ) ;
372- userConfigValues = await gatherUserConfigValues ( name , manifest . user_config , configManager ) ;
457+ userConfigValues = await gatherUserConfigValues ( packageName , manifest . user_config , configManager ) ;
373458 }
374459
375460 // Substitute user_config placeholders in env vars
0 commit comments