diff --git a/docs/PRIVATE_MARKETPLACE_CONFIGURATION.md b/docs/PRIVATE_MARKETPLACE_CONFIGURATION.md new file mode 100644 index 00000000..bd55bd78 --- /dev/null +++ b/docs/PRIVATE_MARKETPLACE_CONFIGURATION.md @@ -0,0 +1,278 @@ +# Private Marketplace Configuration + +This guide explains how to configure a private marketplace for the desktop app and the prerequisites needed for it to work correctly. + +> Note: This document describes the current v1 implementation. In v1, the app supports configuring additional marketplace sources, but authenticated private marketplace flows are not yet supported end-to-end. Support for authenticated private marketplace configuration is planned for v2. + +## Overview + +The desktop app can load tools from multiple marketplace sources: + +- The built-in marketplace (public/default source) +- One or more private marketplace sources + +Private marketplace sources are configured through the app settings and are merged with the built-in marketplace. If the same tool ID appears in multiple sources, private sources take precedence over the built-in marketplace. + +## Prerequisites + +Before enabling a private marketplace, make sure the following are available: + +1. A reachable HTTPS endpoint that serves a marketplace registry JSON file. +2. The registry endpoint must return a JSON payload with a top-level `tools` array. +3. The registry should contain tool entries in the same shape expected by the app. +4. The endpoint should be publicly accessible to the desktop app if the app is running outside your internal network. +5. If you plan to host packages from a private storage location, ensure the download URLs are accessible to the app. + +## Registry Format + +The private registry should look like this: + +```json +{ + "tools": [ + { + "id": "my-private-tool", + "name": "My Private Tool", + "description": "Example private tool", + "authors": ["Contoso Tools Team"], + "version": "1.0.0", + "downloadUrl": "https://example.com/packages/my-private-tool.tar.gz", + "status": "active" + } + ] +} +``` + +Important: the marketplace UI text shown as "by ..." is populated from `authors` in each tool entry. + +Each tool entry should include at least: + +- `id` +- `name` +- `version` +- `downloadUrl` (when package download is required) +- `status` (recommended: `active`) + +## Configuration Steps + +1. Open the desktop app settings. +2. Go to the marketplace settings section. +3. Add a new marketplace source and choose the appropriate type: + - `builtin` for the built-in marketplace + - `private` for your internal/private registry +4. Provide a label for the source. +5. Enter the full HTTPS URL to the registry JSON file. +6. Enable the source. + +## Built-in Marketplace Behavior + +The built-in marketplace is enabled by default. It can be disabled only when at least one private marketplace source is enabled. + +This prevents the app from being left with no marketplace source configured. + +## Recommended Hosting Strategy + +For a secure private marketplace deployment, the recommended approach is: + +- Host the registry JSON over HTTPS. +- Serve tool packages from a dedicated storage location that is readable by the app. +- Use read-only access for registry and package endpoints. +- Prefer a private network or authenticated storage solution when the marketplace is intended only for a specific organization. + +## Example: Azure Blob Storage with SAS-Based Access (v1) + +For the current v1 implementation, a practical pattern is to host the registry and package files in Azure Blob Storage and let each end user supply their own read-only SAS token when configuring the marketplace URL in the app. The admin can generate and keep the SAS token private, while each user pastes their own token into the marketplace source URL. This is supported in v1. + +Authenticated private marketplace flows that require the desktop app to sign in with Entra ID are planned for v2. + +### Example architecture + +- Create an Azure Storage account. +- Create a container such as `tools`. +- Upload: + - `registry.json` + - package files such as `my-private-tool-1.0.0.tar.gz` +- Protect the blob container with a read-only shared access signature (SAS) for v1 usage. +- Expose the registry through a secure HTTPS endpoint, such as: + - Azure Blob URL with a SAS token + - Azure Front Door, API Management, or a small proxy app for additional control + +### Example registry.json + +The admin can generate a SAS token for the registry blob (or for the container containing it), keep it safe, and use that SAS token to configure the marketplace source URL in the app settings. + +Use the registry URL below as the marketplace source URL in the app settings: + +```text +https://.blob.core.windows.net/pptb-tools/registry.json? +``` + +In the registry file, each tool should point to a package URL that also includes the SAS token so the app can download the package content: + +```json +{ + "tools": [ + { + "id": "my-private-tool", + "name": "My Private Tool", + "description": "Example tool hosted in Azure Blob", + "authors": ["Contoso Tools Team"], + "version": "1.0.0", + "downloadUrl": "https://.blob.core.windows.net/pptb-tools/packages/my-private-tool-1.0.0.tar.gz?", + "status": "active" + } + ] +} +``` + +### Exact configuration recipe for v1 + +1. Create or use an Azure Storage account and a container named `pptb-tools`. +2. Upload `registry.json` into that container. +3. Upload each tool package into the same container or into a subfolder under it. +4. Generate a read-only SAS token for the container or for the specific blobs. +5. Put the registry URL with the SAS token into the desktop app as the private marketplace source URL. +6. In the `registry.json` file, ensure every `downloadUrl` also includes the SAS token so the app can download the tool packages. +7. If the package files are stored in a subfolder, include that path in the `downloadUrl`. + +### Example: generate a SAS token per individual blob and append it to the marketplace URL + +Each user appends their own SAS token to the marketplace URL they configure in the app. + +Example for generating a read-only SAS token for accessing the marketplace registry blob: + +```bash +az storage blob generate-sas \ + --account-name \ + --container-name \ + --name registry.json \ + --permissions r \ + --expiry 2030-01-01T00:00:00Z \ + --https-only \ + --auth-mode login +``` + +The command returns a query string similar to: + +```text +se=2030-01-01T00%3A00%3A00Z&sp=r&sv=2024-11-04&sr=b&sig=abc123... +``` + +Append that query string to the full blob URL and use it as the marketplace source URL in the app settings: + +```text +https://.blob.core.windows.net//registry.json?se=2030-01-01T00%3A00%3A00Z&sp=r&sv=2024-11-04&sr=b&sig=abc123... +``` + +This pattern is useful when you want short-lived, per-scope access to a private marketplace while keeping the SAS values private and letting each user configure their own marketplace URL. + +### Packaging tools as .tar.gz for the marketplace + +The marketplace expects tool packages to be distributed as `.tar.gz` archives. A typical approach is to package the tool folder contents so the archive can be downloaded and extracted by the app. + +If you are working from a local tool folder such as: + +```text + +``` + +you can create a package like this. Run the command from the directory that contains the tool's package.json: + +```bash +cd + +tar -czf my-private-tool-1.0.0.tar.gz . +``` + +This produces a tarball named `my-private-tool-1.0.0.tar.gz` that contains the tool contents. + +If you want the archive to contain the tool in a subfolder rather than the current directory contents, you can first create a staging folder: + +```bash +mkdir -p /tmp/pptb-package +cp -R /tmp/pptb-package/my-private-tool +cd /tmp/pptb-package +tar -czf my-private-tool-1.0.0.tar.gz my-private-tool +``` + +Upload the resulting `.tar.gz` file to your Azure Blob container and use its URL as the package download location. + +### Azure deployment example + +1. Create a storage account: + +```bash +az storage account create \ + --name \ + --resource-group \ + --location \ + --sku Standard_LRS \ + --kind StorageV2 +``` + +2. Create a container: + +```bash +az storage container create \ + --account-name \ + --name tools \ + --auth-mode login +``` + +3. Upload the registry and package files: + +```bash +az storage blob upload \ + --account-name \ + --container-name tools \ + --name registry.json \ + --file ./registry.json \ + --auth-mode login + +az storage blob upload \ + --account-name \ + --container-name tools \ + --name packages/my-private-tool-1.0.0.tar.gz \ + --file ./my-private-tool-1.0.0.tar.gz \ + --auth-mode login +``` + +4. Generate a read-only SAS token for a limited duration if you need time-bound access: + +```bash +az storage blob generate-sas \ + --account-name \ + --container-name tools \ + --name registry.json \ + --permissions r \ + --expiry 2030-01-01T00:00:00Z \ + --https-only \ + --auth-mode login +``` + +5. Use the resulting URL as the marketplace source URL in the app settings. + +### Security notes + +- For v1, SAS is the supported approach for private marketplace access. +- Keep SAS tokens read-only and short-lived. +- Avoid exposing secrets in the registry JSON. +- If the registry is meant for a limited audience, place it behind Azure Front Door, API Management, or a small authenticated proxy. +- Entra ID-based authenticated flows are planned for v2 and are not part of the v1 implementation. + +## Notes + +- The app merges marketplace sources in order and gives private sources precedence over the built-in marketplace for duplicate tool IDs. +- If a source URL is invalid or unreachable, the app logs a warning and continues with the other enabled sources. +- The built-in marketplace URL is derived from the configured Azure Blob environment value when available. + +## Troubleshooting + +If the private marketplace does not appear as expected: + +- Verify the registry URL is reachable from the machine running the app. +- Confirm the endpoint returns HTTP 200 and valid JSON. +- Check that the JSON uses the expected `tools` array format. +- Ensure the source is enabled in settings. +- Confirm the tool IDs are unique or that private sources are intended to override the built-in marketplace entries. +- If the tool shows a blank "by" label, confirm the tool entry includes `authors` (array or comma-separated string). diff --git a/src/common/types/settings.ts b/src/common/types/settings.ts index 170e62b5..bd58042a 100644 --- a/src/common/types/settings.ts +++ b/src/common/types/settings.ts @@ -85,6 +85,15 @@ export interface LastUsedToolUpdate { lastUsedAt?: string; } +export interface MarketplaceSource { + id: string; + type: "builtin" | "private"; + label: string; + url: string; + enabled: boolean; + description?: string; +} + /** * Per-tool CSP consent record. * Stores whether consent was granted, and which required/optional domains were @@ -134,4 +143,5 @@ export interface UserSettings { splitDividerRatio?: number; // Persisted position of the split-pane divider (0.15–0.85) enablePreviewFeatures?: boolean; // Show preview/experimental features in the UI previewFeatures?: PreviewFeatureFlags; // Per-feature preview toggles keyed by preview feature ID + marketplaceSources?: MarketplaceSource[]; // Marketplace sources configured for the app } diff --git a/src/common/types/tool.ts b/src/common/types/tool.ts index 9aa4b299..8804eeca 100644 --- a/src/common/types/tool.ts +++ b/src/common/types/tool.ts @@ -75,6 +75,9 @@ export interface Tool { mcpHeadlessEnabled?: boolean; // Whether this tool supports MCP headless execution /** Invocation capability tags declared in pptb.config.json (e.g. ["entity-picker"]). */ capabilities?: string[]; + marketplaceSourceId?: string; + marketplaceSourceLabel?: string; + marketplaceSourceType?: "builtin" | "private"; } /** @@ -106,6 +109,9 @@ export interface ToolRegistryEntry { minAPI?: string; // Minimum ToolBox API version required (from features.minAPI) maxAPI?: string; // Maximum ToolBox API version tested (from npm-shrinkwrap @pptb/types version) npmPackageName?: string; // npm package name used for pre-release version detection + marketplaceSourceId?: string; + marketplaceSourceLabel?: string; + marketplaceSourceType?: "builtin" | "private"; } /** @@ -140,6 +146,9 @@ export interface ToolManifest { mcpHeadlessEnabled?: boolean; // Whether this tool supports MCP headless execution /** Invocation capability tags declared in pptb.config.json (e.g. ["entity-picker"]). */ capabilities?: string[]; + marketplaceSourceId?: string; + marketplaceSourceLabel?: string; + marketplaceSourceType?: "builtin" | "private"; } /** diff --git a/src/main/index.ts b/src/main/index.ts index 87bfb1a2..7bc0d04e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -132,6 +132,7 @@ class ToolBoxApp { process.env.SUPABASE_ANON_KEY, this.installIdManager, process.env.AZURE_BLOB_BASE_URL, + this.settingsManager, ); this.browserviewProtocolManager = new BrowserviewProtocolManager(this.toolManager, this.settingsManager); this.protocolHandlerManager = new ProtocolHandlerManager(); diff --git a/src/main/managers/settingsManager.ts b/src/main/managers/settingsManager.ts index 29e734cc..01244e93 100644 --- a/src/main/managers/settingsManager.ts +++ b/src/main/managers/settingsManager.ts @@ -1,7 +1,8 @@ import { randomBytes } from "crypto"; import Store from "electron-store"; -import { CspConsentRecord, LastUsedToolConnectionInfo, LastUsedToolEntry, LastUsedToolUpdate, ToolSettings, UserSettings } from "../../common/types"; +import { CspConsentRecord, LastUsedToolConnectionInfo, LastUsedToolEntry, LastUsedToolUpdate, MarketplaceSource, ToolSettings, UserSettings } from "../../common/types"; import { buildPreviewFeatureFlags } from "../../common/types/settings"; +import { AZURE_BLOB_BASE_URL } from "../constants"; /** * Generates a random authentication token for MCP server access @@ -41,6 +42,7 @@ export class SettingsManager { restoreSessionOnStartup: true, // Reopen previously open tools on app start enablePreviewFeatures: false, // Show preview/experimental features in the UI previewFeatures: buildPreviewFeatureFlags(), // Per-feature preview toggles + marketplaceSources: this.getDefaultMarketplaceSources(), }, }); @@ -67,11 +69,99 @@ export class SettingsManager { this.store.set("enablePreviewFeatures", hasAnyPreviewFeatureEnabled); } + private getDefaultMarketplaceSources(): MarketplaceSource[] { + return [ + { + id: "builtin-pptb", + type: "builtin", + label: "Power Platform ToolBox marketplace", + url: AZURE_BLOB_BASE_URL ? `${AZURE_BLOB_BASE_URL}/registry.json` : "", + enabled: true, + description: "Built-in public marketplace", + }, + ]; + } + + private normalizeMarketplaceSources(sources?: MarketplaceSource[]): MarketplaceSource[] { + const normalized = (sources || []).filter((source) => { + if (!source?.id || !source?.label) { + return false; + } + + // The built-in source can be configured with an empty URL when AZURE_BLOB_BASE_URL is not set. + if (source.id === "builtin-pptb") { + return true; + } + + return Boolean(source.url); + }); + const builtIn = normalized.find((source) => source.id === "builtin-pptb"); + if (!builtIn) { + normalized.unshift(this.getDefaultMarketplaceSources()[0]); + } + + const normalizedSources = normalized.map((source, index) => ({ + ...source, + id: source.id || `marketplace-${index + 1}`, + type: source.type || (source.id === "builtin-pptb" ? "builtin" : "private"), + enabled: typeof source.enabled === "boolean" ? source.enabled : source.type === "builtin" ? true : false, + })); + + const builtInSource = normalizedSources.find((source) => source.id === "builtin-pptb"); + if (builtInSource) { + const hasPrivateEnabledSource = normalizedSources.some((source) => source.id !== "builtin-pptb" && source.enabled); + builtInSource.enabled = hasPrivateEnabledSource ? builtInSource.enabled : true; + } + + return normalizedSources; + } + + private getMarketplaceSourcesFromStore(): MarketplaceSource[] { + const storedSources = this.store.get("marketplaceSources"); + return this.normalizeMarketplaceSources(storedSources as MarketplaceSource[] | undefined); + } + + private persistMarketplaceSources(sources: MarketplaceSource[]): void { + this.store.set("marketplaceSources", this.normalizeMarketplaceSources(sources)); + } + + getMarketplaceSources(): MarketplaceSource[] { + return this.getMarketplaceSourcesFromStore(); + } + + addMarketplaceSource(source: MarketplaceSource): MarketplaceSource[] { + const sources = this.getMarketplaceSourcesFromStore(); + const nextSources = [...sources, source]; + this.persistMarketplaceSources(nextSources); + return this.getMarketplaceSources(); + } + + setBuiltinMarketplaceEnabled(enabled: boolean): void { + const sources = this.getMarketplaceSourcesFromStore(); + const builtInIndex = sources.findIndex((source) => source.id === "builtin-pptb"); + if (builtInIndex === -1) { + return; + } + + const hasPrivateSource = sources.some((source) => source.id !== "builtin-pptb" && source.enabled); + const nextEnabled = hasPrivateSource ? enabled : true; + sources[builtInIndex] = { + ...sources[builtInIndex], + enabled: nextEnabled, + }; + + this.persistMarketplaceSources(sources); + } + /** * Get all user settings */ getUserSettings(): UserSettings { - return this.store.store; + const settings = this.store.store; + return { + ...settings, + marketplaceSources: this.getMarketplaceSourcesFromStore(), + }; } /** @@ -79,6 +169,11 @@ export class SettingsManager { */ updateUserSettings(settings: Partial): void { Object.entries(settings).forEach(([key, value]) => { + if (key === "marketplaceSources" && Array.isArray(value)) { + this.store.set(key as keyof UserSettings, this.normalizeMarketplaceSources(value as MarketplaceSource[])); + return; + } + this.store.set(key as keyof UserSettings, value); }); } diff --git a/src/main/managers/toolRegistryManager.ts b/src/main/managers/toolRegistryManager.ts index 9ed9f982..9b3c49fe 100644 --- a/src/main/managers/toolRegistryManager.ts +++ b/src/main/managers/toolRegistryManager.ts @@ -7,7 +7,7 @@ import * as https from "https"; import * as path from "path"; import { pipeline } from "stream/promises"; import { logError, logInfo, logWarn } from "../../common/logger"; -import { CapabilityTagEntry, CommunityLinksCollection, CommunityLinksGroup, CommunityLinksItem, ToolManifest, ToolRegistryEntry } from "../../common/types"; +import { CapabilityTagEntry, CommunityLinksCollection, CommunityLinksGroup, CommunityLinksItem, MarketplaceSource, ToolManifest, ToolRegistryEntry } from "../../common/types"; import { AZURE_BLOB_BASE_URL, SUPABASE_ANON_KEY, SUPABASE_URL } from "../constants"; import { loadOfflineMockRegistryTools, OfflineMockRegistryTool } from "../utilities/mockRegistry"; import { InstallIdManager } from "./installIdManager"; @@ -146,6 +146,7 @@ export class ToolRegistryManager extends EventEmitter { private useLocalFallback: boolean = false; private installIdManager: InstallIdManager | null = null; private azureBlobBaseUrl: string; + private settingsManager: { getMarketplaceSources(): MarketplaceSource[] } | null = null; // Registry fetch de-duping + caching private registryFetchInFlight: Promise | null = null; @@ -166,12 +167,20 @@ export class ToolRegistryManager extends EventEmitter { // Capability tags change rarely; use a longer TTL so the fetch happens at most once per session. private static readonly CAPABILITY_TAGS_CACHE_TTL_MS = 300_000; // 5 minutes - constructor(toolsDirectory: string, supabaseUrl?: string, supabaseKey?: string, installIdManager?: InstallIdManager, azureBlobBaseUrl?: string) { + constructor( + toolsDirectory: string, + supabaseUrl?: string, + supabaseKey?: string, + installIdManager?: InstallIdManager, + azureBlobBaseUrl?: string, + settingsManager?: { getMarketplaceSources(): MarketplaceSource[] }, + ) { super(); this.toolsDirectory = toolsDirectory; this.manifestPath = path.join(toolsDirectory, "manifest.json"); this.installIdManager = installIdManager || null; this.azureBlobBaseUrl = azureBlobBaseUrl || AZURE_BLOB_BASE_URL; + this.settingsManager = settingsManager || null; // Initialize Supabase client const url = supabaseUrl || SUPABASE_URL; @@ -216,18 +225,7 @@ export class ToolRegistryManager extends EventEmitter { } this.registryFetchInFlight = (async () => { - // Use remote/local fallback if Supabase is not configured - if (this.useLocalFallback) { - const tools = await this.fetchFallbackRegistry(); - this.registryCache = { - tools, - fetchedAtMs: Date.now(), - source: this.azureBlobBaseUrl ? "azureBlob" : "local", - }; - return tools; - } - - const tools = await this.fetchRegistryFromSupabase(); + const tools = await this.fetchRegistryFromConfiguredSources(); this.registryCache = { tools, fetchedAtMs: Date.now(), @@ -243,6 +241,117 @@ export class ToolRegistryManager extends EventEmitter { } } + private async fetchRegistryFromConfiguredSources(): Promise { + const configuredSources = this.settingsManager?.getMarketplaceSources() ?? []; + const enabledSources = configuredSources.filter((source) => source.enabled); + + if (enabledSources.length === 0) { + return []; + } + + const mergedTools = new Map(); + + for (const source of enabledSources) { + const tools = source.type === "builtin" ? await this.fetchBuiltinRegistryForSource(source) : await this.fetchRegistryFromMarketplaceUrl(source); + tools.forEach((tool) => { + const existingTool = mergedTools.get(tool.id); + const shouldOverride = !existingTool || source.type === "private" || existingTool.marketplaceSourceType !== "private"; + if (!shouldOverride) { + return; + } + + mergedTools.set(tool.id, { + ...tool, + marketplaceSourceId: source.id, + marketplaceSourceLabel: source.label, + marketplaceSourceType: source.type, + }); + }); + } + + return Array.from(mergedTools.values()); + } + + private async fetchBuiltinRegistryForSource(_: MarketplaceSource): Promise { + if (this.useLocalFallback) { + return this.fetchFallbackRegistry(); + } + + try { + return await this.fetchRegistryFromSupabase(); + } catch (error) { + logWarn(`[ToolRegistry] Built-in marketplace fetch failed, falling back to local/azure registry`, error); + return this.fetchFallbackRegistry(); + } + } + + private async fetchRegistryFromMarketplaceUrl(source: MarketplaceSource): Promise { + if (!source.url) { + logWarn(`[ToolRegistry] Marketplace source ${source.id} is missing a URL`); + return []; + } + + try { + const registryUrl = source.url; + logInfo(`[ToolRegistry] Fetching registry from marketplace source ${source.id}: ${registryUrl}`); + + const rawJson = await new Promise((resolve, reject) => { + const protocol = registryUrl.startsWith("https") ? https : http; + protocol + .get(registryUrl, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`Marketplace registry request failed: HTTP ${res.statusCode} for ${registryUrl}`)); + return; + } + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + res.on("error", reject); + }) + .on("error", reject); + }); + + let registryData: AzureBlobRegistryFile; + try { + registryData = JSON.parse(rawJson) as AzureBlobRegistryFile; + } catch (parseError) { + throw new Error(`Failed to parse registry JSON from ${registryUrl}: ${(parseError as Error).message}`); + } + + if (!registryData.tools || registryData.tools.length === 0) { + logInfo(`[ToolRegistry] No tools found in marketplace source ${source.id}`); + return []; + } + + return registryData.tools + .filter((tool) => tool.status === "active" || tool.status === "deprecated" || !tool.status) + .map((tool) => ({ + id: tool.id, + name: tool.name, + description: tool.description, + authors: this.normalizeAuthorList(tool.authors), + version: tool.version, + downloadUrl: this.resolveDownloadUrl(tool.downloadUrl || tool.downloadurl || "", registryUrl), + checksum: tool.checksum, + size: tool.size, + publishedAt: tool.publishedAt || tool.published_at || new Date().toISOString(), + repository: tool.repository, + website: tool.homepage || tool.website, + icon: tool.icon, + cspExceptions: tool.cspExceptions, + features: tool.features, + license: tool.license, + status: (tool.status as "active" | "deprecated" | "archived" | undefined) || "active", + marketplaceSourceId: source.id, + marketplaceSourceLabel: source.label, + marketplaceSourceType: source.type, + })); + } catch (error) { + logWarn(`[ToolRegistry] Failed to fetch marketplace source ${source.id}`, error); + return []; + } + } + private async fetchRegistryFromSupabase(): Promise { try { logInfo(`[ToolRegistry] Fetching registry from Supabase (new schema)`); @@ -429,7 +538,7 @@ export class ToolRegistryManager extends EventEmitter { * Azure Blob Storage (e.g. "my-tool-1.0.0.tar.gz" → "/packages/my-tool-1.0.0/my-tool-1.0.0.tar.gz"). * Returns an empty string when the URL is relative but azureBlobBaseUrl is not configured. */ - private resolveDownloadUrl(downloadUrl: string): string { + private resolveDownloadUrl(downloadUrl: string, baseUrl?: string): string { if (!downloadUrl) { logWarn("[ToolRegistry] Tool entry has no downloadUrl; tool cannot be installed from this registry source"); return ""; @@ -437,6 +546,14 @@ export class ToolRegistryManager extends EventEmitter { if (downloadUrl.startsWith("http://") || downloadUrl.startsWith("https://")) { return downloadUrl; } + + if (baseUrl) { + try { + return new URL(downloadUrl, baseUrl).toString(); + } catch { + // Fall back to the existing behavior below if the URL cannot be resolved. + } + } // Relative filename – resolve to /packages// // where = filename without the .tar.gz extension if (this.azureBlobBaseUrl) { @@ -749,6 +866,9 @@ export class ToolRegistryManager extends EventEmitter { maxAPI, // Maximum API version tested (from @pptb/types) mcpHeadlessEnabled, capabilities, // Invocation capability tags from pptb.config.json + marketplaceSourceId: tool.marketplaceSourceId, + marketplaceSourceLabel: tool.marketplaceSourceLabel, + marketplaceSourceType: tool.marketplaceSourceType, }; // Save to manifest file diff --git a/src/main/managers/toolsManager.ts b/src/main/managers/toolsManager.ts index 3c0aa087..976b37cc 100644 --- a/src/main/managers/toolsManager.ts +++ b/src/main/managers/toolsManager.ts @@ -4,7 +4,7 @@ import * as fs from "fs"; import * as path from "path"; import { pathToFileURL } from "url"; import { logError, logInfo, logWarn } from "../../common/logger"; -import { CapabilityTagEntry, CommunityLinksCollection, CspExceptions, Tool, ToolFeatures, ToolManifest } from "../../common/types"; +import { CapabilityTagEntry, CommunityLinksCollection, CspExceptions, MarketplaceSource, Tool, ToolFeatures, ToolManifest } from "../../common/types"; import { InstallIdManager } from "./installIdManager"; import { ToolRegistryManager } from "./toolRegistryManager"; import { VersionManager } from "./versionManager"; @@ -38,10 +38,17 @@ export class ToolManager extends EventEmitter { private analyticsCache: Map = new Map(); private updatingTools: Set = new Set(); - constructor(toolsDirectory: string, supabaseUrl?: string, supabaseKey?: string, installIdManager?: InstallIdManager, azureBlobBaseUrl?: string) { + constructor( + toolsDirectory: string, + supabaseUrl?: string, + supabaseKey?: string, + installIdManager?: InstallIdManager, + azureBlobBaseUrl?: string, + settingsManager?: { getMarketplaceSources(): MarketplaceSource[] }, + ) { super(); this.toolsDirectory = toolsDirectory; - this.registryManager = new ToolRegistryManager(toolsDirectory, supabaseUrl, supabaseKey, installIdManager, azureBlobBaseUrl); + this.registryManager = new ToolRegistryManager(toolsDirectory, supabaseUrl, supabaseKey, installIdManager, azureBlobBaseUrl, settingsManager); this.ensureToolsDirectory(); // Forward registry events @@ -85,6 +92,9 @@ export class ToolManager extends EventEmitter { isSupported: VersionManager.isToolSupported(manifest.minAPI, manifest.maxAPI), mcpHeadlessEnabled: manifest.mcpHeadlessEnabled, capabilities: manifest.capabilities, + marketplaceSourceId: manifest.marketplaceSourceId, + marketplaceSourceLabel: manifest.marketplaceSourceLabel, + marketplaceSourceType: manifest.marketplaceSourceType, }; const cached = this.analyticsCache.get(tool.id); @@ -157,6 +167,9 @@ export class ToolManager extends EventEmitter { isSupported: VersionManager.isToolSupported(manifest.minAPI, manifest.maxAPI), mcpHeadlessEnabled: manifest.mcpHeadlessEnabled, capabilities: manifest.capabilities, + marketplaceSourceId: manifest.marketplaceSourceId, + marketplaceSourceLabel: manifest.marketplaceSourceLabel, + marketplaceSourceType: manifest.marketplaceSourceType, }; const cached = this.analyticsCache.get(tool.id); diff --git a/src/renderer/modules/settingsManagement.ts b/src/renderer/modules/settingsManagement.ts index b011d1de..c820df08 100644 --- a/src/renderer/modules/settingsManagement.ts +++ b/src/renderer/modules/settingsManagement.ts @@ -4,7 +4,7 @@ */ import { logError } from "../../common/logger"; -import { buildPreviewFeatureFlags } from "../../common/types"; +import { buildPreviewFeatureFlags, type MarketplaceSource } from "../../common/types"; import { DEFAULT_CATEGORY_COLOR_THICKNESS, DEFAULT_ENVIRONMENT_COLOR_THICKNESS, @@ -32,6 +32,10 @@ import { loadSidebarTools } from "./toolsSidebarManagement"; // Track original settings to detect changes let originalSettings: SettingsState = {}; +function escapeHtml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + function arePreviewFeatureFlagsEqual(left?: SettingsState["previewFeatures"], right?: SettingsState["previewFeatures"]): boolean { const normalizedLeft = buildPreviewFeatureFlags(left); const normalizedRight = buildPreviewFeatureFlags(right); @@ -60,6 +64,86 @@ function renderPreviewFeatureSettingsRows(): string { .join(""); } +function renderMarketplaceSourcesList(sources: MarketplaceSource[]): string { + const privateSources = sources.filter((source) => source.type !== "builtin"); + if (privateSources.length === 0) { + return ` +
+ No private marketplace sources configured yet. +
+ `; + } + + return privateSources + .map((source) => { + const sourceId = source.id || `marketplace-${Math.random().toString(36).slice(2, 9)}`; + return ` +
+
+ + +
+
+ + +
+
+ `; + }) + .join(""); +} + +function collectMarketplaceSourcesFromSettingsPanel(): MarketplaceSource[] { + const builtInCheckbox = document.getElementById("sidebar-marketplace-builtin-check") as HTMLInputElement | null; + const listContainer = document.getElementById("marketplace-sources-list") as HTMLElement | null; + const builtInTemplate = originalSettings.marketplaceSources?.find((source) => source.id === "builtin-pptb") ?? { + id: "builtin-pptb", + type: "builtin" as const, + label: "Power Platform ToolBox marketplace", + url: "", + enabled: true, + description: "Built-in public marketplace", + }; + + const sources: MarketplaceSource[] = [ + { + ...builtInTemplate, + id: "builtin-pptb", + type: "builtin", + enabled: builtInCheckbox?.checked ?? builtInTemplate.enabled ?? true, + }, + ]; + + if (!listContainer) { + return sources; + } + + const sourceRows = Array.from(listContainer.querySelectorAll(".settings-vscode-marketplace-source-row")); + sourceRows.forEach((row) => { + const label = (row.querySelector("[data-field='label']")?.value || "").trim(); + const url = (row.querySelector("[data-field='url']")?.value || "").trim(); + const enabled = row.querySelector("[data-field='enabled']")?.checked ?? false; + const sourceId = row.getAttribute("data-source-id") || `marketplace-${sources.length + 1}`; + + if (!label && !url) { + return; + } + + sources.push({ + id: sourceId, + type: "private", + label, + url, + enabled, + }); + }); + + return sources; +} + /** * Load settings into the settings UI panel */ @@ -78,6 +162,8 @@ export async function loadSettings(): Promise { const showEnvironmentColorCheck = document.getElementById("sidebar-show-environment-color-check") as HTMLInputElement | null; const categoryColorThicknessInput = document.getElementById("sidebar-category-color-thickness") as HTMLInputElement | null; const environmentColorThicknessInput = document.getElementById("sidebar-environment-color-thickness") as HTMLInputElement | null; + const marketplaceBuiltinCheck = document.getElementById("sidebar-marketplace-builtin-check") as HTMLInputElement | null; + const marketplaceSourcesList = document.getElementById("marketplace-sources-list") as HTMLElement | null; if (themeSelect && autoUpdateCheck && showDebugMenuCheck && deprecatedToolsSelect && toolDisplayModeSelect && terminalFontSelect) { const settings = await window.toolboxAPI.getUserSettings(); @@ -99,6 +185,7 @@ export async function loadSettings(): Promise { environmentColorThickness: settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS, enablePreviewFeatures: Object.values(previewFeatures).some((enabled) => enabled === true), previewFeatures, + marketplaceSources: settings.marketplaceSources ?? [], }; themeSelect.value = settings.theme; @@ -126,6 +213,13 @@ export async function loadSettings(): Promise { if (environmentColorThicknessInput) { environmentColorThicknessInput.value = String(settings.environmentColorThickness ?? DEFAULT_ENVIRONMENT_COLOR_THICKNESS); } + if (marketplaceBuiltinCheck) { + const builtInSource = (settings.marketplaceSources ?? []).find((source) => source.id === "builtin-pptb"); + marketplaceBuiltinCheck.checked = builtInSource?.enabled ?? true; + } + if (marketplaceSourcesList) { + marketplaceSourcesList.innerHTML = renderMarketplaceSourcesList(settings.marketplaceSources ?? []); + } getPreviewFeatureDefinitions().forEach((feature) => { const checkbox = document.getElementById(getPreviewFeatureCheckboxId(feature.id)) as HTMLInputElement | null; if (checkbox) { @@ -198,6 +292,7 @@ export async function saveSettings(): Promise { : DEFAULT_ENVIRONMENT_COLOR_THICKNESS; const previewFeatures = collectPreviewFeatureFlagsFromSettingsPanel(); const enablePreviewFeatures = Object.values(previewFeatures).some((enabled) => enabled === true); + const marketplaceSources = collectMarketplaceSourcesFromSettingsPanel(); const currentSettings = { theme: themeSelect.value, @@ -214,6 +309,7 @@ export async function saveSettings(): Promise { environmentColorThickness, enablePreviewFeatures, previewFeatures, + marketplaceSources, }; // Only include changed settings in the update @@ -261,6 +357,9 @@ export async function saveSettings(): Promise { if (!arePreviewFeatureFlagsEqual(currentSettings.previewFeatures, originalSettings.previewFeatures ?? buildPreviewFeatureFlags())) { changedSettings.previewFeatures = currentSettings.previewFeatures; } + if (JSON.stringify(currentSettings.marketplaceSources) !== JSON.stringify(originalSettings.marketplaceSources ?? [])) { + changedSettings.marketplaceSources = currentSettings.marketplaceSources; + } // Only save and emit event if something changed if (Object.keys(changedSettings).length > 0) { @@ -356,6 +455,9 @@ function hasUnsavedChanges(): boolean { const currentPreviewFeatures = collectPreviewFeatureFlagsFromSettingsPanel(); if (!arePreviewFeatureFlagsEqual(currentPreviewFeatures, originalSettings.previewFeatures ?? buildPreviewFeatureFlags())) return true; + const currentMarketplaceSources = collectMarketplaceSourcesFromSettingsPanel(); + if (JSON.stringify(currentMarketplaceSources) !== JSON.stringify(originalSettings.marketplaceSources ?? [])) return true; + return false; } @@ -572,6 +674,35 @@ export function renderSettingsContent(panel: HTMLElement): void { +
+

Marketplace

+ +
+
+ +

Enable the default ToolBox marketplace. It stays available when no private marketplace source is configured.

+
+
+ +
+
+ +
+
+ Private marketplace sources +

Add one or more private registries to supplement or override the built-in marketplace.

+
+
+ +
+
+ +
+
+

Preview Features

${renderPreviewFeatureSettingsRows()} @@ -607,6 +738,43 @@ export function renderSettingsContent(panel: HTMLElement): void { }); } + // Wire up marketplace source add/remove actions + const addMarketplaceSourceBtn = panel.querySelector("#sidebar-add-marketplace-source-btn") as HTMLButtonElement | null; + const marketplaceSourcesList = panel.querySelector("#marketplace-sources-list") as HTMLElement | null; + if (addMarketplaceSourceBtn && marketplaceSourcesList) { + addMarketplaceSourceBtn.addEventListener("click", () => { + const nextSourceId = `marketplace-${Date.now()}`; + marketplaceSourcesList.insertAdjacentHTML( + "beforeend", + ` +
+
+ + +
+
+ + +
+
+ `, + ); + const addedRow = marketplaceSourcesList.lastElementChild as HTMLElement | null; + addedRow?.querySelector("[data-action='remove-marketplace-source']")?.addEventListener("click", () => { + addedRow.remove(); + }); + }); + } + + marketplaceSourcesList?.querySelectorAll("[data-action='remove-marketplace-source']").forEach((button) => { + button.addEventListener("click", () => { + button.closest(".settings-vscode-marketplace-source-row")?.remove(); + }); + }); + // Wire up font help link const fontHelpLink = panel.querySelector("#font-help-link") as HTMLAnchorElement | null; if (fontHelpLink) { diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index bb7fda84..67a11635 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -2473,6 +2473,37 @@ body.dark-theme .settings-vscode-item:hover { align-self: flex-start; } +.settings-vscode-marketplace-source-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 24px; + border-top: 1px solid var(--border-color, #d1d1d1); +} + +.settings-vscode-marketplace-source-fields { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.settings-vscode-marketplace-source-label-input { + flex: 1 1 180px; +} + +.settings-vscode-marketplace-source-url-input { + flex: 2 1 280px; +} + +.settings-vscode-marketplace-source-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .mcp-settings-item { margin-bottom: 8px; } diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index cd40d14c..9187379e 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -2,7 +2,7 @@ * Renderer-specific type definitions */ -import type { PreviewFeatureFlags } from "../../common/types"; +import type { MarketplaceSource, PreviewFeatureFlags } from "../../common/types"; /** * Interface for an open tool instance @@ -66,6 +66,7 @@ export interface SettingsState { environmentColorThickness?: number; enablePreviewFeatures?: boolean; previewFeatures?: PreviewFeatureFlags; + marketplaceSources?: MarketplaceSource[]; } /** diff --git a/tests/unit/main/managers/settingsManager.test.ts b/tests/unit/main/managers/settingsManager.test.ts index 083e236b..c06e6e06 100644 --- a/tests/unit/main/managers/settingsManager.test.ts +++ b/tests/unit/main/managers/settingsManager.test.ts @@ -1,5 +1,6 @@ /// +import { MarketplaceSource } from "../../../../src/common/types"; import { SettingsManager } from "../../../../src/main/managers/settingsManager"; // electron-store is replaced by the manual mock at tests/__mocks__/electron-store.ts @@ -47,6 +48,72 @@ describe("SettingsManager", () => { }); }); + describe("marketplace sources", () => { + it("includes a built-in marketplace source by default", () => { + const sources = manager.getMarketplaceSources(); + expect(sources).toEqual(expect.arrayContaining([expect.objectContaining({ id: "builtin-pptb", type: "builtin", enabled: true })])); + }); + + it("keeps the built-in marketplace enabled when no private source exists", () => { + manager.setBuiltinMarketplaceEnabled(false); + expect(manager.getMarketplaceSources().find((source) => source.id === "builtin-pptb")?.enabled).toBe(true); + }); + + it("allows disabling the built-in marketplace when a private source exists", () => { + const privateSource: MarketplaceSource = { + id: "contoso-private", + type: "private", + label: "Contoso private marketplace", + url: "https://example.contoso.test/registry.json", + enabled: true, + }; + + manager.addMarketplaceSource(privateSource); + manager.setBuiltinMarketplaceEnabled(false); + + expect(manager.getMarketplaceSources().find((source) => source.id === "builtin-pptb")?.enabled).toBe(false); + }); + + it("keeps the built-in marketplace enabled when no private source is available", () => { + const sources: MarketplaceSource[] = [ + { + id: "builtin-pptb", + type: "builtin", + label: "Power Platform ToolBox marketplace", + url: "https://example.test/registry.json", + enabled: false, + }, + ]; + + manager.updateUserSettings({ marketplaceSources: sources }); + + expect(manager.getMarketplaceSources().find((source) => source.id === "builtin-pptb")?.enabled).toBe(true); + }); + + it("allows the built-in marketplace to stay disabled when a private source is enabled", () => { + const sources: MarketplaceSource[] = [ + { + id: "builtin-pptb", + type: "builtin", + label: "Power Platform ToolBox marketplace", + url: "https://example.test/registry.json", + enabled: false, + }, + { + id: "contoso-private", + type: "private", + label: "Contoso private marketplace", + url: "https://example.contoso.test/registry.json", + enabled: true, + }, + ]; + + manager.updateUserSettings({ marketplaceSources: sources }); + + expect(manager.getMarketplaceSources().find((source) => source.id === "builtin-pptb")?.enabled).toBe(false); + }); + }); + // ----------------------------------------------------------------------- // Favorite tools // -----------------------------------------------------------------------