From 7b8a09d29aaf7f776ef2d70d8b10d09c569d0aaa Mon Sep 17 00:00:00 2001 From: rrader26 Date: Tue, 12 May 2026 11:30:31 -0400 Subject: [PATCH 1/2] fix(plugins): swap speculative Remote backends for real ActivepiecesMemoryBackend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #35 shipped Remote{Memory,Recipe}Backend classes that targeted REST endpoints we never built — pure stubs. They were marked "v1 proposal" in their headers, which is the kind of speculative interface we don't want in production code. This PR removes the stubs and replaces them with a REAL ActivepiecesMemoryBackend that hits live endpoints in the user's Activepieces deployment: /v1/projects/:projectId/memory (project-scoped) /v1/projects/:projectId/chatbots/:chatbotId/memory (chatbot-scoped) /v1/projects/:projectId/chatbots/:chatbotId/memory/search (hybrid search) Auth: `Authorization: Bearer sk-` against the existing Activepieces Service-principal flow. The same Claude Code / Cursor / Codex agent connecting to agentmark now writes to **real Activepieces memory** instead of a speculative service — and gets hybrid vector + BM25 semantic search for free since agentmark_memory_search routes to /memory/search. Mapping (agentmark K/V → Activepieces rich shape): - agentmark `key` → metadata.agentmark_key - agentmark `value` → content + metadata.raw_value (preserves type) - agentmark `scope` → Activepieces scope (same five-level enum) - agentmark `scope.id` → metadata.scope_id - agentmark `tags` → metadata.tags - source stamp = "agentmark" (filterable; backends only see records they wrote) Recipes: NO ActivepiecesRecipeBackend ships in this PR because Activepieces has no recipes endpoints yet. RecipeBackend interface + LocalFileRecipeBackend remain; when the recipes service ships, a real implementation lands then. Removed (the stubs): - src/plugins/memory/remote-backend.ts - src/plugins/recipes/remote-backend.ts - test/memory/remote-backend.test.ts - test/recipes/remote-backend.test.ts Added: - src/plugins/memory/activepieces-backend.ts (real impl) - test/memory/activepieces-backend.test.ts (20 tests) Tests: 491 pass / 10 skip. Build clean. All prior tests pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 15 +- src/plugins/memory/activepieces-backend.ts | 361 +++++++++++++++++++++ src/plugins/memory/index.ts | 4 +- src/plugins/memory/remote-backend.ts | 170 ---------- src/plugins/recipes/index.ts | 2 - src/plugins/recipes/remote-backend.ts | 153 --------- test/memory/activepieces-backend.test.ts | 314 ++++++++++++++++++ test/memory/remote-backend.test.ts | 150 --------- test/recipes/remote-backend.test.ts | 134 -------- 9 files changed, 685 insertions(+), 618 deletions(-) create mode 100644 src/plugins/memory/activepieces-backend.ts delete mode 100644 src/plugins/memory/remote-backend.ts delete mode 100644 src/plugins/recipes/remote-backend.ts create mode 100644 test/memory/activepieces-backend.test.ts delete mode 100644 test/memory/remote-backend.test.ts delete mode 100644 test/recipes/remote-backend.test.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index c3b85c6..ddf31bd 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -102,11 +102,11 @@ export type { } from '../plugins/system' // Recipes Pack — durable named playbooks the agent learns once + replays. +// Local-file backend only for now; Activepieces recipes endpoints don't +// exist yet so we don't ship a speculative HTTP backend. export { createRecipesPlugin, LocalFileRecipeBackend, - RemoteRecipeBackend, - RemoteRecipeError, RecipeStore, RECIPES_TOOLS, } from '../plugins/recipes' @@ -119,17 +119,18 @@ export type { RecipeBackend, RecipeBackendDescription, LocalFileRecipeBackendConfig, - RemoteRecipeBackendConfig, } from '../plugins/recipes' // Memory Pack — hierarchical persistent memory for AI agents. Opt-in; // targeted at IDE coding-assistant integrations where per-session -// amnesia is the dominant UX limitation. +// amnesia is the dominant UX limitation. Two production-ready backends: +// LocalFile (default, single-user) and Activepieces (multi-user, hits +// the live ThinkFleet agent-memory API). export { createMemoryPlugin, LocalFileMemoryBackend, - RemoteMemoryBackend, - RemoteMemoryError, + ActivepiecesMemoryBackend, + ActivepiecesMemoryError, MemoryStore, MEMORY_TOOLS, } from '../plugins/memory' @@ -143,7 +144,7 @@ export type { MemoryBackendDescription, MemorySetInput, LocalFileMemoryBackendConfig, - RemoteMemoryBackendConfig, + ActivepiecesMemoryBackendConfig, } from '../plugins/memory' // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the diff --git a/src/plugins/memory/activepieces-backend.ts b/src/plugins/memory/activepieces-backend.ts new file mode 100644 index 0000000..c16c6fa --- /dev/null +++ b/src/plugins/memory/activepieces-backend.ts @@ -0,0 +1,361 @@ +/** + * Activepieces-backed memory backend. + * + * Wires the agentmark Memory plugin to the production Activepieces + * agent-memory API. Same `MemoryBackend` interface as the disk-backed + * store — the plugin layer + tool handlers don't know or care which + * backend they're talking to. + * + * The user's ThinkFleet stack already has a rich memory system + * (bi-temporal facts, hybrid vector+BM25 search, scope-aware + * confirmation workflow, knowledge graph). The simple key/value + * interface agentmark exposes to AI agents maps onto a subset of + * that system; this class is the bridge. + * + * Mapping (agentmark K/V → Activepieces rich shape): + * - agentmark `key` → Activepieces `metadata.agentmark_key` + * (Activepieces stores free-form `content`, not key/value pairs; + * we round-trip the key through metadata so get/deleteByKey can + * locate records.) + * - agentmark `value` → Activepieces `content` (string-coerced) + * + `metadata.raw_value` (preserves type) + * - agentmark `scope` → Activepieces `scope` (same five-level enum) + * - agentmark `scope.id` → `metadata.scope_id` + * - agentmark `tags` → `metadata.tags` (string[]) + * + * Auth: `Authorization: Bearer sk-` against the Activepieces + * Service-principal flow. The backend is scoped to one project; pass + * a `chatbotId` to target chatbot-scoped routes (richer create/search + * semantics), otherwise omit and the project-scoped routes are used. + */ +import type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' +import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' + +export interface ActivepiecesMemoryBackendConfig { + /** Base URL of the Activepieces API (no trailing slash). */ + baseUrl: string + /** API key (must start with `sk-`). Service principal. */ + apiKey: string + /** Project id this backend operates against. Required. */ + projectId: string + /** Optional chatbot id for chatbot-scoped routes. */ + chatbotId?: string + /** Injectable fetch (for tests). Default: globalThis.fetch. */ + fetch?: typeof fetch + /** Per-request timeout in ms. Default: 15000. */ + timeoutMs?: number + /** Activepieces `source` value to stamp on writes. Defaults to + * 'agentmark' so records this backend creates are filterable. */ + source?: string +} + +export class ActivepiecesMemoryError extends Error { + constructor( + message: string, + readonly status: number, + readonly body?: unknown, + ) { + super(message) + this.name = 'ActivepiecesMemoryError' + } +} + +/** Shape of one item in the Activepieces clawdbot_memory_item table. */ +interface ApMemoryItem { + id: string + platformId: string + projectId: string | null + chatbotId: string | null + type: string + content: string + category: string | null + importance: number + source: string | null + sessionKey: string | null + chatIdentityId: string | null + metadata: Record | null + scope: string + status: string + confidence: number + impact: string | null + confirmedAt: string | null + validAt: string + invalidAt: string | null + created: string + updated: string + similarity?: number +} + +const AP_KEY_FIELD = 'agentmark_key' +const AP_RAW_VALUE_FIELD = 'raw_value' +const AP_TAGS_FIELD = 'tags' + +export class ActivepiecesMemoryBackend implements MemoryBackend { + readonly baseUrl: string + readonly projectId: string + readonly chatbotId?: string + readonly source: string + private readonly apiKey: string + private readonly fetcher: typeof fetch + private readonly timeoutMs: number + + constructor(config: ActivepiecesMemoryBackendConfig) { + if (!config.baseUrl) throw new Error('ActivepiecesMemoryBackend: baseUrl is required.') + if (!config.apiKey) throw new Error('ActivepiecesMemoryBackend: apiKey is required.') + if (!config.apiKey.startsWith('sk-')) { + throw new Error('ActivepiecesMemoryBackend: apiKey must start with "sk-".') + } + if (!config.projectId) throw new Error('ActivepiecesMemoryBackend: projectId is required.') + + this.baseUrl = config.baseUrl.replace(/\/+$/, '') + this.apiKey = config.apiKey + this.projectId = config.projectId + this.chatbotId = config.chatbotId + this.source = config.source ?? 'agentmark' + this.fetcher = config.fetch ?? globalThis.fetch + this.timeoutMs = config.timeoutMs ?? 15_000 + } + + async set(input: MemorySetInput): Promise { + const scope = input.scope ?? { type: 'platform' as const } + const body = { + type: 'fact', + content: stringifyValue(input.value), + scope: scope.type, + source: this.source, + metadata: { + [AP_KEY_FIELD]: input.key, + [AP_RAW_VALUE_FIELD]: input.value, + ...(input.tags ? { [AP_TAGS_FIELD]: input.tags } : {}), + ...(scope.id ? { scope_id: scope.id } : {}), + }, + } + + // Match agentmark's "set replaces same key+scope" semantics by + // looking up the existing record and deleting it before create. + const existing = await this.findByKey(input.key, scope) + if (existing) await this.deleteById(existing.id) + + const created = await this.request('POST', this.memoryRoot(), body) + return apToAgentmark(created) + } + + async get(input: { key: string; scopes?: MemoryScope[] }): Promise { + const scopes = input.scopes ?? [{ type: 'platform' }] + for (const scope of scopes) { + const found = await this.findByKey(input.key, scope) + if (found) return apToAgentmark(found) + } + return null + } + + async deleteById(recordId: string): Promise { + try { + await this.request('DELETE', `${this.memoryRoot()}/${encodeURIComponent(recordId)}`) + return true + } catch (err) { + if (err instanceof ActivepiecesMemoryError && err.status === 404) return false + throw err + } + } + + async deleteByKey(key: string, scope: MemoryScope): Promise { + const found = await this.findByKey(key, scope) + if (!found) return false + return this.deleteById(found.id) + } + + async search(query: MemorySearchQuery): Promise { + // Activepieces' /memory/search requires a non-empty query for + // hybrid (vector + BM25) ranking. When agentmark callers didn't + // pass a query, fall back to a scope-filtered list so the + // MemoryBackend contract is preserved. + if (!query.query) return this.listFallback(query) + + const body: Record = { query: query.query, limit: query.limit ?? 10 } + if (query.scope) body.scope = query.scope.type + + const results = await this.request( + 'POST', + `${this.memoryRoot()}/search`, + body, + ) + + let records = results.map(apToAgentmark) + if (query.tags && query.tags.length > 0) { + records = records.filter((r) => + r.tags && r.tags.some((t) => query.tags!.includes(t)), + ) + } + // Activepieces' hybrid search already orders by similarity; let + // explicit sort_by override. + if (query.sort_by === 'recency') { + records.sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + } else if (query.sort_by === 'access_count') { + records.sort((a, b) => b.access_count - a.access_count) + } else if (query.sort_by === 'created') { + records.sort((a, b) => b.created_at.localeCompare(a.created_at)) + } + return records + } + + async list(scope?: MemoryScope, prefix?: string, limit = 200): Promise { + const params = new URLSearchParams() + if (scope?.type) params.set('scope', scope.type) + params.set('source', this.source) + params.set('limit', String(Math.min(limit, 100))) // server caps at 100/page + + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + let records = items.map(apToAgentmark) + if (prefix) records = records.filter((r) => r.key.startsWith(prefix)) + return records.slice(0, limit) + } + + async clear(): Promise { + // No bulk-delete endpoint; iterate with source filter so we only + // touch records this backend wrote. Bounded at 10 pages × 100 = + // 1000 records to avoid runaway loops on misconfigured servers. + const params = new URLSearchParams({ source: this.source, limit: '100' }) + for (let page = 0; page < 10; page++) { + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + if (items.length === 0) break + for (const item of items) await this.deleteById(item.id) + if (items.length < 100) break + } + } + + async describe(): Promise { + return { + kind: 'activepieces', + base_url: this.baseUrl, + project_id: this.projectId, + chatbot_id: this.chatbotId, + source: this.source, + } + } + + // ────────────────────────────────────────────────────────────────── + // Internals + // ────────────────────────────────────────────────────────────────── + + private memoryRoot(): string { + if (this.chatbotId) { + return `/v1/projects/${encodeURIComponent(this.projectId)}/chatbots/${encodeURIComponent(this.chatbotId)}/memory` + } + return `/v1/projects/${encodeURIComponent(this.projectId)}/memory` + } + + private async findByKey(key: string, scope: MemoryScope): Promise { + // List by scope + source (cheap server-side filter), then match + // key client-side. source=agentmark scopes us to records THIS + // backend wrote so we don't collide with manually-created + // Activepieces memories. + const params = new URLSearchParams({ + scope: scope.type, + source: this.source, + limit: '100', + }) + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + for (const item of items) { + const md = (item.metadata ?? {}) as Record + if (md[AP_KEY_FIELD] !== key) continue + // Strict scope_id match: distinguishes two project-scoped + // memories with the same key from different repos. + if (scope.id !== undefined && md.scope_id !== scope.id) continue + return item + } + return null + } + + private async listFallback(query: MemorySearchQuery): Promise { + const records = await this.list(query.scope, undefined, query.limit ?? 50) + if (query.tags && query.tags.length > 0) { + return records.filter((r) => + r.tags && r.tags.some((t) => query.tags!.includes(t)), + ) + } + return records + } + + private async request(method: string, p: string, body?: unknown): Promise { + const headers: Record = { + authorization: `Bearer ${this.apiKey}`, + } + if (body !== undefined) headers['content-type'] = 'application/json' + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + + let response: Response + try { + response = await this.fetcher(`${this.baseUrl}${p}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + + const text = await response.text() + let parsed: unknown = undefined + if (text.length > 0) { + try { parsed = JSON.parse(text) } catch { parsed = text } + } + + if (!response.ok) { + throw new ActivepiecesMemoryError( + `Activepieces memory ${method} ${p} failed: ${response.status} ${response.statusText}`, + response.status, + parsed, + ) + } + return parsed as T + } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'string') return value + if (value === undefined || value === null) return '' + return JSON.stringify(value) +} + +function apToAgentmark(item: ApMemoryItem): MemoryRecord { + const metadata = (item.metadata ?? {}) as Record + const storedKey = typeof metadata[AP_KEY_FIELD] === 'string' + ? metadata[AP_KEY_FIELD] as string + : item.id + const rawValue = AP_RAW_VALUE_FIELD in metadata + ? metadata[AP_RAW_VALUE_FIELD] + : item.content + const tags = Array.isArray(metadata[AP_TAGS_FIELD]) + ? metadata[AP_TAGS_FIELD] as string[] + : undefined + const scopeId = typeof metadata.scope_id === 'string' ? metadata.scope_id as string : undefined + + return { + record_id: item.id, + key: storedKey, + value: rawValue, + scope: { + type: item.scope as MemoryScope['type'], + id: scopeId, + }, + tags, + expires_at: item.invalidAt ? Date.parse(item.invalidAt) : undefined, + created_at: item.created, + updated_at: item.updated, + access_count: 0, + last_accessed_at: undefined, + } +} diff --git a/src/plugins/memory/index.ts b/src/plugins/memory/index.ts index 2775c76..e5d42d3 100644 --- a/src/plugins/memory/index.ts +++ b/src/plugins/memory/index.ts @@ -169,8 +169,8 @@ function optionalStringArray(v: unknown): string[] | undefined { export { LocalFileMemoryBackend, MemoryStore } from './store' export type { LocalFileMemoryBackendConfig, MemoryStoreConfig } from './store' -export { RemoteMemoryBackend, RemoteMemoryError } from './remote-backend' -export type { RemoteMemoryBackendConfig } from './remote-backend' +export { ActivepiecesMemoryBackend, ActivepiecesMemoryError } from './activepieces-backend' +export type { ActivepiecesMemoryBackendConfig } from './activepieces-backend' export type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' export { MEMORY_TOOLS } from './tool-defs' export type { diff --git a/src/plugins/memory/remote-backend.ts b/src/plugins/memory/remote-backend.ts deleted file mode 100644 index a389592..0000000 --- a/src/plugins/memory/remote-backend.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Remote (HTTP) memory backend. - * - * Hits a ThinkFleet memory service over HTTPS. Same agentmark binary - * works against on-prem deployments (internal URL, internal auth) and - * cloud deployments (api.thinkfleet.ai) — only the `baseUrl` + `token` - * differ. - * - * Auth: Bearer token. Optional `X-Workspace-Id` header scopes calls to - * a specific team/org so a single user can be a member of multiple - * workspaces. - * - * Endpoint contract (v1 proposal — subject to change before the - * service ships): - * - * POST /v1/memory/records { key, value, scope, tags?, ttl_seconds? } → MemoryRecord - * POST /v1/memory/records/get { key, scopes? } → MemoryRecord | null - * DELETE /v1/memory/records/:record_id → { deleted: boolean } - * DELETE /v1/memory/records { key, scope } → { deleted: boolean } - * POST /v1/memory/search MemorySearchQuery → MemoryRecord[] - * GET /v1/memory/records?scope_type=&scope_id=&prefix=&limit= → MemoryRecord[] - * DELETE /v1/memory/records/all → { cleared: true } - * GET /v1/memory/describe → MemoryBackendDescription - * - * Errors: non-2xx responses are surfaced as RemoteMemoryError with the - * status code + parsed JSON body (when JSON). Auth-related 401s also - * include the workspace header for easier debugging. - */ -import type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' -import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' - -export interface RemoteMemoryBackendConfig { - /** Base URL of the memory service (no trailing slash). */ - baseUrl: string - /** Bearer token. Required for all non-anonymous deployments. */ - token?: string - /** Optional workspace / team id sent as `X-Workspace-Id`. */ - workspaceId?: string - /** Injectable fetch (defaults to globalThis.fetch). Used by tests. */ - fetch?: typeof fetch - /** Per-request timeout in ms. Default: 15_000. */ - timeoutMs?: number -} - -export class RemoteMemoryError extends Error { - constructor( - message: string, - readonly status: number, - readonly body?: unknown, - ) { - super(message) - this.name = 'RemoteMemoryError' - } -} - -export class RemoteMemoryBackend implements MemoryBackend { - readonly baseUrl: string - readonly workspaceId?: string - private readonly token?: string - private readonly fetcher: typeof fetch - private readonly timeoutMs: number - - constructor(config: RemoteMemoryBackendConfig) { - if (!config.baseUrl) throw new Error('RemoteMemoryBackend: baseUrl is required.') - this.baseUrl = config.baseUrl.replace(/\/+$/, '') - this.token = config.token - this.workspaceId = config.workspaceId - this.fetcher = config.fetch ?? globalThis.fetch - this.timeoutMs = config.timeoutMs ?? 15_000 - } - - async set(input: MemorySetInput): Promise { - return await this.request('POST', '/v1/memory/records', { - key: input.key, - value: input.value, - scope: input.scope ?? { type: 'platform' }, - tags: input.tags, - ttl_seconds: input.ttlSeconds, - }) - } - - async get(input: { key: string; scopes?: MemoryScope[] }): Promise { - const result = await this.request('POST', '/v1/memory/records/get', input) - return result ?? null - } - - async deleteById(recordId: string): Promise { - const r = await this.request<{ deleted: boolean }>( - 'DELETE', - `/v1/memory/records/${encodeURIComponent(recordId)}`, - ) - return r.deleted === true - } - - async deleteByKey(key: string, scope: MemoryScope): Promise { - const r = await this.request<{ deleted: boolean }>('DELETE', '/v1/memory/records', { key, scope }) - return r.deleted === true - } - - async search(query: MemorySearchQuery): Promise { - return await this.request('POST', '/v1/memory/search', query) - } - - async list(scope?: MemoryScope, prefix?: string, limit?: number): Promise { - const params = new URLSearchParams() - if (scope?.type) params.set('scope_type', scope.type) - if (scope?.id) params.set('scope_id', scope.id) - if (prefix) params.set('prefix', prefix) - if (typeof limit === 'number') params.set('limit', String(limit)) - const qs = params.toString() - return await this.request( - 'GET', - `/v1/memory/records${qs ? '?' + qs : ''}`, - ) - } - - async clear(): Promise { - await this.request<{ cleared: true }>('DELETE', '/v1/memory/records/all') - } - - async describe(): Promise { - const remote = await this.request( - 'GET', - '/v1/memory/describe', - ).catch((): undefined => undefined) - return { - kind: 'remote-http', - base_url: this.baseUrl, - workspace_id: this.workspaceId, - ...(remote ?? {}), - } - } - - private async request(method: string, path: string, body?: unknown): Promise { - const headers: Record = {} - if (this.token) headers.authorization = `Bearer ${this.token}` - if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId - if (body !== undefined) headers['content-type'] = 'application/json' - - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), this.timeoutMs) - - let response: Response - try { - response = await this.fetcher(`${this.baseUrl}${path}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } - - const text = await response.text() - let parsed: unknown = undefined - if (text.length > 0) { - try { parsed = JSON.parse(text) } catch { parsed = text } - } - - if (!response.ok) { - throw new RemoteMemoryError( - `Memory service ${method} ${path} failed: ${response.status} ${response.statusText}`, - response.status, - parsed, - ) - } - return parsed as T - } -} diff --git a/src/plugins/recipes/index.ts b/src/plugins/recipes/index.ts index 6c43bb3..5f7ca9d 100644 --- a/src/plugins/recipes/index.ts +++ b/src/plugins/recipes/index.ts @@ -149,8 +149,6 @@ function recipeSummary(r: Recipe): Record { export { LocalFileRecipeBackend, RecipeStore } from './store' export type { LocalFileRecipeBackendConfig, RecipeStoreConfig } from './store' -export { RemoteRecipeBackend, RemoteRecipeError } from './remote-backend' -export type { RemoteRecipeBackendConfig } from './remote-backend' export type { RecipeBackend, RecipeBackendDescription } from './backend' export { resolveRecipe, substitute, applyParameterSchema } from './substitute' export { RECIPES_TOOLS } from './tool-defs' diff --git a/src/plugins/recipes/remote-backend.ts b/src/plugins/recipes/remote-backend.ts deleted file mode 100644 index 5c29fc8..0000000 --- a/src/plugins/recipes/remote-backend.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Remote (HTTP) recipe backend. - * - * Same auth + workspace pattern as RemoteMemoryBackend. The desktop - * app instantiates this when the user/org is configured for on-prem - * or cloud sync — recipes saved by one teammate become available to - * everyone in the workspace. - * - * Endpoint contract (v1 proposal): - * - * POST /v1/recipes Recipe → Recipe - * GET /v1/recipes/:name → Recipe | null - * GET /v1/recipes?target_app=... → Recipe[] - * DELETE /v1/recipes/:name → { deleted: boolean } - * DELETE /v1/recipes/all → { cleared: true } - * GET /v1/recipes/describe → RecipeBackendDescription - * - * Save semantics: server controls version + timestamps; the on_conflict - * flag is passed as a query param. - */ -import type { RecipeBackend, RecipeBackendDescription } from './backend' -import type { Recipe } from './types' - -export interface RemoteRecipeBackendConfig { - /** Base URL of the recipe service (no trailing slash). */ - baseUrl: string - /** Bearer token. */ - token?: string - /** Optional workspace / team id sent as `X-Workspace-Id`. */ - workspaceId?: string - /** Injectable fetch (defaults to globalThis.fetch). */ - fetch?: typeof fetch - /** Per-request timeout in ms. Default: 15_000. */ - timeoutMs?: number -} - -export class RemoteRecipeError extends Error { - constructor( - message: string, - readonly status: number, - readonly body?: unknown, - ) { - super(message) - this.name = 'RemoteRecipeError' - } -} - -export class RemoteRecipeBackend implements RecipeBackend { - readonly baseUrl: string - readonly workspaceId?: string - private readonly token?: string - private readonly fetcher: typeof fetch - private readonly timeoutMs: number - - constructor(config: RemoteRecipeBackendConfig) { - if (!config.baseUrl) throw new Error('RemoteRecipeBackend: baseUrl is required.') - this.baseUrl = config.baseUrl.replace(/\/+$/, '') - this.token = config.token - this.workspaceId = config.workspaceId - this.fetcher = config.fetch ?? globalThis.fetch - this.timeoutMs = config.timeoutMs ?? 15_000 - } - - async get(name: string): Promise { - try { - return await this.request('GET', `/v1/recipes/${encodeURIComponent(name)}`) - } catch (err) { - if (err instanceof RemoteRecipeError && err.status === 404) return null - throw err - } - } - - async list(filter?: { target_app?: string }): Promise { - const qs = filter?.target_app ? `?target_app=${encodeURIComponent(filter.target_app)}` : '' - return await this.request('GET', `/v1/recipes${qs}`) - } - - async save(recipe: Recipe, options: { on_conflict?: 'replace' | 'fail' } = {}): Promise { - const onConflict = options.on_conflict ?? 'fail' - return await this.request( - 'POST', - `/v1/recipes?on_conflict=${onConflict}`, - recipe, - ) - } - - async delete(name: string): Promise { - try { - const r = await this.request<{ deleted: boolean }>( - 'DELETE', - `/v1/recipes/${encodeURIComponent(name)}`, - ) - return r.deleted === true - } catch (err) { - if (err instanceof RemoteRecipeError && err.status === 404) return false - throw err - } - } - - async clear(): Promise { - await this.request<{ cleared: true }>('DELETE', '/v1/recipes/all') - } - - async describe(): Promise { - const remote = await this.request( - 'GET', - '/v1/recipes/describe', - ).catch((): undefined => undefined) - return { - kind: 'remote-http', - base_url: this.baseUrl, - workspace_id: this.workspaceId, - ...(remote ?? {}), - } - } - - private async request(method: string, p: string, body?: unknown): Promise { - const headers: Record = {} - if (this.token) headers.authorization = `Bearer ${this.token}` - if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId - if (body !== undefined) headers['content-type'] = 'application/json' - - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), this.timeoutMs) - - let response: Response - try { - response = await this.fetcher(`${this.baseUrl}${p}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } - - const text = await response.text() - let parsed: unknown = undefined - if (text.length > 0) { - try { parsed = JSON.parse(text) } catch { parsed = text } - } - - if (!response.ok) { - throw new RemoteRecipeError( - `Recipe service ${method} ${p} failed: ${response.status} ${response.statusText}`, - response.status, - parsed, - ) - } - return parsed as T - } -} diff --git a/test/memory/activepieces-backend.test.ts b/test/memory/activepieces-backend.test.ts new file mode 100644 index 0000000..92a2131 --- /dev/null +++ b/test/memory/activepieces-backend.test.ts @@ -0,0 +1,314 @@ +/** + * Tests for ActivepiecesMemoryBackend — request shape + response mapping. + * Mocks globalThis.fetch; no real network calls. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { ActivepiecesMemoryBackend, ActivepiecesMemoryError } from '../../src/plugins/memory' + +let originalFetch: typeof globalThis.fetch +let requests: Array<{ url: string; init?: RequestInit }> + +beforeEach(() => { + originalFetch = globalThis.fetch + requests = [] +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL | Request).toString() + requests.push({ url, init }) + return Promise.resolve(responder(url, init)) + }) as typeof globalThis.fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function apItem(overrides: Record = {}): Record { + return { + id: 'mem_default', + platformId: 'plat_1', + projectId: 'proj_1', + chatbotId: null, + type: 'fact', + content: 'pnpm build', + category: null, + importance: 5, + source: 'agentmark', + sessionKey: null, + chatIdentityId: null, + metadata: { + agentmark_key: 'build_command', + raw_value: 'pnpm build', + scope_id: 'proj_1', + }, + scope: 'project', + status: 'confirmed', + confidence: 1.0, + impact: null, + confirmedAt: null, + validAt: '2026-05-12T00:00:00Z', + invalidAt: null, + created: '2026-05-12T00:00:00Z', + updated: '2026-05-12T00:00:00Z', + ...overrides, + } +} + +const baseConfig = { + baseUrl: 'https://app.example.com', + apiKey: 'sk-test-1234', + projectId: 'proj_1', +} + +describe('ActivepiecesMemoryBackend — construction', () => { + it('requires baseUrl, sk-prefixed apiKey, and projectId', () => { + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, baseUrl: '' })).toThrow(/baseUrl/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, apiKey: '' })).toThrow(/apiKey/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, apiKey: 'not-sk-key' })).toThrow(/sk-/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, projectId: '' })).toThrow(/projectId/) + }) + + it('strips trailing slashes from baseUrl', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, baseUrl: 'https://x.com///' }) + await backend.list({ type: 'platform' }) + expect(requests[0].url.startsWith('https://x.com/v1/projects/')).toBe(true) + }) +}) + +describe('ActivepiecesMemoryBackend — auth + path shape', () => { + it('attaches Authorization Bearer header on every request', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list() + const headers = requests[0].init?.headers as Record + expect(headers.authorization).toBe('Bearer sk-test-1234') + }) + + it('targets project-scoped routes when chatbotId is omitted', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list() + expect(requests[0].url).toContain('/v1/projects/proj_1/memory') + expect(requests[0].url).not.toContain('/chatbots/') + }) + + it('targets chatbot-scoped routes when chatbotId is supplied', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, chatbotId: 'cb_42' }) + await backend.list() + expect(requests[0].url).toContain('/v1/projects/proj_1/chatbots/cb_42/memory') + }) +}) + +describe('ActivepiecesMemoryBackend — set', () => { + it('looks for existing record + deletes it before creating new one', async () => { + mockFetch((url, init) => { + if (init?.method === 'GET') return jsonResponse([apItem({ id: 'mem_existing' })]) + if (init?.method === 'DELETE') return jsonResponse({}) + return jsonResponse(apItem({ + id: 'mem_new', + content: 'pnpm test', + metadata: { agentmark_key: 'build_command', raw_value: 'pnpm test', scope_id: 'proj_1' }, + })) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.set({ + key: 'build_command', + value: 'pnpm test', + scope: { type: 'project', id: 'proj_1' }, + }) + + expect(requests.map((r) => r.init?.method)).toEqual(['GET', 'DELETE', 'POST']) + const postBody = JSON.parse(requests[2].init?.body as string) + expect(postBody.type).toBe('fact') + expect(postBody.scope).toBe('project') + expect(postBody.source).toBe('agentmark') + expect(postBody.metadata.agentmark_key).toBe('build_command') + expect(postBody.metadata.raw_value).toBe('pnpm test') + expect(postBody.metadata.scope_id).toBe('proj_1') + expect(record.record_id).toBe('mem_new') + expect(record.value).toBe('pnpm test') + }) + + it('JSON-stringifies non-string values; preserves raw value in metadata', async () => { + const captured: Array<{ body: unknown }> = [] + mockFetch((_url, init) => { + if (init?.method === 'GET') return jsonResponse([]) + if (init?.method === 'POST') { + captured.push({ body: JSON.parse(init.body as string) }) + return jsonResponse(apItem({ content: '{"x":1}' })) + } + return jsonResponse({}) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.set({ key: 'cfg', value: { x: 1 } }) + + const sent = captured[0]?.body as { content: string; metadata: Record } + expect(sent.content).toBe('{"x":1}') + expect(sent.metadata.raw_value).toEqual({ x: 1 }) + }) +}) + +describe('ActivepiecesMemoryBackend — get + deleteByKey', () => { + it('walks scopes in order; returns first match', async () => { + let call = 0 + mockFetch(() => { + call += 1 + if (call === 1) return jsonResponse([]) + return jsonResponse([apItem({ id: 'mem_platform' })]) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ + key: 'build_command', + scopes: [{ type: 'project', id: 'proj_1' }, { type: 'platform' }], + }) + expect(record?.record_id).toBe('mem_platform') + }) + + it('returns null when no scope has the key', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ key: 'missing', scopes: [{ type: 'platform' }] }) + expect(record).toBeNull() + }) + + it('disambiguates by scope_id when scope.id is supplied', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'mem_other', metadata: { agentmark_key: 'build_command', scope_id: '/repo/b' } }), + apItem({ id: 'mem_mine', metadata: { agentmark_key: 'build_command', scope_id: '/repo/a' } }), + ])) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ + key: 'build_command', + scopes: [{ type: 'project', id: '/repo/a' }], + }) + expect(record?.record_id).toBe('mem_mine') + }) + + it('deleteByKey looks up + deletes', async () => { + mockFetch((_url, init) => { + if (init?.method === 'GET') return jsonResponse([apItem({ id: 'mem_to_delete' })]) + return jsonResponse({}) + }) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const ok = await backend.deleteByKey('build_command', { type: 'platform' }) + expect(ok).toBe(true) + expect(requests[1].init?.method).toBe('DELETE') + expect(requests[1].url).toContain('/memory/mem_to_delete') + }) + + it('deleteByKey returns false when no match', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + expect(await backend.deleteByKey('missing', { type: 'platform' })).toBe(false) + }) +}) + +describe('ActivepiecesMemoryBackend — search', () => { + it('routes text queries to /memory/search with scope filter', async () => { + let captured: { body: unknown } | null = null + mockFetch((_url, init) => { + captured = { body: JSON.parse(init?.body as string) } + return jsonResponse([apItem({ id: 'mem_hit', similarity: 0.91 })]) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.search({ + query: 'how do I build', + scope: { type: 'project' }, + limit: 25, + }) + expect(requests[0].url).toContain('/memory/search') + expect(captured?.body).toEqual({ query: 'how do I build', scope: 'project', limit: 25 }) + expect(results[0].record_id).toBe('mem_hit') + }) + + it('falls back to listing when no query is supplied', async () => { + mockFetch(() => jsonResponse([apItem()])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.search({ scope: { type: 'platform' }, limit: 10 }) + expect(requests[0].init?.method).toBe('GET') + expect(requests[0].url).toContain('/memory?') + }) + + it('filters search results client-side by tags', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'mem_with', metadata: { agentmark_key: 'k1', tags: ['build', 'fast'] } }), + apItem({ id: 'mem_without', metadata: { agentmark_key: 'k2' } }), + ])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.search({ query: 'x', tags: ['build'] }) + expect(results).toHaveLength(1) + expect(results[0].record_id).toBe('mem_with') + }) +}) + +describe('ActivepiecesMemoryBackend — list', () => { + it('passes scope + source + limit; caps limit at 100', async () => { + mockFetch(() => jsonResponse([apItem()])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list({ type: 'project', id: '/repo' }, undefined, 999) + const url = requests[0].url + expect(url).toContain('scope=project') + expect(url).toContain('source=agentmark') + expect(url).toContain('limit=100') + }) + + it('filters by prefix client-side', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'a', metadata: { agentmark_key: 'build_a' } }), + apItem({ id: 'b', metadata: { agentmark_key: 'test_b' } }), + apItem({ id: 'c', metadata: { agentmark_key: 'build_c' } }), + ])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.list(undefined, 'build_') + expect(results.map((r) => r.key).sort()).toEqual(['build_a', 'build_c']) + }) +}) + +describe('ActivepiecesMemoryBackend — errors + describe', () => { + it('throws ActivepiecesMemoryError on non-2xx with status + body', async () => { + mockFetch(() => jsonResponse({ error: 'forbidden' }, 403)) + const backend = new ActivepiecesMemoryBackend(baseConfig) + try { + await backend.list() + throw new Error('should not reach') + } catch (err) { + expect(err).toBeInstanceOf(ActivepiecesMemoryError) + expect((err as ActivepiecesMemoryError).status).toBe(403) + expect((err as ActivepiecesMemoryError).body).toEqual({ error: 'forbidden' }) + } + }) + + it('deleteById returns false on 404 instead of throwing', async () => { + mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) + const backend = new ActivepiecesMemoryBackend(baseConfig) + expect(await backend.deleteById('mem_missing')).toBe(false) + }) + + it('describe reports kind=activepieces + project + chatbot + source', async () => { + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, chatbotId: 'cb_x' }) + const desc = await backend.describe() + expect(desc).toEqual({ + kind: 'activepieces', + base_url: 'https://app.example.com', + project_id: 'proj_1', + chatbot_id: 'cb_x', + source: 'agentmark', + }) + }) +}) diff --git a/test/memory/remote-backend.test.ts b/test/memory/remote-backend.test.ts deleted file mode 100644 index 2799437..0000000 --- a/test/memory/remote-backend.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Tests for RemoteMemoryBackend — HTTP shape + auth + error handling. - * fetch is mocked; no real network calls. - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { RemoteMemoryBackend, RemoteMemoryError } from '../../src/plugins/memory' - -let originalFetch: typeof globalThis.fetch -let requests: Array<{ url: string; init?: RequestInit }> - -beforeEach(() => { - originalFetch = globalThis.fetch - requests = [] -}) - -afterEach(() => { - globalThis.fetch = originalFetch - vi.restoreAllMocks() -}) - -function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input as URL | Request).toString() - requests.push({ url, init }) - return Promise.resolve(responder(url, init)) - }) as typeof globalThis.fetch -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -describe('RemoteMemoryBackend — request shape', () => { - it('attaches Bearer token + X-Workspace-Id when configured', async () => { - mockFetch(() => jsonResponse({ record_id: 'mem_x', key: 'k', value: 'v', scope: { type: 'platform' }, version: 1, created_at: '', updated_at: '', access_count: 0 })) - - const backend = new RemoteMemoryBackend({ - baseUrl: 'https://memory.example.com', - token: 'secret', - workspaceId: 'ws_123', - }) - await backend.set({ key: 'k', value: 'v' }) - - expect(requests).toHaveLength(1) - const headers = requests[0].init?.headers as Record - expect(headers.authorization).toBe('Bearer secret') - expect(headers['x-workspace-id']).toBe('ws_123') - expect(headers['content-type']).toBe('application/json') - }) - - it('strips trailing slashes from baseUrl', async () => { - mockFetch(() => jsonResponse(null)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://memory.example.com///' }) - await backend.get({ key: 'k' }) - expect(requests[0].url).toBe('https://memory.example.com/v1/memory/records/get') - }) - - it('throws if baseUrl is missing', () => { - expect(() => new RemoteMemoryBackend({ baseUrl: '' })).toThrow(/baseUrl is required/) - }) -}) - -describe('RemoteMemoryBackend — verb mapping', () => { - it('set → POST /v1/memory/records', async () => { - mockFetch(() => jsonResponse({ record_id: 'r', key: 'k', value: 'v', scope: { type: 'platform' }, created_at: '', updated_at: '', access_count: 0 })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.set({ key: 'k', value: 'v', tags: ['t'], ttlSeconds: 60 }) - - expect(requests[0].url).toBe('https://x.com/v1/memory/records') - expect(requests[0].init?.method).toBe('POST') - const body = JSON.parse(requests[0].init?.body as string) - expect(body).toEqual({ - key: 'k', - value: 'v', - scope: { type: 'platform' }, - tags: ['t'], - ttl_seconds: 60, - }) - }) - - it('get → POST /v1/memory/records/get', async () => { - mockFetch(() => jsonResponse(null)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const result = await backend.get({ key: 'k' }) - expect(result).toBeNull() - expect(requests[0].url).toBe('https://x.com/v1/memory/records/get') - expect(requests[0].init?.method).toBe('POST') - }) - - it('deleteById → DELETE /v1/memory/records/:id', async () => { - mockFetch(() => jsonResponse({ deleted: true })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const ok = await backend.deleteById('mem_abc') - expect(ok).toBe(true) - expect(requests[0].url).toBe('https://x.com/v1/memory/records/mem_abc') - expect(requests[0].init?.method).toBe('DELETE') - }) - - it('list builds the correct query string', async () => { - mockFetch(() => jsonResponse([])) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.list({ type: 'project', id: '/repo' }, 'build_', 25) - expect(requests[0].url).toBe('https://x.com/v1/memory/records?scope_type=project&scope_id=%2Frepo&prefix=build_&limit=25') - expect(requests[0].init?.method).toBe('GET') - }) - - it('search → POST /v1/memory/search', async () => { - mockFetch(() => jsonResponse([])) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.search({ query: 'foo', tags: ['build'] }) - expect(requests[0].url).toBe('https://x.com/v1/memory/search') - expect(JSON.parse(requests[0].init?.body as string)).toEqual({ query: 'foo', tags: ['build'] }) - }) -}) - -describe('RemoteMemoryBackend — error handling', () => { - it('throws RemoteMemoryError with status + body on non-2xx', async () => { - mockFetch(() => jsonResponse({ error: 'unauthorized' }, 401)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com', token: 'bad' }) - try { - await backend.set({ key: 'k', value: 'v' }) - throw new Error('should not reach') - } catch (err) { - expect(err).toBeInstanceOf(RemoteMemoryError) - expect((err as RemoteMemoryError).status).toBe(401) - expect((err as RemoteMemoryError).body).toEqual({ error: 'unauthorized' }) - } - }) - - it('describe() returns "remote-http" kind even when the service has no /describe endpoint', async () => { - mockFetch(() => new Response('', { status: 404 })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com', workspaceId: 'ws_1' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.base_url).toBe('https://x.com') - expect(desc.workspace_id).toBe('ws_1') - }) - - it('describe() merges remote server-supplied fields when available', async () => { - mockFetch(() => jsonResponse({ total_records: 42, region: 'us-east-1' })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.total_records).toBe(42) - expect(desc.region).toBe('us-east-1') - }) -}) diff --git a/test/recipes/remote-backend.test.ts b/test/recipes/remote-backend.test.ts deleted file mode 100644 index 9db5761..0000000 --- a/test/recipes/remote-backend.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Tests for RemoteRecipeBackend — HTTP shape + 404 handling. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { RemoteRecipeBackend, RemoteRecipeError } from '../../src/plugins/recipes' -import type { Recipe } from '../../src/plugins/recipes' - -let originalFetch: typeof globalThis.fetch -let requests: Array<{ url: string; init?: RequestInit }> - -beforeEach(() => { - originalFetch = globalThis.fetch - requests = [] -}) - -afterEach(() => { - globalThis.fetch = originalFetch -}) - -function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input as URL | Request).toString() - requests.push({ url, init }) - return Promise.resolve(responder(url, init)) - }) as typeof globalThis.fetch -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -const SAMPLE: Recipe = { - name: 'test-recipe', - steps: [{ tool: 'agentmark_desktop_execute', args: {} }], - version: 1, - created_at: '', - updated_at: '', -} - -describe('RemoteRecipeBackend — verb mapping', () => { - it('save → POST /v1/recipes with on_conflict query param', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.save(SAMPLE, { on_conflict: 'replace' }) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?on_conflict=replace') - expect(requests[0].init?.method).toBe('POST') - }) - - it('save without on_conflict defaults to "fail"', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.save(SAMPLE) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?on_conflict=fail') - }) - - it('get → GET /v1/recipes/:name', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const r = await backend.get('test-recipe') - expect(r?.name).toBe('test-recipe') - expect(requests[0].url).toBe('https://r.example.com/v1/recipes/test-recipe') - expect(requests[0].init?.method).toBe('GET') - }) - - it('list with target_app builds the query string', async () => { - mockFetch(() => jsonResponse([SAMPLE])) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.list({ target_app: 'excel' }) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?target_app=excel') - }) - - it('delete → DELETE /v1/recipes/:name', async () => { - mockFetch(() => jsonResponse({ deleted: true })) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const ok = await backend.delete('to-go') - expect(ok).toBe(true) - expect(requests[0].init?.method).toBe('DELETE') - }) -}) - -describe('RemoteRecipeBackend — 404 handling', () => { - it('get returns null on 404 (instead of throwing)', async () => { - mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const r = await backend.get('missing') - expect(r).toBeNull() - }) - - it('delete returns false on 404 (instead of throwing)', async () => { - mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const ok = await backend.delete('missing') - expect(ok).toBe(false) - }) - - it('other 4xx/5xx throw RemoteRecipeError', async () => { - mockFetch(() => jsonResponse({ error: 'server_error' }, 500)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - try { - await backend.get('boom') - throw new Error('should not reach') - } catch (err) { - expect(err).toBeInstanceOf(RemoteRecipeError) - expect((err as RemoteRecipeError).status).toBe(500) - } - }) -}) - -describe('RemoteRecipeBackend — describe', () => { - it('returns kind=remote-http with base_url + workspace_id', async () => { - mockFetch(() => new Response('', { status: 404 })) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com', workspaceId: 'ws_1' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.base_url).toBe('https://r.example.com') - expect(desc.workspace_id).toBe('ws_1') - }) -}) - -describe('Backend injection — plugin acceptance', () => { - it('createRecipesPlugin accepts a custom backend', async () => { - const { createRecipesPlugin } = await import('../../src/plugins/recipes') - const plugin = createRecipesPlugin({ - backend: new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }), - }) - expect(plugin.name).toBe('recipes') - // describeSessions reports "custom" when a non-LocalFile backend is used. - const info = plugin.describeSessions?.() - expect((info as { recipes: { kind: string } }).recipes.kind).toBe('custom') - }) -}) From af083c78771a3cb9ced53818dff3dbb8c3b9a56b Mon Sep 17 00:00:00 2001 From: rrader26 Date: Tue, 12 May 2026 11:54:26 -0400 Subject: [PATCH 2/2] feat(packaging): macOS .pkg + Windows .msi installer scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the delivery vehicle for ThinkFleet Desktop. Each installer bundles the agentmark MCP server + the platform bridge + a pinned Node runtime so end users don't need to install Node themselves — just double-click and `agentmark-mcp` is on their PATH. Files: packaging/ README.md operator instructions + signing-secret docs scripts/ common.sh pinned Node version + shared helpers build-macos.sh pnpm build → Swift bridge → pkgbuild → productbuild build-windows.ps1 pnpm build → dotnet publish → WiX 4 → MSI templates/ launcher.sh relocatable POSIX launcher launcher.cmd relocatable Windows launcher distribution.xml productbuild manifest AgentMark.wxs WiX 4 MSI manifest macos-pkg-scripts/ postinstall symlinks /usr/local/bin/agentmark-mcp .github/workflows/release.yml tag-triggered release; builds both platforms in parallel + uploads to a draft GitHub Release What ships in each installer: macOS install to /opt/thinkfleet/agentmark/ ├── node (pinned 22.11.0 LTS, host arch) ├── agentmark/ (dist + schema + package.json + prod deps) ├── bridges/agentmark-bridge-macos └── bin/agentmark-mcp (launcher; symlinked into /usr/local/bin) Windows install to C:\Program Files\ThinkFleet\AgentMark\ ├── node.exe (pinned 22.11.0) ├── agentmark\ (dist + schema + package.json + prod deps) ├── bridges\agentmark-bridge-windows.exe + companion DLLs └── agentmark-mcp.cmd (launcher; install dir added to PATH) What's deliberately NOT bundled: - Playwright Chromium (~150MB; only needed for the browser plugin). The web plugin documents `playwright install chromium` as a one-time post-install step for users who want it. - Activepieces backend wiring. That's per-deployment config the operator sets via env vars; not part of the installer. Signing: optional, conditional on encrypted GitHub secrets: macOS: APPLE_DEVELOPER_ID + APPLE_CERT_P12_BASE64 + notarisation creds Windows: WINDOWS_CERT_PFX_BASE64 + WINDOWS_CERT_PFX_PASSWORD Missing secrets = build skips signing, produces unsigned installers suitable for internal testing. Production signing is a one-time cert setup; the workflow + scripts are signing-ready out of the box. Pinned versions (single source of truth in packaging/scripts/common.sh and packaging/scripts/build-windows.ps1): Node 22.11.0 LTS .NET 8 (for the Windows UIA bridge) Swift 5.9+ (for the macOS AXAPI bridge) WiX 4 (dotnet tool, installed by the workflow if missing) Release flow: tag a version (e.g. `v0.12.0`). GH Actions builds both installers in matrix, uploads to a draft GitHub Release. Tester runs the .pkg or .msi; `agentmark-mcp --help` works immediately afterward. Linux AppImage scaffolding lands as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 134 +++++++++++++++ packaging/README.md | 96 +++++++++++ packaging/scripts/build-macos.sh | 157 ++++++++++++++++++ packaging/scripts/build-windows.ps1 | 154 +++++++++++++++++ packaging/scripts/common.sh | 51 ++++++ packaging/templates/AgentMark.wxs | 57 +++++++ packaging/templates/distribution.xml | 34 ++++ packaging/templates/launcher.cmd | 21 +++ packaging/templates/launcher.sh | 30 ++++ .../templates/macos-pkg-scripts/postinstall | 27 +++ 10 files changed, 761 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 packaging/README.md create mode 100755 packaging/scripts/build-macos.sh create mode 100644 packaging/scripts/build-windows.ps1 create mode 100755 packaging/scripts/common.sh create mode 100644 packaging/templates/AgentMark.wxs create mode 100644 packaging/templates/distribution.xml create mode 100644 packaging/templates/launcher.cmd create mode 100755 packaging/templates/launcher.sh create mode 100755 packaging/templates/macos-pkg-scripts/postinstall diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9aebe2b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,134 @@ +name: Release + +# Triggers on version tags (e.g. v0.12.0). Builds installers on every +# target platform in parallel and attaches them to the GitHub Release. +# Signing is conditional on the matching secret being present — missing +# secrets skip signing rather than failing the build, so this workflow +# works for internal-test releases before code-signing certs are in place. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag name (e.g. v0.12.0) — used to name artifacts when running manually' + required: false + +jobs: + # ────────────────────────────────────────────────────────────────── + # macOS — produces AgentMark--macos.pkg + # ────────────────────────────────────────────────────────────────── + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node (build-time) + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Build .pkg + env: + # Optional signing — workflow runs without these too. + APPLE_DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} + APPLE_APP_NOTARIZATION_USER: ${{ secrets.APPLE_APP_NOTARIZATION_USER }} + APPLE_APP_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_APP_NOTARIZATION_TEAM_ID }} + APPLE_APP_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_APP_NOTARIZATION_PASSWORD }} + run: | + # Import code-signing cert into a temporary keychain if provided. + # The .p12 is base64-encoded in the secret. + if [[ -n "${{ secrets.APPLE_CERT_P12_BASE64 }}" ]]; then + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain + KEYCHAIN_PASSWORD=$(uuidgen) + CERT_PATH=$RUNNER_TEMP/codesign.p12 + echo -n "${{ secrets.APPLE_CERT_P12_BASE64 }}" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -P "${{ secrets.APPLE_CERT_P12_PASSWORD }}" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychain -d user | xargs) + rm "$CERT_PATH" + fi + + chmod +x packaging/scripts/*.sh + packaging/scripts/build-macos.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: agentmark-macos + path: dist/installer/*.pkg + if-no-files-found: error + + # ────────────────────────────────────────────────────────────────── + # Windows — produces AgentMark--windows.msi + # ────────────────────────────────────────────────────────────────── + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node (build-time) + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Build .msi + env: + WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} + WINDOWS_CERT_PFX_PASSWORD: ${{ secrets.WINDOWS_CERT_PFX_PASSWORD }} + shell: pwsh + run: | + .\packaging\scripts\build-windows.ps1 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: agentmark-windows + path: dist/installer/*.msi + if-no-files-found: error + + # ────────────────────────────────────────────────────────────────── + # Attach artifacts to the GitHub Release (only on tag pushes). + # ────────────────────────────────────────────────────────────────── + publish: + if: startsWith(github.ref, 'refs/tags/v') + needs: [build-macos, build-windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Publish to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/agentmark-macos/*.pkg + artifacts/agentmark-windows/*.msi + draft: true + fail_on_unmatched_files: true diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..41424a2 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,96 @@ +# AgentMark Installers + +This directory builds **production installers** for the agentmark MCP +server + bridges, bundled with a pinned Node runtime so end users +don't need to install Node themselves. + +## What ships + +Each installer drops these files onto the target machine: + +| Platform | Install path | Contents | +|---|---|---| +| macOS | `/opt/thinkfleet/agentmark/` | `node` (arm64 + x64 universal), `agentmark/` (npm package), `bridges/agentmark-bridge-macos` (Swift AXAPI binary), `bin/agentmark-mcp` launcher script | +| Windows | `C:\Program Files\ThinkFleet\AgentMark\` | `node.exe`, `agentmark\` (npm package), `bridges\agentmark-bridge-windows.exe` (.NET UIA), `agentmark-mcp.cmd` launcher (added to PATH) | + +Both installers add an `agentmark-mcp` command to the user's PATH that +runs the bundled Node against the bundled agentmark package. + +## What's NOT bundled + +- **Playwright Chromium.** Adds ~150MB and most users won't need the + browser-driving plugin. The installer wires up a post-install step + that runs `playwright install chromium` only if the user opted in. +- **Activepieces backend.** That's a separate service the user + configures via env vars when they want the cloud-backed Memory. + +## Building + +### macOS + +Requires: macOS host, Xcode CLI tools, Swift 5.9+. + +```sh +./packaging/scripts/build-macos.sh +# Produces: dist/installer/AgentMark--macos.pkg +``` + +### Windows + +Requires: Windows host (or GitHub Actions windows-latest), .NET 8 SDK, +WiX Toolset 4. + +```ps1 +.\packaging\scripts\build-windows.ps1 +# Produces: dist\installer\AgentMark--windows.msi +``` + +### Linux (future) + +AppImage scaffolding lives in a follow-up PR. + +## Code signing + +Signing is **optional** in the build scripts — they detect required +secrets and run signing steps only when present. The full release +flow is meant to run in GitHub Actions, which injects certs from +encrypted secrets. + +**macOS:** requires an Apple Developer ID Application certificate +imported into the runner's keychain. Set these GitHub secrets: + +- `APPLE_DEVELOPER_ID` — Common Name of the cert (e.g. + `Developer ID Application: ThinkFleet AI, Inc. (TEAMID)`) +- `APPLE_APP_NOTARIZATION_USER` — your App Store Connect Apple ID +- `APPLE_APP_NOTARIZATION_TEAM_ID` — 10-char team ID +- `APPLE_APP_NOTARIZATION_PASSWORD` — app-specific password +- `APPLE_CERT_P12_BASE64` — base64 of the .p12 file (export from + Keychain Access) +- `APPLE_CERT_P12_PASSWORD` — password protecting the .p12 + +**Windows:** requires an Authenticode code-signing certificate. + +- `WINDOWS_CERT_PFX_BASE64` — base64 of the .pfx +- `WINDOWS_CERT_PFX_PASSWORD` — password protecting the .pfx + +Without these secrets, the build scripts skip signing and produce +unsigned artifacts (suitable for internal testing; macOS Gatekeeper + +Windows SmartScreen will warn end users). + +## Release flow + +Tag a release (e.g. `v0.12.0`). GitHub Actions builds all platforms +in parallel and uploads artifacts to the GitHub Release. See +`.github/workflows/release.yml`. + +## Pinned versions + +| Component | Version | Where | +|---|---|---| +| Node | 22.11.0 (LTS) | `packaging/scripts/common.sh:NODE_VERSION` | +| .NET | 8.0 | bridge `csproj` | +| Swift | 5.9+ | bridge `Package.swift` | +| WiX | 4.x | installed via `dotnet tool install --global wix` in the workflow | + +Bumping Node: update `NODE_VERSION` in `common.sh`, then rebuild on +each platform. diff --git a/packaging/scripts/build-macos.sh b/packaging/scripts/build-macos.sh new file mode 100755 index 0000000..c19b35b --- /dev/null +++ b/packaging/scripts/build-macos.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Build the macOS .pkg installer. +# +# Pipeline: +# 1. Build the npm package (pnpm build). +# 2. Build the Swift AXAPI bridge in release mode (universal arm64+x64). +# 3. Download the pinned Node binary (arm64 + x64 if needed). +# 4. Assemble the install staging directory. +# 5. Run pkgbuild + productbuild to produce the .pkg. +# 6. (Optional) Sign + notarise if Apple secrets are present. +# +# Output: dist/installer/AgentMark--macos.pkg +# +# Designed to run BOTH on a developer's Mac and in GitHub Actions +# macos-latest. Signing is conditional on env vars; missing certs are +# logged but don't fail the build. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=./common.sh +source "$SCRIPT_DIR/common.sh" + +VERSION="$(read_pkg_version "$REPO_ROOT/package.json")" +BUILD_DIR="$REPO_ROOT/dist/installer-build/macos" +OUT_DIR="$REPO_ROOT/dist/installer" +STAGE_DIR="$BUILD_DIR/stage/opt/thinkfleet/agentmark" +PKG_OUT="$OUT_DIR/AgentMark-${VERSION}-macos.pkg" + +mkdir -p "$BUILD_DIR" "$OUT_DIR" +rm -rf "$BUILD_DIR/stage" +mkdir -p "$STAGE_DIR" + +# ────────────────────────────────────────────────────────────────────── +# 1. Build the npm package +# ────────────────────────────────────────────────────────────────────── +section "Build npm package" +cd "$REPO_ROOT" +pnpm install --frozen-lockfile +pnpm build + +# ────────────────────────────────────────────────────────────────────── +# 2. Build the Swift bridge (release, universal) +# ────────────────────────────────────────────────────────────────────── +section "Build macOS AXAPI bridge" +cd "$REPO_ROOT/apps/agent-runner/bridges/macos" +swift build -c release --arch arm64 --arch x86_64 +BRIDGE_BIN="$REPO_ROOT/apps/agent-runner/bridges/macos/.build/apple/Products/Release/agentmark-bridge-macos" +[[ -f "$BRIDGE_BIN" ]] || die "Swift build did not produce expected binary at $BRIDGE_BIN" + +# ────────────────────────────────────────────────────────────────────── +# 3. Download pinned Node +# ────────────────────────────────────────────────────────────────────── +section "Download Node v${NODE_VERSION}" +HOST_ARCH="$(uname -m)" +NODE_TARGET="darwin-arm64" +if [[ "$HOST_ARCH" != "arm64" ]]; then + NODE_TARGET="darwin-x64" +fi +NODE_ARCHIVE="$(download_node "$NODE_TARGET" "$BUILD_DIR/node-download")" +NODE_EXTRACT_DIR="$BUILD_DIR/node-extract" +mkdir -p "$NODE_EXTRACT_DIR" +tar -xJf "$NODE_ARCHIVE" -C "$NODE_EXTRACT_DIR" +NODE_BIN="$NODE_EXTRACT_DIR/node-v${NODE_VERSION}-${NODE_TARGET}/bin/node" +[[ -x "$NODE_BIN" ]] || die "Extracted Node binary not found at $NODE_BIN" + +# ────────────────────────────────────────────────────────────────────── +# 4. Assemble staging tree +# ────────────────────────────────────────────────────────────────────── +section "Assemble installer staging" +mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/bridges" + +# Node runtime +cp "$NODE_BIN" "$STAGE_DIR/node" +chmod +x "$STAGE_DIR/node" + +# npm package: copy dist/ + schema/ + package.json + production deps +mkdir -p "$STAGE_DIR/agentmark" +cp -R "$REPO_ROOT/dist" "$STAGE_DIR/agentmark/dist" +cp -R "$REPO_ROOT/schema" "$STAGE_DIR/agentmark/schema" +cp "$REPO_ROOT/package.json" "$STAGE_DIR/agentmark/package.json" + +# Install production deps into the staging dir. --prod skips devDeps +# (vitest, eslint, etc.). Playwright Chromium is *not* downloaded here; +# the agent-side `agentmark-mcp install-browsers` step handles that +# when the web plugin is used. +( cd "$STAGE_DIR/agentmark" && \ + PNPM_DEPLOY_NO_FROZEN_LOCKFILE=true \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + pnpm install --prod --ignore-scripts ) + +# Bridge +cp "$BRIDGE_BIN" "$STAGE_DIR/bridges/agentmark-bridge-macos" +chmod +x "$STAGE_DIR/bridges/agentmark-bridge-macos" + +# Launcher script +cp "$SCRIPT_DIR/../templates/launcher.sh" "$STAGE_DIR/bin/agentmark-mcp" +chmod +x "$STAGE_DIR/bin/agentmark-mcp" + +# ────────────────────────────────────────────────────────────────────── +# 5. Build the .pkg +# ────────────────────────────────────────────────────────────────────── +section "Build .pkg" +COMPONENT_PKG="$BUILD_DIR/agentmark-component.pkg" + +# pkgbuild creates a single-component package. We pair it with +# productbuild so we can attach a Distribution.xml (which controls the +# installer UX — license screen, post-install symlink to /usr/local/bin). +pkgbuild \ + --root "$BUILD_DIR/stage" \ + --identifier "$BUNDLE_ID" \ + --version "$VERSION" \ + --install-location "/" \ + --scripts "$SCRIPT_DIR/../templates/macos-pkg-scripts" \ + "$COMPONENT_PKG" + +DISTRIBUTION_XML="$BUILD_DIR/distribution.xml" +sed "s/__VERSION__/$VERSION/g; s/__BUNDLE_ID__/$BUNDLE_ID/g; s/__DISPLAY_NAME__/$DISPLAY_NAME/g" \ + "$SCRIPT_DIR/../templates/distribution.xml" > "$DISTRIBUTION_XML" + +productbuild \ + --distribution "$DISTRIBUTION_XML" \ + --package-path "$BUILD_DIR" \ + --version "$VERSION" \ + "$PKG_OUT" + +# ────────────────────────────────────────────────────────────────────── +# 6. Sign + notarise (optional — only when Apple secrets are present) +# ────────────────────────────────────────────────────────────────────── +if [[ -n "${APPLE_DEVELOPER_ID:-}" ]]; then + section "Sign .pkg" + SIGNED_PKG="$BUILD_DIR/AgentMark-${VERSION}-macos-signed.pkg" + productsign \ + --sign "$APPLE_DEVELOPER_ID" \ + "$PKG_OUT" \ + "$SIGNED_PKG" + mv "$SIGNED_PKG" "$PKG_OUT" + + if [[ -n "${APPLE_APP_NOTARIZATION_USER:-}" && -n "${APPLE_APP_NOTARIZATION_TEAM_ID:-}" && -n "${APPLE_APP_NOTARIZATION_PASSWORD:-}" ]]; then + section "Notarise .pkg" + xcrun notarytool submit "$PKG_OUT" \ + --apple-id "$APPLE_APP_NOTARIZATION_USER" \ + --team-id "$APPLE_APP_NOTARIZATION_TEAM_ID" \ + --password "$APPLE_APP_NOTARIZATION_PASSWORD" \ + --wait + xcrun stapler staple "$PKG_OUT" + else + echo "Notarisation env vars not set; skipping. Apple Gatekeeper will warn end users." + fi +else + echo "APPLE_DEVELOPER_ID not set; skipping signing. The .pkg works but Gatekeeper will warn." +fi + +section "Done" +echo "Installer: $PKG_OUT" +ls -lh "$PKG_OUT" diff --git a/packaging/scripts/build-windows.ps1 b/packaging/scripts/build-windows.ps1 new file mode 100644 index 0000000..518a44d --- /dev/null +++ b/packaging/scripts/build-windows.ps1 @@ -0,0 +1,154 @@ +<# + Build the Windows .msi installer for the AgentMark MCP server + + Windows UIA bridge. + + Pipeline: + 1. Build the npm package (pnpm build) — usually already done in CI. + 2. Build the .NET UIA bridge (dotnet publish, self-contained). + 3. Download the pinned Node binary (win-x64). + 4. Assemble the install staging directory. + 5. Run WiX 4 (`dotnet wix build`) to produce the .msi. + 6. Sign with signtool if WINDOWS_CERT_PFX_BASE64 is set. + + Output: dist\installer\AgentMark--windows.msi + + Designed for windows-latest GitHub Actions runners; works on a + local Windows dev box too. Signing is conditional. +#> + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# ────────────────────────────────────────────────────────────────────── +# Pinned versions +# ────────────────────────────────────────────────────────────────────── +$NodeVersion = if ($env:NODE_VERSION) { $env:NODE_VERSION } else { '22.11.0' } +$BundleId = if ($env:BUNDLE_ID) { $env:BUNDLE_ID } else { 'ai.thinkfleet.agentmark' } +$DisplayName = if ($env:DISPLAY_NAME) { $env:DISPLAY_NAME } else { 'ThinkFleet AgentMark' } + +# Paths +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Resolve-Path (Join-Path $ScriptDir '..\..') +$BuildDir = Join-Path $RepoRoot 'dist\installer-build\windows' +$StageDir = Join-Path $BuildDir 'stage' +$OutDir = Join-Path $RepoRoot 'dist\installer' + +$PkgVersion = (node -e "console.log(require('$($RepoRoot.Path.Replace('\','/'))/package.json').version)") +$MsiOut = Join-Path $OutDir "AgentMark-$PkgVersion-windows.msi" + +New-Item -ItemType Directory -Force -Path $BuildDir, $OutDir | Out-Null +if (Test-Path $StageDir) { Remove-Item -Recurse -Force $StageDir } +New-Item -ItemType Directory -Force -Path $StageDir, "$StageDir\agentmark", "$StageDir\bridges" | Out-Null + +function Write-Section($msg) { + Write-Host '' + Write-Host "=== $msg ===" +} + +# ────────────────────────────────────────────────────────────────────── +# 1. Build the npm package +# ────────────────────────────────────────────────────────────────────── +Write-Section 'Build npm package' +Set-Location $RepoRoot +pnpm install --frozen-lockfile +pnpm build + +# ────────────────────────────────────────────────────────────────────── +# 2. Build the .NET UIA bridge (self-contained, win-x64) +# ────────────────────────────────────────────────────────────────────── +Write-Section 'Build Windows UIA bridge' +$BridgeProj = Join-Path $RepoRoot 'apps\agent-runner\bridges\windows\AgentMark.Bridge.Windows.csproj' +$BridgePublish = Join-Path $BuildDir 'bridge-publish' +dotnet publish $BridgeProj -c Release -r win-x64 --self-contained true -o $BridgePublish +$BridgeBin = Join-Path $BridgePublish 'agentmark-bridge-windows.exe' +if (-not (Test-Path $BridgeBin)) { + throw "dotnet publish did not produce $BridgeBin" +} + +# ────────────────────────────────────────────────────────────────────── +# 3. Download pinned Node +# ────────────────────────────────────────────────────────────────────── +Write-Section "Download Node v$NodeVersion" +$NodeZip = Join-Path $BuildDir 'node.zip' +$NodeUrl = "https://nodejs.org/dist/v$NodeVersion/node-v$NodeVersion-win-x64.zip" +Invoke-WebRequest -Uri $NodeUrl -OutFile $NodeZip +$NodeExtract = Join-Path $BuildDir 'node-extract' +if (Test-Path $NodeExtract) { Remove-Item -Recurse -Force $NodeExtract } +Expand-Archive -Path $NodeZip -DestinationPath $NodeExtract +$NodeExe = Join-Path $NodeExtract "node-v$NodeVersion-win-x64\node.exe" +if (-not (Test-Path $NodeExe)) { + throw "Extracted Node binary not found at $NodeExe" +} + +# ────────────────────────────────────────────────────────────────────── +# 4. Assemble staging tree +# ────────────────────────────────────────────────────────────────────── +Write-Section 'Assemble installer staging' + +Copy-Item $NodeExe (Join-Path $StageDir 'node.exe') +Copy-Item $BridgeBin (Join-Path $StageDir 'bridges\agentmark-bridge-windows.exe') + +# Bridge has companion DLLs (FlaUI, etc.) — copy the whole publish dir. +Get-ChildItem $BridgePublish -File | Where-Object { $_.Name -ne 'agentmark-bridge-windows.exe' } | ForEach-Object { + Copy-Item $_.FullName (Join-Path $StageDir 'bridges') +} + +# npm package +Copy-Item -Recurse (Join-Path $RepoRoot 'dist') (Join-Path $StageDir 'agentmark\dist') +Copy-Item -Recurse (Join-Path $RepoRoot 'schema') (Join-Path $StageDir 'agentmark\schema') +Copy-Item (Join-Path $RepoRoot 'package.json') (Join-Path $StageDir 'agentmark\package.json') + +# Install production deps into staging. --ignore-scripts skips +# Playwright's Chromium download; we'll prompt for that post-install. +Set-Location (Join-Path $StageDir 'agentmark') +$env:PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = '1' +pnpm install --prod --ignore-scripts +Set-Location $RepoRoot + +# Launcher batch file (added to PATH by the MSI). +Copy-Item (Join-Path $ScriptDir '..\templates\launcher.cmd') (Join-Path $StageDir 'agentmark-mcp.cmd') + +# ────────────────────────────────────────────────────────────────────── +# 5. Build the .msi with WiX 4 +# ────────────────────────────────────────────────────────────────────── +Write-Section 'Build MSI' +# Install wix as a dotnet tool if not already present. +$wixCheck = dotnet tool list --global | Select-String 'wix\s' +if (-not $wixCheck) { + dotnet tool install --global wix + $env:PATH = "$env:USERPROFILE\.dotnet\tools;$env:PATH" +} + +# Stamp version into the WiX manifest. +$WxsTemplate = Join-Path $ScriptDir '..\templates\AgentMark.wxs' +$WxsStamped = Join-Path $BuildDir 'AgentMark.wxs' +(Get-Content $WxsTemplate -Raw) ` + -replace '__VERSION__', $PkgVersion ` + -replace '__BUNDLE_ID__', $BundleId ` + -replace '__DISPLAY_NAME__', $DisplayName ` + -replace '__STAGE_DIR__', ($StageDir -replace '\\', '\\\\') | + Set-Content $WxsStamped + +wix build -arch x64 -out $MsiOut $WxsStamped + +# ────────────────────────────────────────────────────────────────────── +# 6. Sign with signtool (optional) +# ────────────────────────────────────────────────────────────────────── +if ($env:WINDOWS_CERT_PFX_BASE64 -and $env:WINDOWS_CERT_PFX_PASSWORD) { + Write-Section 'Sign MSI' + $PfxPath = Join-Path $BuildDir 'codesign.pfx' + [System.IO.File]::WriteAllBytes($PfxPath, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64)) + + # signtool ships with the Windows SDK; in CI the windows-latest image has it on PATH. + & signtool sign /f $PfxPath /p $env:WINDOWS_CERT_PFX_PASSWORD ` + /fd SHA256 /tr 'http://timestamp.digicert.com' /td SHA256 ` + $MsiOut + + Remove-Item $PfxPath -Force +} else { + Write-Host 'WINDOWS_CERT_PFX_BASE64 not set; skipping signing. SmartScreen will warn end users.' +} + +Write-Section 'Done' +Write-Host "Installer: $MsiOut" +Get-Item $MsiOut | Select-Object Name, Length diff --git a/packaging/scripts/common.sh b/packaging/scripts/common.sh new file mode 100755 index 0000000..40e7c54 --- /dev/null +++ b/packaging/scripts/common.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Shared helpers + pinned versions for the installer build scripts. +# Sourced by build-macos.sh and (indirectly via env vars) the +# Windows script. + +# Pinned Node runtime. Bump when needed — every platform installer +# downloads from the official Node.js distribution. +NODE_VERSION="${NODE_VERSION:-22.11.0}" + +# Bundle identifier (macOS) + Microsoft Product ID semantics (Windows). +BUNDLE_ID="${BUNDLE_ID:-ai.thinkfleet.agentmark}" + +# Human-friendly display name. +DISPLAY_NAME="${DISPLAY_NAME:-ThinkFleet AgentMark}" + +# Read the package.json version. Single source of truth so installers +# match the npm version. +read_pkg_version() { + local pkg="$1" + node -e "console.log(require('$pkg').version)" +} + +# Download a Node binary tarball/zip for the requested platform/arch. +# Args: $1=platform (darwin-arm64 / darwin-x64 / win-x64), $2=output dir. +download_node() { + local target="$1" + local out_dir="$2" + local archive_ext="tar.xz" + if [[ "$target" == win-* ]]; then + archive_ext="zip" + fi + local url="https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${target}.${archive_ext}" + local archive="$out_dir/node-${target}.${archive_ext}" + + mkdir -p "$out_dir" + echo "Downloading Node v${NODE_VERSION} for ${target}…" + curl --fail --location --silent --show-error --output "$archive" "$url" + echo "$archive" +} + +# Print a uniform header so the build log is scannable. +section() { + echo + echo "=== $1 ===" +} + +# Fail loudly with a clear message. +die() { + echo "ERROR: $*" >&2 + exit 1 +} diff --git a/packaging/templates/AgentMark.wxs b/packaging/templates/AgentMark.wxs new file mode 100644 index 0000000..673223a --- /dev/null +++ b/packaging/templates/AgentMark.wxs @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/templates/distribution.xml b/packaging/templates/distribution.xml new file mode 100644 index 0000000..52a83cb --- /dev/null +++ b/packaging/templates/distribution.xml @@ -0,0 +1,34 @@ + + + + __DISPLAY_NAME__ + ai.thinkfleet + + + + + + + + + + + + + + + + + + + + + agentmark-component.pkg + diff --git a/packaging/templates/launcher.cmd b/packaging/templates/launcher.cmd new file mode 100644 index 0000000..c8b5a40 --- /dev/null +++ b/packaging/templates/launcher.cmd @@ -0,0 +1,21 @@ +@echo off +REM agentmark-mcp launcher (Windows). +REM +REM Installed at C:\Program Files\ThinkFleet\AgentMark\agentmark-mcp.cmd +REM (added to PATH by the MSI). Resolves the bundle's Node + agentmark +REM dist relative to its own path so the launcher is relocatable. + +setlocal + +set "INSTALL_ROOT=%~dp0" +REM Strip trailing backslash for cleaner output paths. +if "%INSTALL_ROOT:~-1%"=="\" set "INSTALL_ROOT=%INSTALL_ROOT:~0,-1%" + +REM Point the desktop plugin at the bundled bridge unless overridden. +if "%AGENTMARK_BRIDGE_PATH%"=="" ( + set "AGENTMARK_BRIDGE_PATH=%INSTALL_ROOT%\bridges\agentmark-bridge-windows.exe" +) + +"%INSTALL_ROOT%\node.exe" "%INSTALL_ROOT%\agentmark\dist\src\mcp\cli.js" %* + +endlocal diff --git a/packaging/templates/launcher.sh b/packaging/templates/launcher.sh new file mode 100755 index 0000000..7cbc029 --- /dev/null +++ b/packaging/templates/launcher.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# agentmark-mcp launcher (POSIX). +# +# Installed at /opt/thinkfleet/agentmark/bin/agentmark-mcp; symlinked +# into /usr/local/bin by the .pkg post-install step. Resolves the +# bundle's Node + agentmark dist relative to its own path so the +# launcher is relocatable. + +set -e + +# Resolve the directory this script lives in, following symlinks. +SCRIPT_PATH="$0" +while [[ -L "$SCRIPT_PATH" ]]; do + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + SCRIPT_PATH="$(readlink "$SCRIPT_PATH")" + [[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH" +done +BIN_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +INSTALL_ROOT="$(cd "$BIN_DIR/.." && pwd)" + +NODE="$INSTALL_ROOT/node" +ENTRY="$INSTALL_ROOT/agentmark/dist/src/mcp/cli.js" + +# Tell the agentmark Desktop plugin where the bridge lives unless the +# operator overrode the path explicitly. +if [[ -z "${AGENTMARK_BRIDGE_PATH:-}" ]]; then + export AGENTMARK_BRIDGE_PATH="$INSTALL_ROOT/bridges/agentmark-bridge-macos" +fi + +exec "$NODE" "$ENTRY" "$@" diff --git a/packaging/templates/macos-pkg-scripts/postinstall b/packaging/templates/macos-pkg-scripts/postinstall new file mode 100755 index 0000000..981134a --- /dev/null +++ b/packaging/templates/macos-pkg-scripts/postinstall @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# pkgbuild post-install script. +# +# Runs as root after the installer extracts files to /opt/thinkfleet/agentmark/. +# Job: drop a symlink in /usr/local/bin so `agentmark-mcp` is on the +# user's PATH out of the box. + +set -e + +INSTALL_ROOT="/opt/thinkfleet/agentmark" +LAUNCHER="$INSTALL_ROOT/bin/agentmark-mcp" +SYMLINK="/usr/local/bin/agentmark-mcp" + +# /usr/local/bin must exist before we can symlink into it. On a fresh +# macOS install without Homebrew it sometimes doesn't. +mkdir -p /usr/local/bin + +# Atomic-ish: write to a temp name, then move into place. +ln -sf "$LAUNCHER" "$SYMLINK.tmp" +mv -f "$SYMLINK.tmp" "$SYMLINK" + +# Make the bridge executable bit survives the tar extraction. +chmod +x "$INSTALL_ROOT/bridges/agentmark-bridge-macos" +chmod +x "$INSTALL_ROOT/node" +chmod +x "$INSTALL_ROOT/bin/agentmark-mcp" + +exit 0