mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 00:21:21 +00:00
feat: add local manifest feature for custom playbooks (#204)
- Add comprehensive documentation for local manifest feature - Create example local-manifest.json with custom playbook structure - Implement local manifest reading and merging with official manifests - Add file watcher for hot reload on local manifest changes - Support local filesystem paths (absolute and tilde-prefixed) - Add "Local" badge to distinguish custom playbooks in UI - Expose manifest change event through preload API - Add source field to playbook type for origin tracking
This commit is contained in:
54
docs/examples/local-manifest.json
Normal file
54
docs/examples/local-manifest.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"lastUpdated": "2026-01-17",
|
||||
"playbooks": [
|
||||
{
|
||||
"id": "my-custom-playbook",
|
||||
"title": "My Custom Security Audit",
|
||||
"description": "Internal security audit playbook for our organization",
|
||||
"category": "Custom",
|
||||
"author": "Internal Team",
|
||||
"lastUpdated": "2026-01-17",
|
||||
"path": "/Users/me/.maestro/custom-playbooks/security-audit",
|
||||
"documents": [
|
||||
{
|
||||
"filename": "1_SCAN",
|
||||
"resetOnCompletion": false
|
||||
},
|
||||
{
|
||||
"filename": "2_ANALYZE",
|
||||
"resetOnCompletion": true
|
||||
},
|
||||
{
|
||||
"filename": "3_REPORT",
|
||||
"resetOnCompletion": false
|
||||
}
|
||||
],
|
||||
"loopEnabled": false,
|
||||
"maxLoops": null,
|
||||
"prompt": null,
|
||||
"assets": ["config.yaml", "rules.json"]
|
||||
},
|
||||
{
|
||||
"id": "development-security",
|
||||
"title": "Enhanced Development Security (Override)",
|
||||
"description": "Custom version of the official playbook with our internal modifications",
|
||||
"category": "Development",
|
||||
"author": "Our Team",
|
||||
"lastUpdated": "2026-01-17",
|
||||
"path": "~/maestro-playbooks/dev-security",
|
||||
"documents": [
|
||||
{
|
||||
"filename": "SETUP",
|
||||
"resetOnCompletion": false
|
||||
},
|
||||
{
|
||||
"filename": "SCAN",
|
||||
"resetOnCompletion": true
|
||||
}
|
||||
],
|
||||
"loopEnabled": true,
|
||||
"maxLoops": 3,
|
||||
"prompt": "Custom prompt for our workflow"
|
||||
}
|
||||
]
|
||||
}
|
||||
257
docs/local-manifest.md
Normal file
257
docs/local-manifest.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Local Manifest for Custom Playbooks
|
||||
|
||||
The local manifest feature allows you to extend the Playbook Exchange with custom or work-in-progress playbooks that are stored locally instead of in the public GitHub repository.
|
||||
|
||||
## Overview
|
||||
|
||||
- **File Location:** `<userData>/local-manifest.json` (same directory as `marketplace-cache.json`)
|
||||
- **Format:** Same structure as the official `manifest.json` from GitHub
|
||||
- **Optional:** If the file doesn't exist, Maestro works normally with official playbooks only
|
||||
- **Hot Reload:** Changes to `local-manifest.json` automatically refresh the Playbook Exchange
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Bespoke Playbooks
|
||||
Create organization-specific playbooks that aren't suitable for public sharing:
|
||||
- Internal tools and workflows
|
||||
- Proprietary processes
|
||||
- Environment-specific automation
|
||||
- Company-specific security policies
|
||||
|
||||
### 2. Playbook Development
|
||||
Iterate on new playbooks locally before submitting them to the [Maestro-Playbooks repository](https://github.com/pedramamini/Maestro-Playbooks):
|
||||
- Test playbook structure and documents
|
||||
- Refine prompts and loop behavior
|
||||
- Validate asset bundling
|
||||
- Preview in the UI before publishing
|
||||
|
||||
## How It Works
|
||||
|
||||
### Merge Semantics
|
||||
|
||||
When both official and local manifests exist, they are merged by `id`:
|
||||
|
||||
1. **Override:** Local playbooks with the same `id` as official ones **override** the official version
|
||||
2. **Append:** Local playbooks with unique `id`s are **added** to the catalog
|
||||
3. **Source Tagging:** All playbooks are tagged with `source: 'official' | 'local'` for UI distinction
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Official playbooks: [A, B, C]
|
||||
Local playbooks: [B_custom, D]
|
||||
Merged result: [A, B_custom, C, D]
|
||||
↑ override ↑ append
|
||||
```
|
||||
|
||||
### Path Resolution
|
||||
|
||||
Local playbooks support local filesystem paths:
|
||||
|
||||
- **Absolute paths:** `/Users/me/.maestro/custom-playbooks/security`
|
||||
- **Tilde paths:** `~/maestro-playbooks/security`
|
||||
- **Import behavior:** Files are copied from the local path instead of fetched from GitHub
|
||||
|
||||
## Schema
|
||||
|
||||
The local manifest uses the exact same structure as the official manifest. See [examples/local-manifest.json](./examples/local-manifest.json) for a complete example.
|
||||
|
||||
### Required Fields
|
||||
|
||||
Each playbook entry must include:
|
||||
|
||||
- `id` - Unique identifier (use same ID as official to override)
|
||||
- `title` - Display name
|
||||
- `description` - Short description for search and tiles
|
||||
- `category` - Top-level category
|
||||
- `author` - Creator name
|
||||
- `lastUpdated` - Date in YYYY-MM-DD format
|
||||
- `path` - **Local filesystem path** or GitHub path
|
||||
- `documents` - Array of document entries with `filename` and `resetOnCompletion`
|
||||
- `loopEnabled` - Whether to loop through documents
|
||||
- `prompt` - Custom prompt or `null` for Maestro default
|
||||
|
||||
### Optional Fields
|
||||
|
||||
- `subcategory` - Nested category
|
||||
- `authorLink` - URL to author's website
|
||||
- `tags` - Searchable keyword array
|
||||
- `maxLoops` - Maximum loop iterations (null for unlimited)
|
||||
- `assets` - Asset files from `assets/` subfolder
|
||||
|
||||
## Creating a Local Playbook
|
||||
|
||||
### Step 1: Create Playbook Files
|
||||
|
||||
Organize your playbook in a local directory:
|
||||
|
||||
```
|
||||
~/my-playbooks/security-audit/
|
||||
├── 1_SCAN.md
|
||||
├── 2_ANALYZE.md
|
||||
├── 3_REPORT.md
|
||||
├── README.md (optional)
|
||||
└── assets/
|
||||
├── config.yaml
|
||||
└── rules.json
|
||||
```
|
||||
|
||||
### Step 2: Create local-manifest.json
|
||||
|
||||
Location: `<userData>/local-manifest.json`
|
||||
|
||||
On macOS: `~/Library/Application Support/Maestro/local-manifest.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"lastUpdated": "2026-01-17",
|
||||
"playbooks": [
|
||||
{
|
||||
"id": "security-audit-internal",
|
||||
"title": "Internal Security Audit",
|
||||
"description": "Custom security audit for our infrastructure",
|
||||
"category": "Security",
|
||||
"author": "Security Team",
|
||||
"lastUpdated": "2026-01-17",
|
||||
"path": "~/my-playbooks/security-audit",
|
||||
"documents": [
|
||||
{ "filename": "1_SCAN", "resetOnCompletion": false },
|
||||
{ "filename": "2_ANALYZE", "resetOnCompletion": true },
|
||||
{ "filename": "3_REPORT", "resetOnCompletion": false }
|
||||
],
|
||||
"loopEnabled": false,
|
||||
"prompt": null,
|
||||
"assets": ["config.yaml", "rules.json"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Open Playbook Exchange
|
||||
|
||||
Your local playbook will appear with a **"Local"** badge, distinguishing it from official playbooks.
|
||||
|
||||
### Step 4: Import and Use
|
||||
|
||||
Import works the same as official playbooks - files are copied from your local path to the Auto Run folder.
|
||||
|
||||
## Hot Reload
|
||||
|
||||
Changes to `local-manifest.json` trigger an automatic refresh:
|
||||
|
||||
1. Edit your local manifest
|
||||
2. Save the file
|
||||
3. The Playbook Exchange automatically reloads (500ms debounce)
|
||||
4. No need to restart Maestro
|
||||
|
||||
This enables rapid iteration during playbook development.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Invalid JSON
|
||||
**Behavior:** Warning logged, empty array used, Maestro continues with official playbooks only
|
||||
|
||||
**Fix:** Validate JSON syntax using a JSON validator
|
||||
|
||||
### Missing Required Fields
|
||||
**Behavior:** Invalid entries are skipped with warnings, valid entries are loaded
|
||||
|
||||
**Fix:** Ensure all playbooks have `id`, `title`, `path`, and `documents`
|
||||
|
||||
### Local Path Doesn't Exist
|
||||
**Behavior:** Clear error message during import, playbook listing works normally
|
||||
|
||||
**Fix:** Verify the `path` field points to an existing directory
|
||||
|
||||
### File Watch Errors
|
||||
**Behavior:** Warning logged, hot reload disabled, normal operation continues
|
||||
|
||||
**Effect:** You'll need to restart Maestro to see manifest changes
|
||||
|
||||
## UI Indicators
|
||||
|
||||
Local playbooks are visually distinguished in the Playbook Exchange:
|
||||
|
||||
- **Badge:** Blue "Local" badge next to category
|
||||
- **Tooltip:** "Custom local playbook" on hover
|
||||
- **Search:** Works across both official and local playbooks
|
||||
- **Categories:** New categories from local playbooks appear in filters
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Testing Before Publishing
|
||||
|
||||
1. Create your playbook files locally
|
||||
2. Add to `local-manifest.json`
|
||||
3. Test import and execution in Maestro
|
||||
4. Refine documents and prompts
|
||||
5. When ready, submit a PR to [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks)
|
||||
6. Remove from local manifest once published
|
||||
|
||||
### Overriding Official Playbooks
|
||||
|
||||
Use the same `id` as the official playbook to test modifications:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "development-security", // Same as official
|
||||
"title": "Dev Security (Custom)",
|
||||
"path": "~/my-version/dev-security",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to:
|
||||
- Add custom documents
|
||||
- Modify prompts
|
||||
- Change loop behavior
|
||||
- Test improvements before contributing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Playbook Doesn't Appear
|
||||
|
||||
1. Check JSON syntax in `local-manifest.json`
|
||||
2. Verify all required fields are present
|
||||
3. Look for warnings in console logs
|
||||
4. Ensure `path` field is set correctly
|
||||
|
||||
### Import Fails
|
||||
|
||||
1. Verify the local `path` exists
|
||||
2. Check file permissions on playbook directory
|
||||
3. Ensure documents have `.md` extension
|
||||
4. Verify assets exist in `assets/` subfolder
|
||||
|
||||
### Hot Reload Not Working
|
||||
|
||||
1. Check console for file watcher errors
|
||||
2. Verify `local-manifest.json` path is correct
|
||||
3. Try restarting Maestro
|
||||
4. Check file system permissions
|
||||
|
||||
## Related Files
|
||||
|
||||
- **Manifest types:** `src/shared/marketplace-types.ts`
|
||||
- **Marketplace handlers:** `src/main/ipc/handlers/marketplace.ts`
|
||||
- **Import logic:** `marketplace:importPlaybook` handler
|
||||
- **UI component:** `src/renderer/components/MarketplaceModal.tsx`
|
||||
- **React hook:** `src/renderer/hooks/batch/useMarketplace.ts`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Local manifest is **not synced** or shared
|
||||
- Paths are validated before file operations
|
||||
- Local-only playbooks remain private
|
||||
- No network requests for local paths
|
||||
- File watching is non-blocking and fails gracefully
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for consideration:
|
||||
|
||||
- JSON schema validation
|
||||
- Visual editor for local manifest
|
||||
- UI controls to manage local playbooks
|
||||
- Relative paths from Auto Run directory
|
||||
- Local manifest templates
|
||||
- Import/export for sharing local manifests
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { ipcMain, App } from 'electron';
|
||||
import fs from 'fs/promises';
|
||||
import fsSync from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import Store from 'electron-store';
|
||||
@@ -20,6 +21,7 @@ import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler';
|
||||
import type {
|
||||
MarketplaceManifest,
|
||||
MarketplaceCache,
|
||||
MarketplacePlaybook,
|
||||
} from '../../../shared/marketplace-types';
|
||||
import {
|
||||
MarketplaceFetchError,
|
||||
@@ -51,6 +53,14 @@ export interface MarketplaceHandlerDependencies {
|
||||
// Module-level reference to settings store (set during registration)
|
||||
let marketplaceSettingsStore: Store<MaestroSettings> | undefined;
|
||||
|
||||
// File watcher for local manifest
|
||||
let localManifestWatcher: fsSync.FSWatcher | undefined;
|
||||
|
||||
// Debounce timer for file changes
|
||||
let watcherDebounceTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const WATCHER_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Get SSH remote configuration by ID from the settings store.
|
||||
* Returns undefined if not found or store not provided.
|
||||
@@ -75,6 +85,160 @@ function getCacheFilePath(app: App): string {
|
||||
return path.join(app.getPath('userData'), 'marketplace-cache.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the local manifest file.
|
||||
* Local manifest allows users to define custom/private playbooks that extend
|
||||
* or override the official marketplace catalog.
|
||||
*/
|
||||
function getLocalManifestPath(app: App): string {
|
||||
return path.join(app.getPath('userData'), 'local-manifest.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a local filesystem path (absolute or tilde-prefixed).
|
||||
* Returns true for paths like:
|
||||
* - /absolute/path
|
||||
* - ~/home/path
|
||||
* - C:\Windows\path (on Windows)
|
||||
* Returns false for GitHub repository paths.
|
||||
*/
|
||||
function isLocalPath(pathStr: string): boolean {
|
||||
// Check for absolute paths
|
||||
if (path.isAbsolute(pathStr)) {
|
||||
return true;
|
||||
}
|
||||
// Check for tilde-prefixed paths
|
||||
if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the local manifest from disk.
|
||||
* Returns null if the file doesn't exist or is invalid.
|
||||
* Logs warnings for invalid JSON but doesn't throw - graceful degradation.
|
||||
*/
|
||||
async function readLocalManifest(app: App): Promise<MarketplaceManifest | null> {
|
||||
const localManifestPath = getLocalManifestPath(app);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(localManifestPath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Validate local manifest structure
|
||||
if (!data.playbooks || !Array.isArray(data.playbooks)) {
|
||||
logger.warn('Invalid local manifest structure: missing playbooks array', LOG_CONTEXT);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`Loaded local manifest with ${data.playbooks.length} playbook(s)`, LOG_CONTEXT);
|
||||
return data as MarketplaceManifest;
|
||||
} catch (error) {
|
||||
// File doesn't exist - this is normal, treat as empty
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.debug('No local manifest found (this is normal)', LOG_CONTEXT);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Invalid JSON or other error - log warning but don't crash
|
||||
logger.warn('Failed to read local manifest, ignoring', LOG_CONTEXT, { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge official and local manifests.
|
||||
*
|
||||
* Merge semantics:
|
||||
* - Playbooks are merged by `id` field
|
||||
* - Local playbooks with same `id` override official ones
|
||||
* - Local-only `id`s are appended to the catalog
|
||||
* - All playbooks are tagged with `source` field for UI distinction
|
||||
*
|
||||
* @param official Official manifest from GitHub (may be null)
|
||||
* @param local Local manifest from filesystem (may be null)
|
||||
* @returns Merged manifest with source tags
|
||||
*/
|
||||
function mergeManifests(
|
||||
official: MarketplaceManifest | null,
|
||||
local: MarketplaceManifest | null
|
||||
): MarketplaceManifest {
|
||||
// If no manifests at all, return empty
|
||||
if (!official && !local) {
|
||||
return {
|
||||
lastUpdated: new Date().toISOString().split('T')[0],
|
||||
playbooks: [],
|
||||
};
|
||||
}
|
||||
|
||||
// If only official exists, tag all as official
|
||||
if (official && !local) {
|
||||
return {
|
||||
...official,
|
||||
playbooks: official.playbooks.map((p) => ({ ...p, source: 'official' as const })),
|
||||
};
|
||||
}
|
||||
|
||||
// If only local exists, tag all as local
|
||||
if (!official && local) {
|
||||
return {
|
||||
...local,
|
||||
playbooks: local.playbooks.map((p) => ({ ...p, source: 'local' as const })),
|
||||
};
|
||||
}
|
||||
|
||||
// Both exist - merge by ID
|
||||
const officialPlaybooks = official!.playbooks;
|
||||
const localPlaybooks = local!.playbooks;
|
||||
|
||||
// Create map of local playbooks by ID for fast lookup
|
||||
const localMap = new Map<string, MarketplacePlaybook>();
|
||||
for (const playbook of localPlaybooks) {
|
||||
if (!playbook.id) {
|
||||
logger.warn('Local playbook missing required "id" field, skipping', LOG_CONTEXT, {
|
||||
title: playbook.title,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Validate required fields
|
||||
if (!playbook.title || !playbook.path || !playbook.documents) {
|
||||
logger.warn(`Local playbook "${playbook.id}" missing required fields, skipping`, LOG_CONTEXT);
|
||||
continue;
|
||||
}
|
||||
localMap.set(playbook.id, { ...playbook, source: 'local' });
|
||||
}
|
||||
|
||||
// Override official playbooks with local matches, tag official ones
|
||||
const mergedPlaybooks = officialPlaybooks.map((official) => {
|
||||
const localOverride = localMap.get(official.id);
|
||||
if (localOverride) {
|
||||
logger.info(`Local playbook "${official.id}" overrides official version`, LOG_CONTEXT);
|
||||
return localOverride;
|
||||
}
|
||||
return { ...official, source: 'official' as const };
|
||||
});
|
||||
|
||||
// Find local-only playbooks (not in official catalog)
|
||||
const officialIds = new Set(officialPlaybooks.map((p) => p.id));
|
||||
const localOnlyPlaybooks = Array.from(localMap.values()).filter(
|
||||
(local) => !officialIds.has(local.id)
|
||||
);
|
||||
|
||||
// Append local-only playbooks
|
||||
const finalPlaybooks = [...mergedPlaybooks, ...localOnlyPlaybooks];
|
||||
|
||||
logger.info(
|
||||
`Merged manifest: ${officialPlaybooks.length} official, ${localPlaybooks.length} local, ${finalPlaybooks.length} total`,
|
||||
LOG_CONTEXT
|
||||
);
|
||||
|
||||
return {
|
||||
lastUpdated: official?.lastUpdated || local?.lastUpdated || new Date().toISOString().split('T')[0],
|
||||
playbooks: finalPlaybooks,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the marketplace cache from disk.
|
||||
* Returns null if cache doesn't exist or is invalid.
|
||||
@@ -170,11 +334,45 @@ async function fetchManifest(): Promise<MarketplaceManifest> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a document from GitHub.
|
||||
* Resolve tilde (~) to user's home directory.
|
||||
*/
|
||||
function resolveTildePath(pathStr: string): string {
|
||||
if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) {
|
||||
const homedir = require('os').homedir();
|
||||
return path.join(homedir, pathStr.slice(2));
|
||||
}
|
||||
return pathStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a document from GitHub or local filesystem.
|
||||
* If playbookPath is a local filesystem path, reads from disk.
|
||||
* Otherwise, fetches from GitHub.
|
||||
*/
|
||||
async function fetchDocument(playbookPath: string, filename: string): Promise<string> {
|
||||
// Check if this is a local path
|
||||
if (isLocalPath(playbookPath)) {
|
||||
const resolvedPath = resolveTildePath(playbookPath);
|
||||
const docPath = path.join(resolvedPath, `${filename}.md`);
|
||||
logger.debug(`Reading local document: ${docPath}`, LOG_CONTEXT);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(docPath, 'utf-8');
|
||||
return content;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new MarketplaceFetchError(`Local document not found: ${docPath}`);
|
||||
}
|
||||
throw new MarketplaceFetchError(
|
||||
`Failed to read local document: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub path - fetch from remote
|
||||
const url = `${GITHUB_RAW_BASE}/${playbookPath}/${filename}.md`;
|
||||
logger.debug(`Fetching document: ${url}`, LOG_CONTEXT);
|
||||
logger.debug(`Fetching document from GitHub: ${url}`, LOG_CONTEXT);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
@@ -201,12 +399,33 @@ async function fetchDocument(playbookPath: string, filename: string): Promise<st
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an asset file from GitHub (from assets/ subfolder).
|
||||
* Fetch an asset file from GitHub or local filesystem (from assets/ subfolder).
|
||||
* Returns the raw content as a Buffer for binary-safe handling.
|
||||
*/
|
||||
async function fetchAsset(playbookPath: string, assetFilename: string): Promise<Buffer> {
|
||||
// Check if this is a local path
|
||||
if (isLocalPath(playbookPath)) {
|
||||
const resolvedPath = resolveTildePath(playbookPath);
|
||||
const assetPath = path.join(resolvedPath, 'assets', assetFilename);
|
||||
logger.debug(`Reading local asset: ${assetPath}`, LOG_CONTEXT);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(assetPath);
|
||||
return content;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new MarketplaceFetchError(`Local asset not found: ${assetPath}`);
|
||||
}
|
||||
throw new MarketplaceFetchError(
|
||||
`Failed to read local asset: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub path - fetch from remote
|
||||
const url = `${GITHUB_RAW_BASE}/${playbookPath}/assets/${assetFilename}`;
|
||||
logger.debug(`Fetching asset: ${url}`, LOG_CONTEXT);
|
||||
logger.debug(`Fetching asset from GitHub: ${url}`, LOG_CONTEXT);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
@@ -234,11 +453,31 @@ async function fetchAsset(playbookPath: string, assetFilename: string): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch README from GitHub.
|
||||
* Fetch README from GitHub or local filesystem.
|
||||
*/
|
||||
async function fetchReadme(playbookPath: string): Promise<string | null> {
|
||||
// Check if this is a local path
|
||||
if (isLocalPath(playbookPath)) {
|
||||
const resolvedPath = resolveTildePath(playbookPath);
|
||||
const readmePath = path.join(resolvedPath, 'README.md');
|
||||
logger.debug(`Reading local README: ${readmePath}`, LOG_CONTEXT);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(readmePath, 'utf-8');
|
||||
return content;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null; // README is optional
|
||||
}
|
||||
// Other errors are non-fatal for README
|
||||
logger.debug(`Local README read failed (non-fatal): ${error}`, LOG_CONTEXT);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub path - fetch from remote
|
||||
const url = `${GITHUB_RAW_BASE}/${playbookPath}/README.md`;
|
||||
logger.debug(`Fetching README: ${url}`, LOG_CONTEXT);
|
||||
logger.debug(`Fetching README from GitHub: ${url}`, LOG_CONTEXT);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
@@ -263,6 +502,76 @@ async function fetchReadme(playbookPath: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup file watcher for local manifest changes.
|
||||
* Enables hot reload during development - changes to local-manifest.json
|
||||
* trigger a manifest refresh event.
|
||||
*/
|
||||
function setupLocalManifestWatcher(app: App): void {
|
||||
const localManifestPath = getLocalManifestPath(app);
|
||||
|
||||
try {
|
||||
// Clean up existing watcher if any
|
||||
if (localManifestWatcher) {
|
||||
localManifestWatcher.close();
|
||||
localManifestWatcher = undefined;
|
||||
}
|
||||
|
||||
// Create new watcher
|
||||
localManifestWatcher = fsSync.watch(
|
||||
localManifestPath,
|
||||
(eventType: string) => {
|
||||
logger.debug(`Local manifest file changed (${eventType}), debouncing refresh...`, LOG_CONTEXT);
|
||||
|
||||
// Clear existing timer
|
||||
if (watcherDebounceTimer) {
|
||||
clearTimeout(watcherDebounceTimer);
|
||||
}
|
||||
|
||||
// Debounce file changes (wait for rapid saves to settle)
|
||||
watcherDebounceTimer = setTimeout(async () => {
|
||||
logger.info('Local manifest changed, broadcasting refresh event', LOG_CONTEXT);
|
||||
|
||||
// Send IPC event to all renderer windows
|
||||
const { BrowserWindow } = require('electron');
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
for (const win of allWindows) {
|
||||
win.webContents.send('marketplace:manifestChanged');
|
||||
}
|
||||
}, WATCHER_DEBOUNCE_MS);
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('Local manifest file watcher initialized', LOG_CONTEXT);
|
||||
} catch (error) {
|
||||
// File might not exist yet - this is normal
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('Failed to setup local manifest watcher (non-fatal)', LOG_CONTEXT, { error });
|
||||
}
|
||||
// Don't throw - watcher failure shouldn't prevent normal operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup file watcher on app shutdown.
|
||||
*/
|
||||
function cleanupLocalManifestWatcher(): void {
|
||||
if (watcherDebounceTimer) {
|
||||
clearTimeout(watcherDebounceTimer);
|
||||
watcherDebounceTimer = undefined;
|
||||
}
|
||||
|
||||
if (localManifestWatcher) {
|
||||
try {
|
||||
localManifestWatcher.close();
|
||||
logger.debug('Local manifest watcher cleaned up', LOG_CONTEXT);
|
||||
} catch (error) {
|
||||
logger.warn('Error closing local manifest watcher', LOG_CONTEXT, { error });
|
||||
}
|
||||
localManifestWatcher = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create handler options with consistent context.
|
||||
*/
|
||||
@@ -285,6 +594,14 @@ export function registerMarketplaceHandlers(deps: MarketplaceHandlerDependencies
|
||||
// Store settings reference for SSH remote lookups
|
||||
marketplaceSettingsStore = settingsStore;
|
||||
|
||||
// Setup hot reload watcher for local manifest
|
||||
setupLocalManifestWatcher(app);
|
||||
|
||||
// Cleanup watcher on app quit
|
||||
app.on('will-quit', () => {
|
||||
cleanupLocalManifestWatcher();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// marketplace:getManifest - Get manifest (from cache if valid, else fetch)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -293,24 +610,38 @@ export function registerMarketplaceHandlers(deps: MarketplaceHandlerDependencies
|
||||
createIpcHandler(handlerOpts('getManifest'), async () => {
|
||||
// Try to read from cache first
|
||||
const cache = await readCache(app);
|
||||
let officialManifest: MarketplaceManifest | null = null;
|
||||
let fromCache = false;
|
||||
let cacheAge: number | undefined;
|
||||
|
||||
if (cache && isCacheValid(cache)) {
|
||||
const cacheAge = Date.now() - cache.fetchedAt;
|
||||
logger.debug(`Serving manifest from cache (age: ${Math.round(cacheAge / 1000)}s)`, LOG_CONTEXT);
|
||||
return {
|
||||
manifest: cache.manifest,
|
||||
fromCache: true,
|
||||
cacheAge,
|
||||
};
|
||||
cacheAge = Date.now() - cache.fetchedAt;
|
||||
logger.debug(`Serving official manifest from cache (age: ${Math.round(cacheAge / 1000)}s)`, LOG_CONTEXT);
|
||||
officialManifest = cache.manifest;
|
||||
fromCache = true;
|
||||
} else {
|
||||
// Cache miss or expired - fetch fresh data
|
||||
try {
|
||||
officialManifest = await fetchManifest();
|
||||
await writeCache(app, officialManifest);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch official manifest, continuing with local only', LOG_CONTEXT, { error });
|
||||
// Continue - we might still have local playbooks
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired - fetch fresh data
|
||||
const manifest = await fetchManifest();
|
||||
await writeCache(app, manifest);
|
||||
// Read local manifest (always, not cached)
|
||||
const localManifest = await readLocalManifest(app);
|
||||
logger.info(`Local manifest loaded: ${localManifest ? localManifest.playbooks.length : 0} playbooks`, LOG_CONTEXT);
|
||||
|
||||
// Merge manifests
|
||||
const mergedManifest = mergeManifests(officialManifest, localManifest);
|
||||
logger.info(`Merged manifest: ${mergedManifest.playbooks.length} total playbooks`, LOG_CONTEXT);
|
||||
|
||||
return {
|
||||
manifest,
|
||||
fromCache: false,
|
||||
manifest: mergedManifest,
|
||||
fromCache,
|
||||
cacheAge,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -323,11 +654,22 @@ export function registerMarketplaceHandlers(deps: MarketplaceHandlerDependencies
|
||||
createIpcHandler(handlerOpts('refreshManifest'), async () => {
|
||||
logger.info('Force refreshing manifest (bypass cache)', LOG_CONTEXT);
|
||||
|
||||
const manifest = await fetchManifest();
|
||||
await writeCache(app, manifest);
|
||||
let officialManifest: MarketplaceManifest | null = null;
|
||||
try {
|
||||
officialManifest = await fetchManifest();
|
||||
await writeCache(app, officialManifest);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch official manifest during refresh, continuing with local only', LOG_CONTEXT, { error });
|
||||
}
|
||||
|
||||
// Read local manifest (always fresh, not cached)
|
||||
const localManifest = await readLocalManifest(app);
|
||||
|
||||
// Merge manifests
|
||||
const mergedManifest = mergeManifests(officialManifest, localManifest);
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifest: mergedManifest,
|
||||
fromCache: false,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -1325,6 +1325,11 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
ipcRenderer.invoke('marketplace:getReadme', playbookPath),
|
||||
importPlaybook: (playbookId: string, targetFolderName: string, autoRunFolderPath: string, sessionId: string, sshRemoteId?: string) =>
|
||||
ipcRenderer.invoke('marketplace:importPlaybook', playbookId, targetFolderName, autoRunFolderPath, sessionId, sshRemoteId),
|
||||
onManifestChanged: (handler: () => void) => {
|
||||
const wrappedHandler = () => handler();
|
||||
ipcRenderer.on('marketplace:manifestChanged', wrappedHandler);
|
||||
return () => ipcRenderer.removeListener('marketplace:manifestChanged', wrappedHandler);
|
||||
},
|
||||
},
|
||||
|
||||
// Debug Package API (generate support bundles for bug reporting)
|
||||
|
||||
@@ -174,8 +174,8 @@ function PlaybookTile({ playbook, theme, isSelected, onSelect }: PlaybookTilePro
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Category badge */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Category and source badges */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs"
|
||||
style={{
|
||||
@@ -190,6 +190,18 @@ function PlaybookTile({ playbook, theme, isSelected, onSelect }: PlaybookTilePro
|
||||
/ {playbook.subcategory}
|
||||
</span>
|
||||
)}
|
||||
{playbook.source === 'local' && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: '#3b82f620',
|
||||
color: '#3b82f6',
|
||||
}}
|
||||
title="Custom local playbook"
|
||||
>
|
||||
Local
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title - with tooltip for truncated text */}
|
||||
|
||||
3
src/renderer/global.d.ts
vendored
3
src/renderer/global.d.ts
vendored
@@ -1008,6 +1008,7 @@ interface MaestroAPI {
|
||||
loopEnabled: boolean;
|
||||
maxLoops?: number | null;
|
||||
prompt: string | null;
|
||||
source?: 'official' | 'local';
|
||||
}>;
|
||||
};
|
||||
fromCache?: boolean;
|
||||
@@ -1036,6 +1037,7 @@ interface MaestroAPI {
|
||||
loopEnabled: boolean;
|
||||
maxLoops?: number | null;
|
||||
prompt: string | null;
|
||||
source?: 'official' | 'local';
|
||||
}>;
|
||||
};
|
||||
fromCache?: boolean;
|
||||
@@ -1066,6 +1068,7 @@ interface MaestroAPI {
|
||||
importedDocs?: string[];
|
||||
error?: string;
|
||||
}>;
|
||||
onManifestChanged: (handler: () => void) => () => void;
|
||||
};
|
||||
// Updates API
|
||||
updates: {
|
||||
|
||||
@@ -152,6 +152,25 @@ export function useMarketplace(): UseMarketplaceReturn {
|
||||
loadManifest();
|
||||
}, []);
|
||||
|
||||
// Hot reload: listen for manifest changes (local-manifest.json edits)
|
||||
useEffect(() => {
|
||||
const cleanup = window.maestro.marketplace.onManifestChanged(async () => {
|
||||
console.log('Local manifest changed, reloading...');
|
||||
try {
|
||||
const result = await window.maestro.marketplace.getManifest();
|
||||
if (result.success && result.manifest) {
|
||||
setManifest(result.manifest);
|
||||
setFromCache(result.fromCache ?? false);
|
||||
setCacheAge(result.cacheAge ?? null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to reload manifest after change:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
// Extract playbooks from manifest
|
||||
const playbooks = useMemo(() => {
|
||||
return manifest?.playbooks ?? [];
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface MarketplaceManifest {
|
||||
playbooks: MarketplacePlaybook[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Playbook source type - distinguishes official GitHub playbooks from local ones.
|
||||
*/
|
||||
export type PlaybookSource = 'official' | 'local';
|
||||
|
||||
/**
|
||||
* Individual playbook entry in the marketplace manifest.
|
||||
*/
|
||||
@@ -43,7 +48,7 @@ export interface MarketplacePlaybook {
|
||||
tags?: string[];
|
||||
/** Last update date in YYYY-MM-DD format */
|
||||
lastUpdated: string;
|
||||
/** Folder path in repo for fetching documents */
|
||||
/** Folder path in repo for fetching documents (GitHub path or local filesystem path) */
|
||||
path: string;
|
||||
/** Ordered list of documents in the playbook */
|
||||
documents: MarketplaceDocument[];
|
||||
@@ -59,6 +64,8 @@ export interface MarketplacePlaybook {
|
||||
* that are bundled with the playbook.
|
||||
*/
|
||||
assets?: string[];
|
||||
/** Source of the playbook - official (from GitHub) or local (from local-manifest.json) */
|
||||
source?: PlaybookSource;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user