diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..5043b6660 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,136 @@ +# Contributor Covenant 3.0 Code of Conduct + +## Our Pledge + +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all +individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, +neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or +religion, national or social origin, socio-economic position, level of education, or other status. The same privileges +of participation are extended to everyone who participates in good faith and in accordance with this Covenant. + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive +behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, +background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared +values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are +violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any + clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of + people. +3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable + identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or + purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their + permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade + enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the + community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other + restricted behaviors. + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict +represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help +avoid conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to +report@liketrek.com**. + +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They +will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing +witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as +possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried +out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon +resolution. + +## Addressing and Repairing Harm + +**** + +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following +enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals +involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be +skipped. + +1) Warning + 1) Event: A violation involving a single incident or series of incidents. + 2) Consequence: A private, written warning from the Community Moderators. + 3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking + clarification on expectations. +2) Temporarily Limited Activities + 1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a + more serious violation. + 2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the + seriousness of the situation and give the community members involved time to process the incident. The cooldown + period may be limited to particular communication channels or interactions with particular community members. + 3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and + impact, and being thoughtful about re-entering community spaces after the period is over. +3) Temporary Suspension + 1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a + single serious violation. + 2) Consequence: A private written warning with conditions for return from suspension. In general, temporary + suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for + return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4) Permanent Ban + 1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or + a violation so serious that the Community Moderators determine there is no way to keep the community safe with + this person as a member. + 2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent + bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working + through other remedies has failed to change the behavior. + 3) Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their +discretion and judgment, in keeping with the best interests of our community. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing +the community in public or other spaces. Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available +at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy +of this license, +visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) + +For answers to common questions about Contributor Covenant, see the FAQ +at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided +at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional +enforcement and community guideline resources can be found +at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement +ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion). + diff --git a/README.md b/README.md index 31f8c82d2..92ac2e637 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,7 @@ Caddy handles TLS and WebSockets automatically. | `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random | | **Other** | | | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | +| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | | `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` | diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index 09d90dc86..37d52b1c1 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -63,6 +63,12 @@ spec: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: OIDC_CLIENT_SECRET optional: true + - name: UNSPLASH_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: UNSPLASH_ACCESS_KEY + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml index 20edd114f..08d81cabd 100644 --- a/charts/trek/templates/secret.yaml +++ b/charts/trek/templates/secret.yaml @@ -17,6 +17,9 @@ data: {{- if .Values.secretEnv.OIDC_CLIENT_SECRET }} OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }} {{- end }} + {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} + UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }} + {{- end }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} @@ -44,4 +47,7 @@ stringData: {{- if .Values.secretEnv.OIDC_CLIENT_SECRET }} OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }} {{- end }} + {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} + UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }} + {{- end }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index e141aa88f..a0523ce14 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -92,6 +92,12 @@ secretEnv: ADMIN_PASSWORD: "" # OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID. OIDC_CLIENT_SECRET: "" + # Optional Unsplash Access Key for trip-cover and place-image search. + # Without one, TREK uses Unsplash's unauthenticated endpoint, which some + # datacenter/VPS IPs (including many Kubernetes clusters) are blocked from. + # Get a free key at https://unsplash.com/developers. Can also be set per-admin + # in Admin > Settings; this value overrides that. Leave empty to disable. + UNSPLASH_ACCESS_KEY: "" # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades generateEncryptionKey: false diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f9a388d66..fd2165ef1 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -461,14 +461,16 @@ export const adminApi = { addons: () => apiClient.get('/admin/addons').then(r => r.data), updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), plugins: () => apiClient.get('/admin/plugins').then(r => r.data), - pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data), + pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data), pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data), - pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data), + pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) => + apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data), pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data), pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data), + pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return apiClient.post('/admin/plugins/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data), pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data), // Local LLM (Ollama) management for the AI-parsing addon. @@ -556,9 +558,34 @@ export const addonsApi = { export const pluginsApi = { // Active plugins the client renders (page nav entries, dashboard widgets). active: () => apiClient.get('/plugins').then(r => r.data), - // Call one of a plugin's own declared routes through the host proxy. - invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => - apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data), + // Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe: + // the server skips any slow/failing provider, so this only ever adds rows. + placeDetails: (placeId: number) => + apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }), + // Validation/warning contributions from warningProvider plugins (#1429). Fail-safe. + tripWarnings: (tripId: number) => + apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }), + // Call one of a plugin's own declared routes through the host proxy. `sub` is + // supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it + // MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with + // the URL parser — which normalizes `../`, encoded traversal and backslashes the + // same way the browser would before sending — and reject anything that escapes + // the prefix or points off-origin. Without this a plugin could send + // sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user. + invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => { + const prefix = `/api/plugins/${id}/` + let resolved: URL + try { + resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix) + } catch { + return Promise.reject(new Error('invalid plugin route')) + } + if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) { + return Promise.reject(new Error('plugin route escapes its namespace')) + } + const url = resolved.pathname.slice('/api'.length) + resolved.search + return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data) + }, } export const airtrailApi = { @@ -675,8 +702,8 @@ export const budgetApi = { setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), - createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), - updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), + createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), + updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index f030adc83..6c021e331 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react' import { - Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, + Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, - BookOpen, Wallet, Puzzle, + BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, } from 'lucide-react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' @@ -21,6 +21,12 @@ import ToggleSwitch from '../Settings/ToggleSwitch' * tile; the security section at the bottom explains the model honestly. */ +interface PluginDep { id: string; version: string } +interface VersionMismatch { id: string; wanted: string; installed: string } +type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' +interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] } +interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] } + interface PluginRow { id: string name: string @@ -35,6 +41,9 @@ interface PluginRow { source_repo: string | null permissions: string capabilities: string + dependencies?: PluginDependencies + dependencyStatus?: DependencyStatus + dependencyIssues?: DependencyIssues } interface RegistryItem { id: string @@ -48,6 +57,8 @@ interface RegistryItem { minTrekVersion: string | null reviewedAt: string | null screenshotUrl: string | null + requiredAddons?: string[] + pluginDependencies?: PluginDep[] } interface RegistryDetail extends RegistryItem { size: number | null @@ -58,11 +69,21 @@ interface RegistryDetail extends RegistryItem { settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> license: string | null icon: string | null + requiredAddons?: string[] + pluginDependencies?: PluginDep[] } | null } +/** 409 error-body shape from POST /activate when a dependency blocks activation. */ +interface ActivateErr { + response?: { + status?: number + data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[]; addons?: string[]; missing?: PluginDep[]; versionMismatch?: VersionMismatch[] } + } +} + type T = (k: string, p?: Record) => string -type TypeFilter = 'all' | 'widget' | 'page' | 'integration' +type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page' type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err' type SortKey = 'name' | 'recent' | 'updates' @@ -83,11 +104,15 @@ const ICON_MAP: Record v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0) @@ -111,16 +136,50 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string } }, t: T) const out: Cap[] = [] if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') }) if (perms.includes('db:read:users')) out.push({ icon: Users, label: t('admin.plugins.cap.readsUsers') }) - if (caps.widget) out.push({ icon: LayoutDashboard, label: t(caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' : 'admin.plugins.cap.widget') }) + if (perms.includes('db:write:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.writesCosts') }) + else if (perms.includes('db:read:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.readsCosts') }) + if (perms.includes('db:read:packing')) out.push({ icon: Luggage, label: t('admin.plugins.cap.readsPacking') }) + if (perms.includes('db:read:files')) out.push({ icon: FileText, label: t('admin.plugins.cap.readsFiles') }) + if (perms.includes('db:write:places')) out.push({ icon: MapPin, label: t('admin.plugins.cap.writesPlaces') }) + if (perms.includes('db:write:days')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.writesDays') }) + if (perms.includes('db:write:itinerary')) out.push({ icon: ListChecks, label: t('admin.plugins.cap.writesItinerary') }) + if (perms.includes('db:write:trips')) out.push({ icon: Pencil, label: t('admin.plugins.cap.writesTrips') }) + if (perms.includes('db:meta')) out.push({ icon: Tag, label: t('admin.plugins.cap.metadata') }) + if (caps.widget) { + const slotKey = caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' + : caps.widget.slot === 'place-detail' ? 'admin.plugins.cap.placeSlot' + : 'admin.plugins.cap.widget' + out.push({ icon: LayoutDashboard, label: t(slotKey as never) }) + } if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') }) if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) + if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) + if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) + if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { out.push({ icon: ArrowRight, label: h, net: true }) } return out } +interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean } + +// A plugin's declared dependencies as chips — a required addon (amber when that +// addon is disabled) or a plugin dependency (amber when missing / version-mismatched). +function deriveDeps(p: PluginRow, t: T): DepChip[] { + const out: DepChip[] = [] + const issues = p.dependencyIssues + for (const a of p.dependencies?.requiredAddons ?? []) { + out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) }) + } + for (const d of p.dependencies?.pluginDependencies ?? []) { + const blocked = !!(issues?.missing.some(m => m.id === d.id) || issues?.versionMismatch.some(m => m.id === d.id)) + out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked }) + } + return out +} + function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) { const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks return @@ -135,6 +194,16 @@ function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) { ) } +/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */ +function SideloadedBadge({ t }: { t: T }) { + return ( + + {t('admin.plugins.sideloaded')} + + ) +} + function TypeBadge({ type, t }: { type: string; t: T }) { return ( @@ -160,6 +229,8 @@ export default function AdminPluginsPanel() { // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. const [consentQueue, setConsentQueue] = useState>([]) + // Open when enabling a plugin is blocked by missing/outdated plugin dependencies. + const [depResolve, setDepResolve] = useState<{ plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } | null>(null) const [menu, setMenu] = useState(null) // Toolbar state. @@ -168,6 +239,11 @@ export default function AdminPluginsPanel() { const [statusFilter, setStatusFilter] = useState('all') const [sort, setSort] = useState('name') + // Sideload upload: drag a plugin .zip onto the panel or use the toolbar button. + const [dragActive, setDragActive] = useState(false) + const uploadInputRef = useRef(null) + const dragDepth = useRef(0) + const refresh = () => { adminApi.plugins() .then((d: { enabled: boolean; plugins: PluginRow[] }) => { @@ -199,6 +275,43 @@ export default function AdminPluginsPanel() { setView('discover') if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([])) } + // The rescan/reload button rediscovers locally-installed plugins AND force-pulls + // the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a + // just-published plugin shows up right away instead of up to ~35 min later. + const rescan = () => act('__rescan', async () => { + await adminApi.pluginRescan() + const items: RegistryItem[] = await adminApi.pluginBrowse(true) + setRegistry(items) + const map: Record = {} + items.forEach((i) => { if (i.latest) map[i.id] = i.latest }) + setLatest(map) + }, t('admin.plugins.rescanned')) + + // Sideload a plugin archive (installs INACTIVE — the admin still consents on activation). + const uploadPlugin = async (file: File) => { + setBusy('__upload'); setMenu(null) + try { + const res = await adminApi.pluginUpload(file) + setView('installed') + toast.success(t('admin.plugins.uploaded', { name: res.id })) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) + } finally { + setBusy(null); refresh() + } + } + const pickUpload = () => uploadInputRef.current?.click() + const onDragEnter = (e: DragEvent) => { + if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return + e.preventDefault(); dragDepth.current++; setDragActive(true) + } + const onDragLeave = () => { if (--dragDepth.current <= 0) { dragDepth.current = 0; setDragActive(false) } } + const onDrop = (e: DragEvent) => { + e.preventDefault(); dragDepth.current = 0; setDragActive(false) + if (!runtimeOn) return + const f = e.dataTransfer.files?.[0] + if (f) void uploadPlugin(f) + } const openErrors = (id: string) => { setMenu(null) adminApi.pluginErrors(id) @@ -211,23 +324,63 @@ export default function AdminPluginsPanel() { const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) const installedIds = new Set(plugins.map(p => p.id)) + // Installed-but-disabled direct deps that enabling `p` will auto-enable first. + const autoEnabledDeps = (p: PluginRow) => + (p.dependencies?.pluginDependencies ?? []) + .map(d => plugins.find(x => x.id === d.id)) + .filter((x): x is PluginRow => !!x && x.enabled === 0) + .map(x => x.name) + + // Shared handling for a failed activation: route each 409 code to the right fix + // (consent dialog, download-dependency dialog, or a clear toast). + const onActivateError = (p: PluginRow, e: ActivateErr) => { + const d = e?.response?.data + if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { + setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) + } else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') { + toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: (d.addons ?? []).join(', ') })) + } else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') { + setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] }) + } else { + // DEPENDENCY_CYCLE and everything else surface their server message. + toast.error(d?.error || t('admin.plugins.actionError')) + } + } + + const attemptActivate = (p: PluginRow) => { + const cascaded = autoEnabledDeps(p) + return adminApi.pluginActivate(p.id) + .then(() => { + toast.success(t('admin.plugins.activated')) + if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { plugins: cascaded.join(', ') })) + setDepResolve(null) + }) + .catch((e: ActivateErr) => onActivateError(p, e)) + } + // Enable/disable a plugin. Re-enabling one whose update widened its permissions - // must NOT grant them silently — the server answers 409 CONSENT_REQUIRED and we - // route through the same consent dialog the Update button uses. + // must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled + // required addon or a missing plugin dependency (409 ADDON_DISABLED / + // DEPENDENCY_MISSING) routes to the right remedy. const toggle = (p: PluginRow) => { if (busy === p.id) return if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return } setBusy(p.id); setMenu(null) - adminApi.pluginActivate(p.id) - .then(() => toast.success(t('admin.plugins.activated'))) - .catch((e: { response?: { status?: number; data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[] } } }) => { - const d = e?.response?.data - if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { - setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) - } else { - toast.error(d?.error || t('admin.plugins.actionError')) - } + attemptActivate(p).finally(() => { setBusy(null); refresh() }) + } + + // Download a missing/outdated plugin dependency (latest compatible for its range, + // transitively), then retry enabling the plugin that needed it. + const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => { + if (busy === parent.id) return + setBusy(parent.id) + adminApi.pluginInstall(depId, { constraint, withDependencies: true }) + .then((r: { installed?: string[]; requiredAddons?: string[] }) => { + toast.success(t('admin.plugins.dep.downloaded', { id: depId })) + if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') })) + return attemptActivate(parent) }) + .catch((e: ActivateErr) => onActivateError(parent, e)) .finally(() => { setBusy(null); refresh() }) } @@ -285,7 +438,18 @@ export default function AdminPluginsPanel() { const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all' return ( -
+
{ if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}> + {/* Hidden input for the toolbar "Upload plugin" button (drag-drop uses the same handler). */} + { const f = e.target.files?.[0]; if (f) void uploadPlugin(f); e.target.value = '' }} /> + {/* Drag-to-install overlay */} + {dragActive && ( +
+ + {t('admin.plugins.dropToUpload')} +
+ )} {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} {menu &&
setMenu(null)} />} {/* Header */} @@ -324,10 +488,16 @@ export default function AdminPluginsPanel() { setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} />
- +
+ + +
@@ -344,6 +514,7 @@ export default function AdminPluginsPanel() { options={[ ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], + ['trip-page', t('admin.plugins.type.trip-page')], ]} valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} onPick={v => setTypeFilter(v as TypeFilter)} /> @@ -363,7 +534,11 @@ export default function AdminPluginsPanel() { valueLabel={sortLabel(sort, t)} onPick={v => setSort(v as SortKey)} /> - + @@ -462,6 +637,14 @@ export default function AdminPluginsPanel() { onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }} /> )} + + {depResolve && ( + resolveDependency(depResolve.plugin, depId, constraint)} + onClose={() => setDepResolve(null)} + /> + )}
) } @@ -541,6 +724,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) + const deps = deriveDeps(p, t) const menuOpen = menu === `row:${p.id}` return (
@@ -557,6 +741,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl {p.name} {p.version && v{p.version}} {p.reviewed_at && } + {p.source_repo === 'local:upload' && }
{p.description &&

{p.description}

} {p.status === 'error' && p.last_error ? ( @@ -573,6 +758,16 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl ))}
)} + {deps.length > 0 && ( +
+ {deps.map((d, i) => ( + + {d.label} + + ))} +
+ )}
@@ -597,7 +792,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl } label={t('admin.plugins.restart')} onClick={onRestart} /> )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> - {p.source_repo && ( + {p.source_repo && p.source_repo !== 'local:upload' && ( setMenu(null)} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> {t('admin.plugins.sourceRepo')} @@ -875,6 +1070,57 @@ function UpdateConsentDialog({ data, t, onApprove, onLater }: { ) } +// Shown when enabling a plugin is blocked by missing/outdated plugin dependencies. +// Each dependency gets a one-click download (latest version satisfying its range, +// transitively) that then retries enabling the plugin. +function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onClose }: { + data: { plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } + t: T; busy: boolean; installedIds: Set + onDownload: (depId: string, constraint?: string) => void; onClose: () => void +}) { + const rows: Array<{ id: string; constraint: string; installed?: string }> = [ + ...data.missing.map(d => ({ id: d.id, constraint: d.version })), + ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })), + ] + return ( +
+
e.stopPropagation()}> +
+
+
+

{t('admin.plugins.dep.resolveTitle')}

+

{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}

+
+
+
+ {rows.map(r => ( +
+
+
{r.id}
+
+ {r.installed + ? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed }) + : t('admin.plugins.dep.requires', { version: r.constraint })} +
+
+ +
+ ))} + {rows.some(r => !installedIds.has(r.id)) && ( +

{t('admin.plugins.dep.resolveHint')}

+ )} +
+
+ +
+
+
+ ) +} + // Footer: a plain-language note on what "Reviewed" means, plus a collapsible // panel that lays out how plugins are contained, the limits, and the worst case. function SecurityInfo({ t }: { t: T }) { diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 9c530a380..7899e2b83 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -4,6 +4,7 @@ import { http, HttpResponse } from 'msw' import { server } from '../../../tests/helpers/msw/server' import { useAuthStore } from '../../store/authStore' import { useTripStore } from '../../store/tripStore' +import { useSettingsStore } from '../../store/settingsStore' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories' import CostsPanel from './CostsPanel' @@ -164,6 +165,28 @@ describe('CostsPanel — settlements in the ledger', () => { expect(screen.getByText('Unfinished')).toBeInTheDocument() }) + it('sums only unfinished expenses in the Outstanding amount card', async () => { + // Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic. + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] } + const unfinishedA = { ...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } + const unfinishedB = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 30, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } + const zero = { ...buildBudgetItem({ trip_id: 1, category: 'misc', name: 'Freebie' }), total_price: 0, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, unfinishedA, unfinishedB, zero] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + ) + render() + + // Footer only shows the count once unfinished expenses have loaded. + const foot = await screen.findByText('expenses need a payer') + expect(foot).toHaveTextContent('2 expenses need a payer') // the two payer-less, non-zero expenses + // Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded. + // Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded. + const card = screen.getByText('Outstanding amount').closest('div[style*="border-radius: 22"]') + expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30 + }) + it('records a recorded-total expense with nobody to split with (#1286)', async () => { let posted: Record | null = null server.use( diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 2a35aba18..d5e622ccb 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' -import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react' +import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' @@ -179,6 +179,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const myShare = shares[me] || 0 return convert(myShare, curOf(e)) } + // "Unfinished": a recorded total nobody has paid yet — counts toward the trip + // total but stays out of settlements until who-paid is filled in. + const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0 const totals = useMemo(() => { const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) @@ -186,7 +189,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0) const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0) const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0) - return { totalSpend, myPaid, myShare, owe, owed } + const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0) + const outstandingCount = budgetItems.filter(isUnfinished).length + return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount } }, [budgetItems, settlement, me]) // ── filtering + day grouping ──────────────────────────────────────────── @@ -254,7 +259,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // ── settle actions ────────────────────────────────────────────────────── const settleFlow = async (fromId: number, toId: number, amount: number) => { try { - await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount }) + await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base }) loadSettlement() } catch { toast.error(t('common.unknownError')) } } @@ -265,7 +270,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const flows = settlement?.flows || [] if (!flows.length) return try { - for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount }) + for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }) loadSettlement() } catch { toast.error(t('common.unknownError')) } } @@ -369,7 +374,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* ── Summary cards ── */} -
+
} tone="owe" foot={totals.owe > 0.01 @@ -380,6 +385,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps foot={totals.owed > 0.01 ? f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} /> : {t('costs.nothingOwed')}} /> + } tone="unfinished" + foot={totals.outstandingCount > 0 + ? {totals.outstandingCount} {t('costs.outstandingItems')} + : {t('costs.allSettled')}} /> } tone="total" foot={{t('costs.yourShare')} · {fmt0(totals.myShare)}{t('costs.youPaid')} · {fmt0(totals.myPaid)}} /> @@ -475,7 +485,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps )} {(editingSettlement || addingPayment) && ( - { setEditingSettlement(null); setAddingPayment(false) }} onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> )} @@ -511,9 +521,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps .costs-root .text-content-faint { color: var(--c-ink3) !important; } .costs-root .exp-actions { opacity: 1; } @media (max-width: 1100px) { - .costs-root .costs-summary { grid-template-columns: 1fr !important; } + .costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; } .costs-root .costs-grid { grid-template-columns: 1fr !important; } } + @media (max-width: 640px) { + .costs-root .costs-summary { grid-template-columns: 1fr !important; } + } `}
) @@ -580,6 +593,18 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
+ {/* Outstanding */} +
+
+
+
+
{t('costs.outstanding')}
+
{t('costs.outstandingSub')}
+
+
{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}
+
+
+ {/* Settle up */}
@@ -645,21 +670,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const cur = curOf(e) const payers = (e.payers || []).filter(p => p.amount > 0) const net = round2(myPaidOf(e) - myShareOf(e)) - // "Unfinished": a recorded total nobody has paid yet — counts toward the trip - // total but stays out of settlements until who-paid is filled in. - const isUnfinished = baseTotal(e) > 0 && payers.length === 0 + const unfinished = isUnfinished(e) return (
- {isMobile && isUnfinished && ( + {isMobile && unfinished && ( ! )}
{e.name} - {isUnfinished && !isMobile && ( + {unfinished && !isMobile && ( ! {t('costs.unfinished')} @@ -786,9 +809,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } // ── pure subcomponents ───────────────────────────────────────────────────── -function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) { +function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' | 'unfinished' }) { const total = tone === 'total' - const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined + const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : tone === 'unfinished' ? '#d97706' : undefined const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)' // formatToParts keeps the design's "big integer + muted symbol/decimals" styling // while letting Intl place the symbol and pick separators per locale + currency. @@ -835,8 +858,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A // Add or edit a settle-up payment (from / to / amount). Reachable inline from the // ledger row and from a manual "Add payment" button, so recording "I sent money to // X" works the same whether or not there's an outstanding expense behind it. -function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: { - tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void +function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: { + tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void }) { const { t } = useTranslation() const toast = useToast() @@ -853,7 +876,7 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: { const save = async () => { if (!valid) return setSaving(true) - const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt } + const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency } try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) diff --git a/client/src/components/Files/FileManager.helpers.test.ts b/client/src/components/Files/FileManager.helpers.test.ts new file mode 100644 index 000000000..dbb2bc6d3 --- /dev/null +++ b/client/src/components/Files/FileManager.helpers.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { isWalletPass } from './FileManager.helpers'; + +describe('isWalletPass (#1447)', () => { + it('detects by extension when the mime is unreliable', () => { + // Browsers frequently send octet-stream / empty for .pkpass uploads + expect(isWalletPass('application/octet-stream', 'boarding.pkpass')).toBe(true); + expect(isWalletPass(null, 'multi.pkpasses')).toBe(true); + expect(isWalletPass('', 'CAPS.PKPASS')).toBe(true); + }); + + it('falls back to the wallet MIME types when there is no extension', () => { + expect(isWalletPass('application/vnd.apple.pkpass', 'pass')).toBe(true); + expect(isWalletPass('application/vnd.apple.pkpasses', null)).toBe(true); + }); + + it('is false for non-wallet files', () => { + expect(isWalletPass('application/pdf', 'report.pdf')).toBe(false); + expect(isWalletPass('image/png', 'photo.png')).toBe(false); + expect(isWalletPass('text/markdown', 'notes.md')).toBe(false); + expect(isWalletPass(null, null)).toBe(false); + }); +}); diff --git a/client/src/components/Files/FileManager.helpers.ts b/client/src/components/Files/FileManager.helpers.ts index b9568e832..747f8fc0e 100644 --- a/client/src/components/Files/FileManager.helpers.ts +++ b/client/src/components/Files/FileManager.helpers.ts @@ -26,6 +26,18 @@ export function isMarkdown(mimeType?: string | null, name?: string | null) { return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown') } +/** + * Apple Wallet pass (#1447). Detected by EXTENSION first — browsers often send an + * empty / octet-stream MIME for .pkpass — falling back to the wallet MIME types. + * Wallet passes must be downloaded so the OS hands them to Apple Wallet rather + * than rendered in the in-app PDF preview. + */ +export function isWalletPass(mimeType?: string | null, name?: string | null) { + const ext = (name || '').toLowerCase().split('.').pop() + if (ext === 'pkpass' || ext === 'pkpasses') return true + return !!mimeType && (mimeType === 'application/vnd.apple.pkpass' || mimeType === 'application/vnd.apple.pkpasses') +} + export function getFileIcon(mimeType?: string | null) { if (!mimeType) return File if (mimeType === 'application/pdf') return FileText diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index cd4dd40fc..478caf79f 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -15,6 +15,15 @@ vi.mock('../../api/authUrl', () => ({ getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'), })); +// Mock the blob download/open helpers so we can assert wallet passes are +// downloaded (#1447) rather than opened in the in-app PDF preview. +vi.mock('../../utils/fileDownload', () => ({ + openFile: vi.fn().mockResolvedValue(undefined), + downloadFile: vi.fn().mockResolvedValue(undefined), +})); + +import { openFile as openFileInTab } from '../../utils/fileDownload'; + // Markdown pipeline mocked to render its children verbatim (the unified/ESM // pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal. vi.mock('react-markdown', () => ({ @@ -313,6 +322,21 @@ describe('FileManager', () => { }); }); + it('FE-COMP-FILEMANAGER-035: pkpass click downloads via blob helper, not the PDF preview (#1447)', async () => { + const files = [buildFile({ id: 1, mime_type: 'application/octet-stream', original_name: 'boarding.pkpass', url: '/uploads/trips/1/boarding.pkpass' })]; + render(); + const user = userEvent.setup(); + + await user.click(screen.getByText('boarding.pkpass')); + + // Blob helper is called with the file url + name — the OS hands it to Wallet + await waitFor(() => { + expect(openFileInTab).toHaveBeenCalledWith('/uploads/trips/1/boarding.pkpass', 'boarding.pkpass'); + }); + // No PDF preview modal — the filename appears only once (in the list row) + expect(screen.getAllByText('boarding.pkpass').length).toBe(1); + }); + it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => { const files = [buildFile({ uploaded_by_name: 'Alice Smith' })]; render(); diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index 93d2e3fe3..0bfcb5cc3 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -7,7 +7,8 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { getAuthUrl } from '../../api/authUrl' -import { isImage, isMedia } from './FileManager.helpers' +import { isImage, isMedia, isWalletPass } from './FileManager.helpers' +import { openFile as openFileInTab } from '../../utils/fileDownload' export interface FileManagerProps { files?: TripFile[] @@ -191,6 +192,10 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place if (isMedia(file.mime_type)) { const idx = mediaFiles.findIndex(f => f.id === file.id) setLightboxIndex(idx >= 0 ? idx : 0) + } else if (isWalletPass(file.mime_type, file.original_name)) { + // Download so the OS hands the pass to Apple Wallet (#1447) rather than + // forcing it into the in-app PDF preview. + openFileInTab(file.url, file.original_name).catch(() => {}) } else { setPreviewFile(file) } diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 3c29bb8c0..a4763248d 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -129,6 +129,7 @@ beforeEach(() => { resetAllStores() vi.clearAllMocks() sessionStorage.clear() + localStorage.clear() // Reset mutable day-notes state mockDayNotesState.noteUi = {} mockDayNotesState.dayNotes = {} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index a97acda7b..d37dfdfa2 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -96,6 +96,9 @@ interface DayPlanSidebarProps { onScrollTopChange?: (top: number) => void /** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */ showRouteToolsWhenExpanded?: boolean + /** Mobile: drag & drop reorder is disabled (touch-scroll hijack, #1432); the + * grip handle is hidden and the arrow reorder buttons take over instead. */ + isMobile?: boolean } /** @@ -140,6 +143,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded = false, + isMobile = false, } = props const toast = useToast() const { t, language, locale } = useTranslation() @@ -153,7 +157,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const [expandedDays, setExpandedDays] = useState(() => { try { - const saved = sessionStorage.getItem(`day-expanded-${tripId}`) + const saved = localStorage.getItem(`day-expanded-${tripId}`) if (saved) return new Set(JSON.parse(saved) as number[]) } catch {} return new Set(days.map(d => d.id)) @@ -260,7 +264,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { setExpandedDays(prev => { const n = new Set(prev) days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) }) - try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} + try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} return n }) } @@ -293,7 +297,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { setExpandedDays(prev => { const n = new Set(prev) n.has(dayId) ? n.delete(dayId) : n.add(dayId) - try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} + try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} return n }) } @@ -1019,6 +1023,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded, + isMobile, toast, t, language, @@ -1184,6 +1189,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded, + isMobile, toast, t, language, @@ -1648,9 +1654,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP return (
{ - if (!canEditDays) { e.preventDefault(); return } + if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('assignmentId', String(assignment.id)) e.dataTransfer.setData('fromDayId', String(day.id)) e.dataTransfer.effectAllowed = 'move' @@ -1745,7 +1751,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP opacity: isDraggingThis ? 0.4 : 1, }} > - {canEditDays &&
+ {canEditDays && !isMobile &&
}
{ - if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return } + if (!canEditDays || spanPhase === 'middle' || res.__leg || isMobile) { e.preventDefault(); return } // setData is required for the drag to start reliably (Firefox) and // matches how place/note items initiate their drag. e.dataTransfer.setData('reservationId', String(res.id)) @@ -2056,7 +2062,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1, }} > - {canEditDays && spanPhase !== 'middle' && !res.__leg && ( + {canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile && (
@@ -2173,8 +2179,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP return (
{ if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} + draggable={canEditDays && !isMobile} + onDragStart={e => { if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDrop={e => { @@ -2242,7 +2248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP transition: 'background 0.1s', cursor: 'grab', userSelect: 'none', }} > - {canEditDays &&
+ {canEditDays && !isMobile &&
}
diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 9178bd97b..38c0ae340 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -259,8 +259,8 @@ describe('PlaceInspector', () => { onRemoveAssignment={onRemoveAssignment} /> ); - // Find the remove button — it has "Remove" text (sm:hidden span) - const removeBtn = screen.getByText('Remove').closest('button')!; + // Find the remove button — it carries the "Remove from Day" label + const removeBtn = screen.getByText('Remove from Day').closest('button')!; await user.click(removeBtn); // Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id) expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 4d1684f98..35f6eccbf 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -8,7 +8,7 @@ import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus import PlaceAvatar from '../shared/PlaceAvatar' import GuestBadge from '../shared/GuestBadge' import StatusBadge from '../Collections/StatusBadge' -import { mapsApi } from '../../api/client' +import { mapsApi, pluginsApi } from '../../api/client' import { collectionsApi } from '../../api/collections' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' @@ -16,6 +16,8 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { getCategoryIcon } from '../shared/categoryIcons' import { useToast } from '../shared/Toast' import { useTranslation, translateApiError } from '../../i18n' +import { usePluginStore } from '../../store/pluginStore' +import PluginFrame from '../Plugins/PluginFrame' import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types' import type { CollectionStatus } from '@trek/shared' import { splitReservationDateTime, formatTime } from '../../utils/formatters' @@ -142,6 +144,21 @@ export default function PlaceInspector({ leftWidth = 0, rightWidth = 0, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, }: PlaceInspectorProps) { + // Plugins that declared a place-detail slot mount at the bottom of this panel, + // scoped to the open place (trip mode only). Inline-filter like the other sites. + const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail') + // Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe: + // any provider error/timeout is dropped server-side, so this only ever adds rows. + const [providerDetails, setProviderDetails] = useState }>>([]) + const placeIdForDetails = mode === 'trip' ? place?.id : undefined + useEffect(() => { + if (placeIdForDetails == null) { setProviderDetails([]); return } + let cancelled = false + pluginsApi.placeDetails(placeIdForDetails) + .then((d) => { if (!cancelled) setProviderDetails((d.providers || []).filter((p) => Array.isArray(p.items) && p.items.length > 0)) }) + .catch(() => { if (!cancelled) setProviderDetails([]) }) + return () => { cancelled = true } + }, [placeIdForDetails]) const { t, locale, language } = useTranslation() const toast = useToast() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' @@ -356,6 +373,34 @@ export default function PlaceInspector({ fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} distanceUnit={distanceUnit} /> + {/* Extra native rows from placeDetailProvider plugins (#1429). */} + {mode === 'trip' && providerDetails.length > 0 && ( +
+ {providerDetails.flatMap((p) => p.items.map((it, i) => ( +
+ {it.label} + {it.url + ? {it.value ?? '↗'} + : {it.value}} +
+ )))} +
+ )} + + {/* Place-detail plugin slots (#1429): sandboxed, scoped to this place. */} + {mode === 'trip' && placeDetailPlugins.length > 0 && ( +
+ {placeDetailPlugins.map((p) => { + const tid = (place as { trip_id?: number | string }).trip_id + return ( +
+ +
+ ) + })} +
+ )} +
{/* Footer actions */} @@ -372,7 +417,7 @@ export default function PlaceInspector({ {mode === 'trip' && selectedDayId && ( assignmentInDay ? ( onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={} - label={<>{t('inspector.removeFromDay')}{t('inspector.remove')}} /> + label={{t('inspector.removeFromDay')}} /> ) : ( onAssignToDay?.(place.id)} variant="primary" icon={} label={t('inspector.addToDay')} /> ) diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 06f93bc05..cefe35382 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -23,13 +23,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp } = S return (
- {sidebarDragOver && } + {!isMobile && sidebarDragOver && } {/* Kopfbereich */} diff --git a/client/src/components/Planner/PlacesSidebarRow.tsx b/client/src/components/Planner/PlacesSidebarRow.tsx index ff718c49e..0801b838e 100644 --- a/client/src/components/Planner/PlacesSidebarRow.tsx +++ b/client/src/components/Planner/PlacesSidebarRow.tsx @@ -36,8 +36,9 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({ ref={element => registerPlaceRow(place.id, element)} aria-selected={isSelected} data-place-id={place.id} - draggable={!selectMode} + draggable={!selectMode && !isMobile} onDragStart={e => { + if (isMobile) { e.preventDefault(); return } e.dataTransfer.setData('placeId', String(place.id)) e.dataTransfer.effectAllowed = 'copy' window.__dragData = { placeId: String(place.id) } @@ -55,7 +56,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({ style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px 9px 16px', - cursor: selectMode ? 'pointer' : 'grab', + cursor: selectMode || isMobile ? 'pointer' : 'grab', background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent', borderBottom: '1px solid var(--border-faint)', transition: 'background 0.1s', diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index a4d1209c1..f1f2a7bfc 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -409,6 +409,12 @@ describe('ReservationModal', () => { expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument(); }); + it('FE-PLANNER-RESMODAL-029b: file input accepts pkpass (#1448)', () => { + render(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(fileInput.accept).toContain('.pkpass'); + }); + it('FE-PLANNER-RESMODAL-030: hotel type — saving calls onSave with correct hotel shape', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 05960f994..f8a775f1f 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -682,7 +682,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
))} - +
{onFileUpload &&
))} - +
{onFileUpload &&
+

The frame runs sandboxed at an opaque origin, exactly like TREK — trek.invoke() is proxied to your /api routes as the dev user.

+
+ +`; +} diff --git a/plugin-sdk/src/cli/menu.ts b/plugin-sdk/src/cli/menu.ts new file mode 100644 index 000000000..4b2ac7941 --- /dev/null +++ b/plugin-sdk/src/cli/menu.ts @@ -0,0 +1,70 @@ +/** + * The top-level menu shown when `trek-plugin-sdk` is run with no command in a + * terminal (#plugins). It only picks WHICH command to run — the dispatcher then + * runs it through the same interactive path a named command would take, so each + * command still prompts for whatever it needs. + */ +import { intro, outro, promptSelect } from './ui.js'; + +export interface MenuItem { + value: string; + label: string; + hint: string; +} + +/** The everyday author journey. `advanced`/`exit` are control entries. */ +export const PRIMARY_MENU: MenuItem[] = [ + { value: 'create', label: 'Create a plugin', hint: 'Scaffold a new plugin' }, + { value: 'dev', label: 'Run the dev server', hint: 'Live-reload your plugin locally' }, + { value: 'validate', label: 'Validate', hint: 'Check the manifest + layout' }, + { value: 'pack', label: 'Pack', hint: 'Build plugin.zip' }, + { value: 'publish', label: 'Publish', hint: 'Release + open the registry PR' }, + { value: 'advanced', label: 'Advanced…', hint: 'Signing, registry entry, preflight' }, + { value: 'exit', label: 'Exit', hint: '' }, +]; + +/** The less-common signing/registry commands, behind the `advanced` entry. */ +export const ADVANCED_MENU: MenuItem[] = [ + { value: 'keygen', label: 'Generate a signing key', hint: 'Create an Ed25519 signing key' }, + { value: 'sign', label: 'Sign an artifact', hint: 'Print a signature + public key' }, + { value: 'entry', label: 'Registry entry', hint: 'Print the ready-to-PR registry JSON' }, + { value: 'preflight', label: 'Preflight', hint: 'Run the registry CI checks locally' }, + { value: 'submit', label: 'Submit', hint: 'Open the registry PR' }, + { value: 'release', label: 'Release', hint: 'Pack → GitHub release → print entry' }, + { value: 'back', label: '← Back', hint: '' }, +]; + +/** Every value the menu can yield, control entries included. */ +const MENU_VALUES = new Set([...PRIMARY_MENU, ...ADVANCED_MENU].map((m) => m.value)); + +/** Pure: a menu value maps to itself when known, else `undefined`. Unit-tested. */ +export function resolveMenuChoice(value: string): string | undefined { + return MENU_VALUES.has(value) ? value : undefined; +} + +const toOptions = (items: MenuItem[]) => + items.map(({ value, label, hint }) => (hint ? { value, label, hint } : { value, label })); + +/** + * Show the menu and return the chosen command name for the dispatcher to run, + * or `null` if the user chose Exit. Loops back from the Advanced submenu. + */ +export async function runMenu(): Promise { + intro('trek-plugin-sdk'); + for (;;) { + const choice = await promptSelect({ + message: 'What would you like to do?', + options: toOptions(PRIMARY_MENU), + }); + if (choice === 'exit') { + outro('Nothing to do — bye!'); + return null; + } + if (choice === 'advanced') { + const adv = await promptSelect({ message: 'Advanced commands', options: toOptions(ADVANCED_MENU) }); + if (adv === 'back') continue; + return adv; + } + return choice; + } +} diff --git a/plugin-sdk/src/cli/pack.ts b/plugin-sdk/src/cli/pack.ts index 02d215d3e..de1371951 100644 --- a/plugin-sdk/src/cli/pack.ts +++ b/plugin-sdk/src/cli/pack.ts @@ -12,6 +12,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { makeZip, type ZipInput } from '../zip.js'; +import { injectTrekUi } from '../ui/kit.js'; import { validatePluginDir } from './validate.js'; const MAX_TOTAL = 50 * 1024 * 1024; @@ -41,7 +42,14 @@ function walk(base: string, rel: string, out: ZipInput[]): void { walk(base, childRel, out); } else if (entry.isFile()) { if (entry.name.endsWith('.map') || entry.name.endsWith('.ts')) continue; - out.push({ name: childRel, data: fs.readFileSync(path.join(base, childRel)) }); + // Expand the `` marker into the inlined design kit as the file + // enters the archive, so the source stays a one-line opt-in. A no-op on HTML + // without the marker, so it's safe to run over every .html. + if (entry.name.endsWith('.html')) { + out.push({ name: childRel, data: Buffer.from(injectTrekUi(fs.readFileSync(path.join(base, childRel), 'utf8')), 'utf8') }); + } else { + out.push({ name: childRel, data: fs.readFileSync(path.join(base, childRel)) }); + } } // symlinks and other non-regular entries are skipped (never shipped) } diff --git a/plugin-sdk/src/cli/publish.ts b/plugin-sdk/src/cli/publish.ts index 5efb4f2d7..11c84b9e2 100644 --- a/plugin-sdk/src/cli/publish.ts +++ b/plugin-sdk/src/cli/publish.ts @@ -14,6 +14,7 @@ import { packPluginDir } from './pack.js'; import { buildEntry } from './entry.js'; import { preflight } from './preflight.js'; import { submitEntry } from './submit.js'; +import { plainLog, type LogSink } from './ui.js'; function git(dir: string, args: string[], quiet = true): string { return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: quiet ? 'pipe' : 'inherit' }).toString().trim(); @@ -25,15 +26,18 @@ function tagExists(dir: string, tag: string): boolean { export async function publishPlugin(opts: { dir: string; repo: string; tag: string; now: string; signKeyPath?: string; registry?: string; draft?: boolean; notes?: string; skipPreflight?: boolean; + /** Progress sink. Defaults to the plain console.error lines (CI parity). */ + log?: LogSink; }): Promise<{ prUrl: string }> { const dir = path.resolve(opts.dir); - const step = (n: number, msg: string) => console.error(`[${n}/4] ${msg}`); + const log = opts.log ?? plainLog; + const step = (n: number, msg: string) => log(`[${n}/4] ${msg}`); // 1. Pack step(1, 'Packing the artifact…'); const zip = path.join(dir, 'plugin.zip'); const packed = packPluginDir(dir, zip); - console.error(` ✓ ${packed.files.length} files, ${packed.size} bytes`); + log(` ✓ ${packed.files.length} files, ${packed.size} bytes`); // 2. Tag (if needed) + push + GitHub release with the artifact attached step(2, `Tagging ${opts.tag} + creating the GitHub release…`); @@ -47,24 +51,24 @@ export async function publishPlugin(opts: { // Release already exists — (re)upload the packed artifact so its bytes match the pin. execFileSync('gh', ['release', 'upload', opts.tag, packed.artifact, '--repo', opts.repo, '--clobber'], { stdio: 'pipe' }); } - console.error(` ✓ release ${opts.tag} on ${opts.repo}`); + log(` ✓ release ${opts.tag} on ${opts.repo}`); // 3. Build the entry, then run the registry CI checks locally const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now }); if (opts.skipPreflight) { - console.error('[3/4] Preflight skipped (--no-preflight).'); + log('[3/4] Preflight skipped (--no-preflight).'); } else { step(3, 'Preflight — running the registry CI checks…'); const rep = await preflight(entry); - for (const f of rep.failures) console.error(' ✗ ' + f); + for (const f of rep.failures) log(' ✗ ' + f); if (!rep.ok) throw new Error(`preflight found ${rep.failures.length} problem(s) — fix these and re-run (nothing was submitted). Did you push your code to ${opts.repo} before publishing?`); - console.error(` ✓ all ${rep.passed.length} checks passed`); + log(` ✓ all ${rep.passed.length} checks passed`); } // 4. Open the registry PR step(4, 'Opening the registry PR…'); const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft }); - console.error(' ✓ done'); + log(' ✓ done'); fs.rmSync(zip, { force: true }); return { prUrl }; } diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index 9d9282649..418c4b8e2 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -34,6 +34,11 @@ import { submitEntry } from './submit.js'; import { publishPlugin } from './publish.js'; import { generateKeypair, loadPrivateKey, signArtifact, publicKeyBase64, defaultKeyPath } from './sign.js'; import { readJsonFile } from './json.js'; +import { + isInteractive, intro, outro, note, logInfo, logSuccess, logWarn, logError, spinner, + promptText, promptConfirm, clackLogSink, missingArgs, +} from './ui.js'; +import { runMenu } from './menu.js'; const [cmd, ...args] = process.argv.slice(2); @@ -57,116 +62,210 @@ function fail(msg: string): never { const { flags, pos } = parse(args); +type Flags = Record; + /** --sign, --sign , or absent → the key path to sign with (or undefined). */ -function signKey(): string | undefined { - if (!flags.sign) return undefined; - return flags.sign === 'true' ? (flags.key || defaultKeyPath()) : flags.sign; +function signKey(f: Flags): string | undefined { + if (!f.sign) return undefined; + return f.sign === 'true' ? (f.key || defaultKeyPath()) : f.sign; } +/** + * Resolve the repo + tag every publishing command needs. Interactive: prompt for + * whatever is missing. Non-interactive: reproduce the command's exact error so + * scripts/CI behave exactly as before. + */ +async function ensureRepoTag(f: Flags, failMsg: string): Promise<{ repo: string; tag: string }> { + if (missingArgs(f, ['repo', 'tag']).length === 0) return { repo: f.repo, tag: f.tag }; + if (!isInteractive()) fail(failMsg); + const repo = f.repo || await promptText({ + message: 'GitHub repo (owner/name)', placeholder: 'you/trek-plugin-thing', + validate: (v) => (/^[^/\s]+\/[^/\s]+$/.test((v ?? '').trim()) ? undefined : 'format: owner/name'), + }); + const tag = f.tag || await promptText({ + message: 'Release tag', placeholder: 'v1.0.0', + validate: (v) => (/^v\d+\.\d+\.\d+/.test((v ?? '').trim()) ? undefined : 'format: vX.Y.Z'), + }); + return { repo: repo.trim(), tag: tag.trim() }; +} + +const USAGE = 'usage: trek-plugin [...]'; + async function main(): Promise { - if (cmd === 'create') { - const name = pos[0]; - if (!name || flags.interactive) { - const created = await interactiveScaffold(process.cwd(), name); - console.log(`\nCreated ${created}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${created}\`.`); + if (!cmd) { + // Bare invocation: a menu in a terminal, the usage line for scripts. + if (!isInteractive()) { console.error(USAGE); process.exit(2); } + const chosen = await runMenu(); + if (chosen) await dispatch(chosen, {}, []); + return; + } + await dispatch(cmd, flags, pos); +} + +async function dispatch(command: string, f: Flags, positional: string[]): Promise { + const tui = isInteractive(); + if (command === 'create') { + const name = positional[0]; + if (!name || f.interactive) { + if (!tui) fail('create needs a plugin name in non-interactive mode: create [--type integration|page|widget]'); + await interactiveScaffold(process.cwd(), name); return; } - scaffold(name, flags.type || 'integration', process.cwd(), { - author: flags.author, description: flags.description, - permissions: flags.permissions ? flags.permissions.split(/[\s,]+/).filter(Boolean) : undefined, + scaffold(name, f.type || 'integration', process.cwd(), { + author: f.author, description: f.description, + permissions: f.permissions ? f.permissions.split(/[\s,]+/).filter(Boolean) : undefined, }); console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`); - } else if (cmd === 'dev') { - await runDev(pos[0] || '.', { port: flags.port ? Number(flags.port) : undefined }); - } else if (cmd === 'validate') { - const r = validatePluginDir(pos[0] || '.'); - for (const w of r.warnings) console.warn('warning: ' + w); - if (!r.ok) { for (const e of r.errors) console.error('error: ' + e); process.exit(1); } - console.log('✓ plugin is valid'); - } else if (cmd === 'pack') { - const r = packPluginDir(pos[0] || '.', flags.out || 'plugin.zip'); - if (flags.json) { - console.log(JSON.stringify(r, null, 2)); + } else if (command === 'dev') { + if (tui) intro(`trek-plugin dev — ${positional[0] || '.'}`); + await runDev(positional[0] || '.', { port: f.port ? Number(f.port) : undefined }); + } else if (command === 'validate') { + const r = validatePluginDir(positional[0] || '.'); + if (tui) { + for (const w of r.warnings) logWarn(w); + if (!r.ok) { for (const e of r.errors) logError(e); outro('✗ plugin has errors'); process.exit(1); } + outro('✓ plugin is valid'); } else { - console.log(`Packed ${r.files.length} files -> ${path.relative(process.cwd(), r.artifact) || r.artifact}`); - for (const f of r.files) console.log(' ' + f); + for (const w of r.warnings) console.warn('warning: ' + w); + if (!r.ok) { for (const e of r.errors) console.error('error: ' + e); process.exit(1); } + console.log('✓ plugin is valid'); + } + } else if (command === 'pack') { + const r = packPluginDir(positional[0] || '.', f.out || 'plugin.zip'); + const rel = path.relative(process.cwd(), r.artifact) || r.artifact; + if (f.json) { + console.log(JSON.stringify(r, null, 2)); // machine output — never decorated + } else if (tui) { + note([...r.files, '', `sha256: ${r.sha256}`, `size: ${r.size}`].join('\n'), `Packed ${r.files.length} files → ${rel}`); + logInfo('Upload plugin.zip to your release, then run `npx trek-plugin-sdk entry`.'); + } else { + console.log(`Packed ${r.files.length} files -> ${rel}`); + for (const file of r.files) console.log(' ' + file); console.log(`\nsha256: ${r.sha256}\nsize: ${r.size}`); console.log('\nUpload this plugin.zip to your release, then run `npx trek-plugin-sdk entry` to generate the registry entry.'); } - } else if (cmd === 'keygen') { - const keyPath = flags.key || defaultKeyPath(); + } else if (command === 'keygen') { + const keyPath = f.key || defaultKeyPath(); const { publicKey } = generateKeypair(keyPath); - console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`); - console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`); - console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); - } else if (cmd === 'sign') { - const zip = pos[0] || 'plugin.zip'; + if (tui) { + note(`Signing key written to ${keyPath}\nKeep it safe + BACK IT UP — losing it means you can't ship signed updates.\n\nauthorPublicKey (goes in your registry entry):\n${publicKey}`, 'Signing key'); + logInfo('Sign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); + } else { + console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`); + console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`); + console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); + } + } else if (command === 'sign') { + const zip = positional[0] || 'plugin.zip'; if (!fs.existsSync(zip)) fail(`artifact not found: ${zip} — run \`npx trek-plugin-sdk pack\` first`); - const key = loadPrivateKey(flags.key || defaultKeyPath()); + const key = loadPrivateKey(f.key || defaultKeyPath()); const buf = fs.readFileSync(zip); - console.log(`signature: ${signArtifact(buf, key)}`); - console.log(`authorPublicKey: ${publicKeyBase64(key)}`); - } else if (cmd === 'entry') { - if (!flags.repo || !flags.tag) fail('entry needs --repo and --tag '); + if (tui) { + note(`signature: ${signArtifact(buf, key)}\nauthorPublicKey: ${publicKeyBase64(key)}`, `Signed ${zip}`); + } else { + console.log(`signature: ${signArtifact(buf, key)}`); + console.log(`authorPublicKey: ${publicKeyBase64(key)}`); + } + } else if (command === 'entry') { + const { repo, tag } = await ensureRepoTag(f, 'entry needs --repo and --tag '); const entry = buildEntry({ - dir: flags.dir || pos[0] || '.', - repo: flags.repo, tag: flags.tag, - zipPath: flags.zip || 'plugin.zip', - commit: flags.commit, asset: flags.asset, mergePath: flags.merge, - signKeyPath: signKey(), - now: new Date().toISOString(), + dir: f.dir || positional[0] || '.', repo, tag, + zipPath: f.zip || 'plugin.zip', + commit: f.commit, asset: f.asset, mergePath: f.merge, + signKeyPath: signKey(f), now: new Date().toISOString(), }); const json = JSON.stringify(entry, null, 2) + '\n'; - if (flags.out) { - fs.writeFileSync(flags.out, json); - console.error(`Wrote ${flags.out} — add it as registry/plugins/${entry.id}.json in a TREK-Plugins PR.`); + if (f.out) { + fs.writeFileSync(f.out, json); + const msg = `Wrote ${f.out} — add it as registry/plugins/${entry.id}.json in a TREK-Plugins PR.`; + if (tui) logSuccess(msg); else console.error(msg); } else { - process.stdout.write(json); + process.stdout.write(json); // machine output on stdout — never decorated + } + } else if (command === 'preflight') { + let entry: ReturnType; + if (f.entry) { + entry = readJsonFile>(f.entry); + } else { + const { repo, tag } = await ensureRepoTag(f, 'preflight needs --repo --tag , or --entry '); + entry = buildEntry({ dir: positional[0] || '.', repo, tag, zipPath: f.zip || 'plugin.zip', commit: f.commit, signKeyPath: signKey(f), now: new Date().toISOString() }); + } + if (tui) { + const s = spinner(); s.start('Running the registry CI checks over the network'); + const rep = await preflight(entry, { all: !!f.all }); + s.stop('Checks complete'); + for (const p of rep.passed) logSuccess(p); + for (const fa of rep.failures) logError(fa); + if (!rep.ok) { outro(`${rep.failures.length} check(s) would fail CI — fix these before submitting.`); process.exit(1); } + outro('✓ all checks passed — this entry should sail through CI.'); + } else { + console.error('Running registry CI checks over the network…\n'); + const rep = await preflight(entry, { all: !!f.all }); + for (const p of rep.passed) console.error(' ✓ ' + p); + for (const fa of rep.failures) console.error(' ✗ ' + fa); + if (!rep.ok) { console.error(`\n${rep.failures.length} check(s) would fail CI — fix these before submitting.`); process.exit(1); } + console.error('\n✓ all checks passed — this entry should sail through CI.'); + } + } else if (command === 'publish') { + const { repo, tag } = await ensureRepoTag(f, 'publish needs --repo and --tag '); + if (tui) { + note(`repo ${repo}\ntag ${tag}\ndir ${positional[0] || '.'}`, 'Publish'); + const ok = await promptConfirm({ message: 'Create the GitHub release and open the registry PR?', initialValue: true }); + if (!ok) { outro('Cancelled — nothing was published.'); return; } } - } else if (cmd === 'preflight') { - const entry = flags.entry - ? readJsonFile>(flags.entry) - : (flags.repo && flags.tag - ? buildEntry({ dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, zipPath: flags.zip || 'plugin.zip', commit: flags.commit, signKeyPath: signKey(), now: new Date().toISOString() }) - : fail('preflight needs --repo --tag , or --entry ')); - console.error('Running registry CI checks over the network…\n'); - const rep = await preflight(entry, { all: !!flags.all }); - for (const p of rep.passed) console.error(' ✓ ' + p); - for (const f of rep.failures) console.error(' ✗ ' + f); - if (!rep.ok) { console.error(`\n${rep.failures.length} check(s) would fail CI — fix these before submitting.`); process.exit(1); } - console.error('\n✓ all checks passed — this entry should sail through CI.'); - } else if (cmd === 'publish') { - if (!flags.repo || !flags.tag) fail('publish needs --repo and --tag '); const { prUrl } = await publishPlugin({ - dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, - signKeyPath: signKey(), registry: flags.registry, draft: !!flags.draft, - notes: flags.notes, skipPreflight: !!flags['no-preflight'], now: new Date().toISOString(), + dir: positional[0] || '.', repo, tag, + signKeyPath: signKey(f), registry: f.registry, draft: !!f.draft, + notes: f.notes, skipPreflight: !!f['no-preflight'], now: new Date().toISOString(), + log: tui ? clackLogSink : undefined, }); - console.error('\n✓ published — registry PR:'); - console.log(prUrl); - } else if (cmd === 'submit') { - if (!flags.repo || !flags.tag) fail('submit needs --repo and --tag '); + if (tui) logSuccess('Published — registry PR:'); else console.error('\n✓ published — registry PR:'); + console.log(prUrl); // machine output on stdout + } else if (command === 'submit') { + const { repo, tag } = await ensureRepoTag(f, 'submit needs --repo and --tag '); const entry = buildEntry({ - dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, - zipPath: flags.zip || 'plugin.zip', commit: flags.commit, signKeyPath: signKey(), - now: new Date().toISOString(), + dir: positional[0] || '.', repo, tag, + zipPath: f.zip || 'plugin.zip', commit: f.commit, signKeyPath: signKey(f), now: new Date().toISOString(), }); - console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`); - const { prUrl } = submitEntry(entry, { registry: flags.registry, branch: flags.branch, draft: !!flags.draft, keep: !!flags.keep }); - console.log(prUrl); - } else if (cmd === 'release') { - if (!flags.repo || !flags.tag) fail('release needs --repo and --tag '); - const dir = pos[0] || '.'; - const zip = path.resolve(dir, flags.out || 'plugin.zip'); - const packed = packPluginDir(dir, zip); - console.error(`Packed ${packed.files.length} files (${packed.size} bytes).`); - console.error(`Creating GitHub release ${flags.tag} on ${flags.repo}…`); - execFileSync('gh', ['release', 'create', flags.tag, packed.artifact, '--repo', flags.repo, '--title', flags.tag, '--notes', flags.notes || `Release ${flags.tag}`], { stdio: 'inherit' }); - const entry = buildEntry({ dir, repo: flags.repo, tag: flags.tag, zipPath: packed.artifact, commit: flags.commit, mergePath: flags.merge, signKeyPath: signKey(), now: new Date().toISOString() }); - console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n'); - process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); + if (tui) { + note(`${entry.id} ${entry.versions[0].version}\nrepo ${repo}`, 'Submit registry PR'); + const ok = await promptConfirm({ message: 'Open the registry PR now?', initialValue: true }); + if (!ok) { outro('Cancelled — no PR opened.'); return; } + const s = spinner(); s.start('Opening the registry PR'); + const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep }); + s.stop('Registry PR opened'); + console.log(prUrl); + } else { + console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`); + const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep }); + console.log(prUrl); + } + } else if (command === 'release') { + const { repo, tag } = await ensureRepoTag(f, 'release needs --repo and --tag '); + const dir = positional[0] || '.'; + const zip = path.resolve(dir, f.out || 'plugin.zip'); + if (tui) { + const packed = packPluginDir(dir, zip); + note(`packed ${packed.files.length} files (${packed.size} bytes)\nrepo ${repo}\ntag ${tag}`, 'Release'); + const ok = await promptConfirm({ message: `Create GitHub release ${tag} on ${repo}?`, initialValue: true }); + if (!ok) { outro('Cancelled — no release created.'); return; } + const s = spinner(); s.start(`Creating GitHub release ${tag}`); + execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'pipe' }); + s.stop(`Released ${tag} on ${repo}`); + const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() }); + logInfo(`Registry entry (add as registry/plugins/${entry.id}.json, or run \`npx trek-plugin-sdk submit\`):`); + process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); + } else { + const packed = packPluginDir(dir, zip); + console.error(`Packed ${packed.files.length} files (${packed.size} bytes).`); + console.error(`Creating GitHub release ${tag} on ${repo}…`); + execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'inherit' }); + const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() }); + console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n'); + process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); + } } else { - console.error('usage: trek-plugin [...]'); + console.error(USAGE); process.exit(2); } } diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts new file mode 100644 index 000000000..37903f94f --- /dev/null +++ b/plugin-sdk/src/cli/ui.ts @@ -0,0 +1,122 @@ +/** + * The one Clack styling layer for the plugin-author CLI (#plugins). + * + * Two rules make this safe to sprinkle across every command: + * 1. Nothing here runs unless `isInteractive()` — in CI / pipes the commands fall + * back to their plain, flag-driven behaviour with byte-identical output. + * 2. ALL decoration and prompts render to STDERR, so stdout stays a pure data + * channel (the `entry` JSON, `pack --json`, PR URLs) that can be piped. + */ +import { + intro as clackIntro, + outro as clackOutro, + note as clackNote, + log as clackLog, + spinner as clackSpinner, + cancel as clackCancel, + isCancel, + text as clackText, + select as clackSelect, + multiselect as clackMultiselect, + confirm as clackConfirm, + type TextOptions, + type SelectOptions, + type MultiSelectOptions, + type ConfirmOptions, + type SpinnerResult, +} from '@clack/prompts'; + +/** Every prompt and every decoration renders here, never on stdout. */ +const OUT = process.stderr; + +/** True only when a human is driving a real terminal on both stdin and stdout. */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +// ── decoration ───────────────────────────────────────────────────────────── + +export const intro = (title: string): void => clackIntro(title, { output: OUT }); +export const outro = (message: string): void => clackOutro(message, { output: OUT }); +export const note = (message: string, title?: string): void => clackNote(message, title, { output: OUT }); +export const logInfo = (m: string): void => clackLog.info(m, { output: OUT }); +export const logSuccess = (m: string): void => clackLog.success(m, { output: OUT }); +export const logWarn = (m: string): void => clackLog.warn(m, { output: OUT }); +export const logError = (m: string): void => clackLog.error(m, { output: OUT }); +export const spinner = (): SpinnerResult => clackSpinner({ output: OUT }); + +// ── cancellation ───────────────────────────────────────────────────────────── + +/** Clack returns a symbol when the user hits Ctrl+C — turn that into a clean exit. */ +export function orCancel(value: T | symbol): T { + if (isCancel(value)) { + clackCancel('Cancelled.', { output: OUT }); + process.exit(0); + } + return value as T; +} + +// ── prompts (cancel-checked, stderr-rendered) ──────────────────────────────── + +export const promptText = async (opts: Omit): Promise => + orCancel(await clackText({ ...opts, output: OUT })); + +export const promptSelect = async (opts: Omit, 'output'>): Promise => + orCancel(await clackSelect({ ...opts, output: OUT })); + +export const promptMultiselect = async (opts: Omit, 'output'>): Promise => + orCancel(await clackMultiselect({ ...opts, output: OUT })); + +export const promptConfirm = async (opts: Omit): Promise => + orCancel(await clackConfirm({ ...opts, output: OUT })); + +// ── permissions ────────────────────────────────────────────────────────────── + +/** + * The permissions an author can grant, each with a human hint for the multiselect. + * This list must stay in lockstep with the host's `KNOWN_PERMISSIONS` + * (server `src/nest/plugins/protocol/envelope.ts`) — same strings, same order. + * The manifest validator's `KNOWN_PERMISSIONS` (`../manifest.ts`) mirrors it too. + */ +export const PERMISSION_CATALOG: { value: string; label: string; hint: string }[] = [ + { value: 'db:own', label: 'db:own', hint: 'A private database only this plugin can read/write' }, + { value: 'db:read:trips', label: 'db:read:trips', hint: 'Read trips the requesting user can access' }, + { value: 'db:read:users', label: 'db:read:users', hint: 'Read basic user profiles' }, + { value: 'db:read:costs', label: 'db:read:costs', hint: 'Read costs (budget items) the requesting user can access' }, + { value: 'db:write:costs', label: 'db:write:costs', hint: 'Create costs (budget items) on trips the user can edit' }, + { value: 'db:write:places', label: 'db:write:places', hint: 'Add, edit and remove places on trips the user can edit' }, + { value: 'db:write:days', label: 'db:write:days', hint: 'Add, edit and remove days on trips the user can edit' }, + { value: 'db:write:itinerary', label: 'db:write:itinerary', hint: 'Assign and remove places on days of trips the user can edit' }, + { value: 'db:write:trips', label: 'db:write:trips', hint: 'Edit trip details (title, dates, currency…) on trips the user can edit' }, + { value: 'db:meta', label: 'db:meta', hint: 'Attach its own private data to trips, places and days the user can access' }, + { value: 'ws:broadcast:trip', label: 'ws:broadcast:trip', hint: 'Push realtime events to everyone on a trip' }, + { value: 'ws:broadcast:user', label: 'ws:broadcast:user', hint: 'Push realtime events to a single user' }, + { value: 'hook:photo-provider', label: 'hook:photo-provider', hint: 'Supply place photos to TREK' }, + { value: 'hook:calendar-source', label: 'hook:calendar-source', hint: 'Supply calendar events to TREK' }, + { value: 'hook:place-detail-provider', label: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' }, + { value: 'hook:trip-warning-provider', label: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' }, + { value: 'http:outbound', label: 'http:outbound', hint: 'Call external HTTP hosts (needs an egress allow-list)' }, +]; + +export const KNOWN_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value); + +// ── reporter seam (for commands that log their own progress, e.g. publish) ──── + +/** A progress sink. The default writes the same plain lines as before; the + * interactive one routes through Clack styling. Keeping this an injectable + * `log(msg)` means the tested publish path is byte-identical by default. */ +export type LogSink = (msg: string) => void; + +/** Non-interactive default: exactly the previous `console.error` behaviour. */ +export const plainLog: LogSink = (msg: string) => console.error(msg); + +/** Interactive sink: same text, rendered as a Clack step on stderr. */ +export const clackLogSink: LogSink = (msg: string) => clackLog.step(msg.trim(), { output: OUT }); + +// ── flag helpers ────────────────────────────────────────────────────────────── + +/** Pure: which of `required` flags are absent. Drives both the interactive + * prompt (fill the gaps) and the non-interactive error (report them). */ +export function missingArgs(flags: Record, required: string[]): string[] { + return required.filter((k) => !flags[k]); +} diff --git a/plugin-sdk/src/cli/validate.ts b/plugin-sdk/src/cli/validate.ts index ad4c2f192..c044e1564 100644 --- a/plugin-sdk/src/cli/validate.ts +++ b/plugin-sdk/src/cli/validate.ts @@ -7,7 +7,7 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { validateManifest } from '../manifest.js'; +import { validateManifest, KNOWN_ADDONS } from '../manifest.js'; import { readJsonFile } from './json.js'; export interface ValidateReport { @@ -33,6 +33,11 @@ export function validatePluginDir(dir: string): ValidateReport { if (manifestId && path.basename(path.resolve(dir)) !== manifestId) { warnings.push(`directory name should equal the plugin id "${manifestId}"`); } + // A well-formed but unrecognised addon id is not an error (the plugin may target + // a newer TREK), but it can never enable here — surface it as a warning. + for (const a of res.manifest?.requiredAddons ?? []) { + if (!KNOWN_ADDONS.includes(a)) warnings.push(`requiredAddons: "${a}" is not a known TREK addon on this SDK version`); + } } catch (e) { errors.push('trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : e)); } diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index aa79f11ca..50435272a 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -9,6 +9,20 @@ /** Bumped on any breaking change to the plugin API surface. Embed as `apiVersion` in your manifest. */ export const PLUGIN_API_VERSION = 1 as const; +// Core entity shapes returned by ctx reads/writes. Only `id` is guaranteed; the rest +// are the fields plugins most commonly use (typed for autocomplete), left optional +// because they mirror raw DB rows — and every shape keeps an index signature, so no +// column is ever hidden from you. +export interface Trip { id: number; user_id?: number; title?: string; start_date?: string | null; end_date?: string | null; currency?: string | null; [k: string]: unknown } +export interface Place { id: number; trip_id?: number; name?: string; lat?: number | null; lng?: number | null; day_id?: number | null; category_id?: number | null; notes?: string | null; [k: string]: unknown } +export interface Day { id: number; trip_id?: number; date?: string | null; title?: string | null; [k: string]: unknown } +export interface Reservation { id: number; trip_id?: number; type?: string; [k: string]: unknown } +export interface PackingItem { id: number; trip_id?: number; name?: string; [k: string]: unknown } +export interface TripFile { id: number; trip_id?: number; filename?: string; [k: string]: unknown } +export interface BudgetItem { id: number; trip_id?: number; name?: string; total_price?: number | null; currency?: string | null; [k: string]: unknown } +export interface Assignment { id: number; day_id?: number; place_id?: number; notes?: string | null; [k: string]: unknown } +export interface User { id: number; username?: string; display_name?: string | null; avatar?: string | null; [k: string]: unknown } + export interface PluginContext { readonly id: string; readonly config: Readonly>; @@ -18,11 +32,58 @@ export interface PluginContext { migrate(id: string, sql: string): Promise<{ applied: boolean }>; }; trips: { - getById(tripId: number, asUserId: number): Promise; - getPlaces(tripId: number, asUserId: number): Promise; - getReservations(tripId: number, asUserId: number): Promise; + getById(tripId: number, asUserId?: number): Promise; + getPlaces(tripId: number, asUserId?: number): Promise; + getReservations(tripId: number, asUserId?: number): Promise; + /** Update trip fields; needs `db:write:trips` + the acting user's trip_edit permission. Route context only. */ + update(tripId: number, input: Record): Promise; }; - users: { getById(id: number): Promise }; + // Read-only views of other trip subsystems (#1429 eco). Membership-checked against + // the current user; each needs its own db:read:* scope. + packing: { + /** A trip's packing items (hydrated bags/assignees). Needs `db:read:packing`. */ + list(tripId: number): Promise; + }; + files: { + /** A trip's files, trash excluded. Needs `db:read:files`. */ + list(tripId: number): Promise; + }; + // "Costs" = budget items. The acting user is bound by the host to the current + // invocation; create/update/delete also need 'budget_edit' and the Costs addon + // enabled. + costs: { + getByTrip(tripId: number): Promise; + listMine(): Promise; + create(tripId: number, input: Record): Promise; + update(tripId: number, itemId: number, input: Record): Promise; + delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>; + }; + // Core planner writes (#1429). Membership-checked against the invocation's user; + // each needs the matching write scope + the app's place_edit/day_edit permission. + places: { + create(tripId: number, input: Record): Promise; + update(tripId: number, placeId: number, input: Record): Promise; + delete(tripId: number, placeId: number): Promise<{ deleted: boolean }>; + }; + days: { + create(tripId: number, input: Record): Promise; + update(tripId: number, dayId: number, input: Record): Promise; + delete(tripId: number, dayId: number): Promise<{ deleted: boolean }>; + }; + itinerary: { + assign(tripId: number, dayId: number, placeId: number, notes?: string | null): Promise; + unassign(tripId: number, assignmentId: number): Promise<{ deleted: boolean }>; + }; + // Your OWN namespaced key/value store on a trip/place/day (#1429) — enrich core + // entities without forking the schema. Needs `db:meta`; the entity must belong to + // a trip the current user can access. Values are JSON-serialisable. + meta: { + get(entityType: 'trip' | 'place' | 'day', entityId: number, key: string): Promise; + set(entityType: 'trip' | 'place' | 'day', entityId: number, key: string, value: unknown): Promise; + list(entityType: 'trip' | 'place' | 'day', entityId: number): Promise>; + delete(entityType: 'trip' | 'place' | 'day', entityId: number, key: string): Promise<{ deleted: boolean }>; + }; + users: { getById(id: number): Promise }; ws: { broadcastToTrip(tripId: number, event: string, data: Record): Promise; broadcastToUser(userId: number, event: string, data: Record): Promise; @@ -32,6 +93,17 @@ export interface PluginContext { warn(msg: string, meta?: Record): void; error(msg: string, meta?: Record): void; }; + /** Call a function another plugin exposes. `pluginId` must be a declared, version- + * satisfied dependency that lists `fn` in its manifest `capabilities.provides`. The + * call runs as the current acting user. */ + plugins: { + call(pluginId: string, fn: string, args?: unknown): Promise; + }; + /** Publish an event to dependents that subscribed to it. `name` must be declared in + * this plugin's manifest `capabilities.emits`. Fire-and-forget. */ + events: { + emit(name: string, payload?: unknown): void; + }; } export interface PluginRequest { @@ -81,7 +153,40 @@ export interface CalendarEvent { } export interface CalendarSource { getName(): string; - getEvents(userId: number, start: Date, end: Date): Promise; + // start/end are ISO strings — the host->plugin boundary is JSON, so a Date would + // arrive as a string anyway (kept in lockstep with the runtime SDK copy). + getEvents(userId: number, start: string, end: string): Promise; +} +/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */ +export interface PlaceDetailItem { label: string; value?: string; url?: string; } +export interface PlaceDetailProvider { + /** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`. */ + getDetails(placeId: number, ctx: PluginContext): Promise; +} +/** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */ +export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; } +export interface WarningProvider { + /** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`. */ + getWarnings(tripId: number, ctx: PluginContext): Promise; +} + +/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job) + * and receive only the event name + tripId — never the payload. Needs `events:subscribe`. */ +export interface PluginEventSubscription { + /** A core event name (e.g. `place:created`, `day:updated`, `file:created`) or `*` for all. */ + on: string; + handler(payload: { event: string; tripId: number }, ctx: PluginContext): Promise | void; +} + +/** A function this plugin exposes to its dependents (declared in `capabilities.provides`). */ +export type PluginExport = (args: unknown, ctx: PluginContext) => Promise | unknown; + +/** A subscription to another plugin's event. Authorized by declaring that plugin as a + * `pluginDependency`; the handler runs with NO user and receives the emitter's payload. */ +export interface PluginSubscription { + plugin: string; + event: string; + handler(payload: unknown, ctx: PluginContext): Promise | void; } export interface PluginDefinition { @@ -89,7 +194,17 @@ export interface PluginDefinition { onUnload?(ctx: PluginContext): Promise | void; routes?: PluginRoute[]; jobs?: PluginJob[]; - hooks?: { photoProvider?: PhotoProvider; calendarSource?: CalendarSource }; + events?: PluginEventSubscription[]; + hooks?: { + photoProvider?: PhotoProvider; + calendarSource?: CalendarSource; + placeDetailProvider?: PlaceDetailProvider; + warningProvider?: WarningProvider; + }; + /** Functions exposed to dependents (names must match manifest `capabilities.provides`). */ + exports?: Record; + /** Subscriptions to other plugins' events (each `plugin` must be a declared dependency). */ + subscriptions?: PluginSubscription[]; } /** Define a plugin. Gives you types; the returned object is what TREK loads. */ @@ -99,3 +214,7 @@ export function definePlugin(def: PluginDefinition): PluginDefinition { export { validateManifest, type PluginManifest, type ValidationResult } from './manifest.js'; export { createMockHost, type MockHostOptions } from './mock-host.js'; +// The design kit for page/widget UIs: inline these into your client/index.html +// (or drop a `` marker and let `dev`/`pack` expand it) to get the +// native TREK look — glass, hover, buttons, inputs — plus a `window.trek` bridge. +export { TREK_UI_CSS, TREK_THEME_JS, TREK_UI_MARKER, injectTrekUi } from './ui/kit.js'; diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 1ffa3452d..f931629fc 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -4,15 +4,23 @@ * result (no throw) so the CLI can print every problem at once. */ +import { validRange } from 'semver'; + +export interface PluginDependency { + id: string; + version: string; +} export interface PluginManifest { id: string; name: string; version: string; apiVersion: number; - type: 'integration' | 'page' | 'widget'; + type: 'integration' | 'page' | 'widget' | 'trip-page'; permissions: string[]; egress: string[]; nativeModules?: boolean; + requiredAddons: string[]; + pluginDependencies: PluginDependency[]; } export interface ValidationResult { ok: boolean; @@ -25,14 +33,25 @@ const ID_RE = /^[a-z][a-z0-9-]{2,39}$/; // install loader, so surface it locally too. const RESERVED_IDS = new Set(['registry', 'install', 'rescan']); const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +// Addon-id format (lowercase slug, underscores allowed e.g. `llm_parsing`). +const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/; +// Mirror of the server's ADDON_IDS (server/src/addons.ts). Kept in sync so +// `trek-plugin validate` can WARN on an addon id TREK doesn't know — never a hard +// error (a plugin built for a newer TREK may reference an addon this SDK predates). +export const KNOWN_ADDONS = [ + 'mcp', 'packing', 'budget', 'documents', 'vacay', 'atlas', 'collab', 'journey', 'airtrail', 'llm_parsing', 'collections', +]; // An outbound host: exact hostname (single-label sibling or dotted FQDN) or a // `*.`-wildcard with a multi-label suffix. No `*`, no `*.`, no whole-TLD `*.com`, // no spaces (mirrors the server manifest validator). const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i; -const TYPES = ['integration', 'page', 'widget']; +const TYPES = ['integration', 'page', 'widget', 'trip-page']; const KNOWN_PERMISSIONS = [ - 'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user', - 'hook:photo-provider', 'hook:calendar-source', 'http:outbound', + 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:write:costs', + 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', + 'db:meta', + 'ws:broadcast:trip', 'ws:broadcast:user', + 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'events:subscribe', 'http:outbound', ]; function isKnownPermission(p: string): boolean { @@ -70,10 +89,16 @@ export function validateManifest(raw: unknown): ValidationResult { if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"'); for (const h of egress) if (!HOST_RE.test(h)) errors.push(`invalid egress host "${h}"`); - const widget = (m.capabilities as { widget?: { slot?: unknown } } | undefined)?.widget; - if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero') { - errors.push(`widget slot must be "sidebar" or "hero", got "${String(widget.slot)}"`); + const capabilities = (m.capabilities ?? undefined) as { widget?: { slot?: unknown }; provides?: unknown; emits?: unknown } | undefined; + const widget = capabilities?.widget; + if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero' && widget.slot !== 'place-detail') { + errors.push(`widget slot must be "sidebar", "hero" or "place-detail", got "${String(widget.slot)}"`); } + validateCapabilityNames(capabilities?.provides, 'provides', errors); + validateCapabilityNames(capabilities?.emits, 'emits', errors); + + const requiredAddons = validateRequiredAddons(m.requiredAddons, errors); + const pluginDependencies = validatePluginDependencies(m.pluginDependencies, typeof m.id === 'string' ? m.id : '', errors); if (errors.length) return { ok: false, errors }; return { @@ -88,6 +113,66 @@ export function validateManifest(raw: unknown): ValidationResult { permissions, egress, nativeModules: false, + requiredAddons, + pluginDependencies, }, }; } + +// Export/event names exposed to other plugins (dots allowed for event names). +const CAPABILITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/; + +function validateCapabilityNames(raw: unknown, field: string, errors: string[]): void { + if (raw === undefined) return; + if (!Array.isArray(raw)) { + errors.push(`capabilities.${field} must be an array of names`); + return; + } + for (const v of raw) { + if (typeof v !== 'string' || !CAPABILITY_NAME_RE.test(v)) errors.push(`invalid capabilities.${field} entry "${String(v)}"`); + } +} + +function validateRequiredAddons(raw: unknown, errors: string[]): string[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + errors.push('requiredAddons must be an array of addon ids'); + return []; + } + const out: string[] = []; + for (const v of raw) { + if (typeof v !== 'string' || !ADDON_ID_RE.test(v)) { + errors.push(`invalid requiredAddons entry "${String(v)}"`); + continue; + } + if (!out.includes(v)) out.push(v); + } + return out; +} + +function validatePluginDependencies(raw: unknown, selfId: string, errors: string[]): PluginDependency[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + errors.push('pluginDependencies must be an array of { id, version }'); + return []; + } + const out: PluginDependency[] = []; + for (const v of raw) { + if (!v || typeof v !== 'object') { + errors.push('each pluginDependencies entry must be an object'); + continue; + } + const d = v as Record; + const id = typeof d.id === 'string' ? d.id : ''; + const version = typeof d.version === 'string' ? d.version : ''; + if (!ID_RE.test(id)) errors.push(`invalid pluginDependencies id "${id}"`); + else if (RESERVED_IDS.has(id)) errors.push(`pluginDependencies id "${id}" is reserved`); + else if (id === selfId) errors.push(`plugin "${selfId}" cannot depend on itself`); + else if (out.some((e) => e.id === id)) errors.push(`duplicate pluginDependencies id "${id}"`); + if (!version || validRange(version) === null) errors.push(`invalid pluginDependencies version range "${version}" for "${id || '?'}"`); + if (ID_RE.test(id) && !RESERVED_IDS.has(id) && id !== selfId && version && validRange(version) !== null && !out.some((e) => e.id === id)) { + out.push({ id, version }); + } + } + return out; +} diff --git a/plugin-sdk/src/mock-host.ts b/plugin-sdk/src/mock-host.ts index 8c979d6b2..956662684 100644 --- a/plugin-sdk/src/mock-host.ts +++ b/plugin-sdk/src/mock-host.ts @@ -1,4 +1,4 @@ -import type { PluginContext } from './index.js'; +import type { PluginContext, Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, User } from './index.js'; /** * A mock PluginContext for unit-testing a plugin without a running TREK @@ -12,11 +12,31 @@ import type { PluginContext } from './index.js'; export interface MockHostOptions { grants?: string[]; config?: Record; - /** Fixtures keyed by trip id; `members` gates access like the real host. */ - trips?: Record; + /** + * Fixtures keyed by trip id; `members` gates access like the real host. + * `costs` seeds budget items; `canEditCosts` (default true) models the + * 'budget_edit' permission for `costs.create`. + */ + trips?: Record< + number, + { + members: number[]; data?: unknown; places?: unknown[]; reservations?: unknown[]; costs?: unknown[]; + days?: unknown[]; assignments?: unknown[]; packing?: unknown[]; files?: unknown[]; + /** Default true — model the place_edit / day_edit / trip_edit permission for writes. */ + canEditCosts?: boolean; canEditPlaces?: boolean; canEditDays?: boolean; canEditTrip?: boolean; + } + >; users?: Record; /** Optional canned db.query results, keyed by the exact sql string. */ queryResults?: Record; + /** The host-bound acting user for costs.* (a job/onLoad has none → refused). */ + actingUserId?: number; + /** Whether the Costs (budget) addon is enabled; gates all costs.* (default true). */ + budgetAddonEnabled?: boolean; + /** Exports of the plugins this one depends on, keyed by plugin id then fn name. + * `ctx.plugins.call(id, fn, args)` invokes the matching function; a missing entry + * throws RESOURCE_FORBIDDEN (models "not a satisfied dependency / not exported"). */ + pluginExports?: Record unknown>>; } export interface MockHost { @@ -25,6 +45,8 @@ export interface MockHost { calls: { method: string; args: unknown[] }[]; logs: { level: string; msg: string }[]; broadcasts: { kind: 'trip' | 'user'; target: number; event: string; data: unknown }[]; + /** Events the plugin published via ctx.events.emit, for assertions. */ + emitted: { name: string; payload: unknown }[]; } class PermissionDenied extends Error {} @@ -34,6 +56,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { const calls: MockHost['calls'] = []; const logs: MockHost['logs'] = []; const broadcasts: MockHost['broadcasts'] = []; + const emitted: MockHost['emitted'] = []; const need = (perm: string, method: string) => { calls.push({ method, args: [] }); @@ -44,6 +67,30 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { if (!t || !t.members.includes(asUserId)) throw new Error(`RESOURCE_FORBIDDEN: no access to trip ${tripId}`); return t; }; + const requireBudgetAddon = () => { + if (opts.budgetAddonEnabled === false) throw new Error('RESOURCE_FORBIDDEN: the costs addon is disabled'); + }; + const requireActingUser = (): number => { + if (opts.actingUserId === undefined) throw new Error('RESOURCE_FORBIDDEN: this call requires an authenticated user context'); + return opts.actingUserId; + }; + const assertEdit = ( + t: { canEditPlaces?: boolean; canEditDays?: boolean; canEditTrip?: boolean }, + flag: 'canEditPlaces' | 'canEditDays' | 'canEditTrip', + tripId: number, + ) => { + if (t[flag] === false) throw new Error(`RESOURCE_FORBIDDEN: no permission to edit trip ${tripId}`); + }; + const rows = (arr: unknown[] | undefined): Array> => (arr ?? []) as Array>; + // In-memory namespaced metadata store for ctx.meta (per mock plugin). + const metaStore: Record = {}; + const metaKey = (et: string, eid: number, key: string) => `${et}:${eid}:${key}`; + const metaGate = (entityType: string, entityId: number) => { + // The real host resolves place/day → trip; the mock only membership-checks the + // 'trip' entity type and otherwise just requires an acting user. + if (entityType === 'trip') assertMember(entityId, requireActingUser()); + else requireActingUser(); + }; const ctx: PluginContext = { id: 'mock-plugin', @@ -65,21 +112,199 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { trips: { async getById(tripId, asUserId) { need('db:read:trips', 'trips.getById'); - return assertMember(tripId, asUserId).data ?? null; + return (assertMember(tripId, asUserId ?? requireActingUser()).data ?? null) as Trip | null; }, async getPlaces(tripId, asUserId) { need('db:read:trips', 'trips.getPlaces'); - return assertMember(tripId, asUserId).places ?? []; + return (assertMember(tripId, asUserId ?? requireActingUser()).places ?? []) as Place[]; }, async getReservations(tripId, asUserId) { need('db:read:trips', 'trips.getReservations'); - return assertMember(tripId, asUserId).reservations ?? []; + return (assertMember(tripId, asUserId ?? requireActingUser()).reservations ?? []) as Reservation[]; + }, + async update(tripId, input) { + need('db:write:trips', 'trips.update'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditTrip', tripId); + const data = (t.data ??= {}) as Record; + Object.assign(data, input); + return data as Trip; + }, + }, + packing: { + async list(tripId) { + need('db:read:packing', 'packing.list'); + return (assertMember(tripId, requireActingUser()).packing ?? []) as PackingItem[]; + }, + }, + files: { + async list(tripId) { + need('db:read:files', 'files.list'); + return (assertMember(tripId, requireActingUser()).files ?? []) as TripFile[]; + }, + }, + costs: { + async getByTrip(tripId) { + need('db:read:costs', 'costs.getByTrip'); + requireBudgetAddon(); + return (assertMember(tripId, requireActingUser()).costs ?? []) as BudgetItem[]; + }, + async listMine() { + need('db:read:costs', 'costs.listMine'); + requireBudgetAddon(); + const uid = requireActingUser(); + return Object.values(opts.trips ?? {}) + .filter((t) => t.members.includes(uid)) + .flatMap((t) => t.costs ?? []) as BudgetItem[]; + }, + async create(tripId, input) { + need('db:write:costs', 'costs.create'); + requireBudgetAddon(); + const t = assertMember(tripId, requireActingUser()); + if (t.canEditCosts === false) { + throw new Error(`RESOURCE_FORBIDDEN: no permission to edit costs on trip ${tripId}`); + } + const item = { id: (t.costs?.length ?? 0) + 1, trip_id: tripId, ...input }; + (t.costs ??= []).push(item); + return item; + }, + async update(tripId, itemId, input) { + need('db:write:costs', 'costs.update'); + requireBudgetAddon(); + const t = assertMember(tripId, requireActingUser()); + if (t.canEditCosts === false) { + throw new Error(`RESOURCE_FORBIDDEN: no permission to edit costs on trip ${tripId}`); + } + const item = rows(t.costs).find((x) => x.id === itemId); + if (!item) throw new Error(`RESOURCE_FORBIDDEN: no cost ${itemId} on trip ${tripId}`); + Object.assign(item, input); + return item as BudgetItem; + }, + async delete(tripId, itemId) { + need('db:write:costs', 'costs.delete'); + requireBudgetAddon(); + const t = assertMember(tripId, requireActingUser()); + if (t.canEditCosts === false) { + throw new Error(`RESOURCE_FORBIDDEN: no permission to edit costs on trip ${tripId}`); + } + const list = rows((t.costs ??= [])); + const i = list.findIndex((x) => x.id === itemId); + if (i < 0) throw new Error(`RESOURCE_FORBIDDEN: no cost ${itemId} on trip ${tripId}`); + list.splice(i, 1); + return { deleted: true }; + }, + }, + places: { + async create(tripId, input) { + need('db:write:places', 'places.create'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditPlaces', tripId); + const place = { id: (t.places?.length ?? 0) + 1, trip_id: tripId, ...input }; + (t.places ??= []).push(place); + return place; + }, + async update(tripId, placeId, input) { + need('db:write:places', 'places.update'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditPlaces', tripId); + const place = rows(t.places).find((x) => x.id === placeId); + if (!place) throw new Error(`RESOURCE_FORBIDDEN: no place ${placeId} on trip ${tripId}`); + Object.assign(place, input); + return place as Place; + }, + async delete(tripId, placeId) { + need('db:write:places', 'places.delete'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditPlaces', tripId); + const list = rows((t.places ??= [])); + const i = list.findIndex((x) => x.id === placeId); + if (i < 0) throw new Error(`RESOURCE_FORBIDDEN: no place ${placeId} on trip ${tripId}`); + list.splice(i, 1); + return { deleted: true }; + }, + }, + days: { + async create(tripId, input) { + need('db:write:days', 'days.create'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const day = { id: (t.days?.length ?? 0) + 1, trip_id: tripId, ...input }; + (t.days ??= []).push(day); + return day; + }, + async update(tripId, dayId, input) { + need('db:write:days', 'days.update'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const day = rows(t.days).find((x) => x.id === dayId); + if (!day) throw new Error(`RESOURCE_FORBIDDEN: no day ${dayId} on trip ${tripId}`); + Object.assign(day, input); + return day as Day; + }, + async delete(tripId, dayId) { + need('db:write:days', 'days.delete'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const list = rows((t.days ??= [])); + const i = list.findIndex((x) => x.id === dayId); + if (i < 0) throw new Error(`RESOURCE_FORBIDDEN: no day ${dayId} on trip ${tripId}`); + list.splice(i, 1); + return { deleted: true }; + }, + }, + itinerary: { + async assign(tripId, dayId, placeId, notes) { + need('db:write:itinerary', 'itinerary.assign'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const assignment = { id: (t.assignments?.length ?? 0) + 1, day_id: dayId, place_id: placeId, notes: notes ?? null }; + (t.assignments ??= []).push(assignment); + return assignment; + }, + async unassign(tripId, assignmentId) { + need('db:write:itinerary', 'itinerary.unassign'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const list = rows((t.assignments ??= [])); + const i = list.findIndex((x) => x.id === assignmentId); + if (i < 0) throw new Error(`RESOURCE_FORBIDDEN: no assignment ${assignmentId} on trip ${tripId}`); + list.splice(i, 1); + return { deleted: true }; + }, + }, + meta: { + async get(entityType, entityId, key) { + need('db:meta', 'meta.get'); + metaGate(entityType, entityId); + return metaStore[metaKey(entityType, entityId, key)] ?? null; + }, + async set(entityType, entityId, key, value) { + need('db:meta', 'meta.set'); + metaGate(entityType, entityId); + metaStore[metaKey(entityType, entityId, key)] = value ?? null; + return { key, value: value ?? null }; + }, + async list(entityType, entityId) { + need('db:meta', 'meta.list'); + metaGate(entityType, entityId); + const prefix = `${entityType}:${entityId}:`; + const out: Record = {}; + for (const k of Object.keys(metaStore)) if (k.startsWith(prefix)) out[k.slice(prefix.length)] = metaStore[k]; + return out; + }, + async delete(entityType, entityId, key) { + need('db:meta', 'meta.delete'); + metaGate(entityType, entityId); + const k = metaKey(entityType, entityId, key); + const had = k in metaStore; + delete metaStore[k]; + return { deleted: had }; }, }, users: { async getById(id) { need('db:read:users', 'users.getById'); - return opts.users?.[id] ?? null; + return (opts.users?.[id] ?? null) as User | null; }, }, ws: { @@ -97,7 +322,21 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { warn: (msg) => logs.push({ level: 'warn', msg }), error: (msg) => logs.push({ level: 'error', msg }), }, + plugins: { + async call(pluginId, fn, args) { + calls.push({ method: 'plugins.call', args: [pluginId, fn, args] }); + const impl = opts.pluginExports?.[pluginId]?.[fn]; + if (typeof impl !== 'function') throw new Error(`RESOURCE_FORBIDDEN: plugin ${pluginId} does not export "${fn}"`); + return impl(args); + }, + }, + events: { + emit(name, payload) { + calls.push({ method: 'events.emit', args: [name, payload] }); + emitted.push({ name, payload }); + }, + }, }; - return { ctx, calls, logs, broadcasts }; + return { ctx, calls, logs, broadcasts, emitted }; } diff --git a/plugin-sdk/src/ui/kit.ts b/plugin-sdk/src/ui/kit.ts new file mode 100644 index 000000000..eff2eae83 --- /dev/null +++ b/plugin-sdk/src/ui/kit.ts @@ -0,0 +1,335 @@ +/** + * The TREK plugin design kit (#plugins). + * + * A plugin's UI runs in a sandboxed, opaque-origin iframe — it can't load TREK's + * stylesheet, only postMessage. So instead of forcing every author to re-derive the + * look, we ship it: a token-driven stylesheet (`TREK_UI_CSS`) plus a tiny bootstrap + * (`TREK_THEME_JS`) that wires the frame to the host. Both are plain strings, meant + * to be INLINED into the plugin's own `client/index.html` (the CSP forbids external + * /` (typically via + * the `` marker). It: announces readiness; applies the host's theme + * tokens, theme name and appearance flags to the document; auto-reports its height so + * a widget/page self-sizes; and installs a small `window.trek` helper over the same + * bridge messages the host already understands — it adds no new capability. + */ +export const TREK_THEME_JS = `(function () { + 'use strict'; + var docEl = document.documentElement; + var ctxHandlers = []; + var lastCtx = null; + var pending = {}; + var seq = 0; + var lastH = -1; + + function send(msg) { try { window.parent.postMessage(msg, '*'); } catch (e) {} } + function setFlag(name, on) { if (on) { docEl.setAttribute(name, ''); } else { docEl.removeAttribute(name); } } + + function applyContext(m) { + if (m.theme) { docEl.setAttribute('data-theme', m.theme); } + var t = m.tokens || {}; + for (var k in t) { + if (Object.prototype.hasOwnProperty.call(t, k) && t[k]) { docEl.style.setProperty(k, t[k]); } + } + var a = m.appearance || {}; + setFlag('data-reduce-motion', a.reducedMotion); + setFlag('data-no-transparency', a.noTransparency); + if (a.density) { docEl.setAttribute('data-density', a.density); } + if (a.scheme) { docEl.setAttribute('data-scheme', a.scheme); } + if (document.body) { document.body.classList.add('trek-ui'); } + } + + function reportHeight() { + var h = Math.ceil(document.documentElement.scrollHeight); + if (h > 0 && h !== lastH) { lastH = h; send({ type: 'trek:resize', height: h }); } + } + + window.addEventListener('message', function (ev) { + // Opaque frame: origin serialises to 'null', so trust the SENDER — only our real + // parent window. Never act on a claimed id or on origin. + if (ev.source !== window.parent) { return; } + var m = ev.data; + if (!m || typeof m !== 'object') { return; } + if (m.type === 'trek:context') { + lastCtx = m; api.context = m; + applyContext(m); + for (var i = 0; i < ctxHandlers.length; i++) { try { ctxHandlers[i](m); } catch (e) {} } + reportHeight(); + } else if (m.type === 'trek:response') { + var p = pending[m.requestId]; + if (p) { delete pending[m.requestId]; p.resolve(m.data); } + } else if (m.type === 'trek:error') { + var q = pending[m.requestId]; + if (q) { delete pending[m.requestId]; var err = new Error(m.message || 'invoke failed'); err.code = m.code; q.reject(err); } + } + }); + + // Native DOM helpers so a widget can build kit-styled UI with no bundler and no + // hand-written CSS — every element carries the same trek-* classes the kit ships. + function mkEl(tag, props, children) { + var node = document.createElement(tag); + props = props || {}; + for (var k in props) { + if (!Object.prototype.hasOwnProperty.call(props, k)) { continue; } + var v = props[k]; + if (v == null) { continue; } + if (k === 'class' || k === 'className') { node.className = v; } + else if (k === 'text') { node.textContent = v; } + else if (k === 'html') { node.innerHTML = v; } + else if (k === 'on') { for (var ev in v) { if (Object.prototype.hasOwnProperty.call(v, ev)) { node.addEventListener(ev, v[ev]); } } } + else { node.setAttribute(k, v); } + } + var kids = children == null ? [] : (typeof children === 'string' || children.nodeType ? [children] : children); + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c == null) { continue; } + node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return node; + } + var ui = { + el: mkEl, + button: function (label, opts) { + opts = opts || {}; + return mkEl('button', { class: 'trek-btn' + (opts.variant ? ' trek-btn--' + opts.variant : ''), type: 'button', text: label, on: opts.onClick ? { click: opts.onClick } : null }, null); + }, + card: function (children) { return mkEl('div', { class: 'trek-card' }, children); }, + chip: function (text, variant) { return mkEl('span', { class: 'trek-chip' + (variant ? ' trek-chip--' + variant : ''), text: text }, null); }, + input: function (opts) { opts = opts || {}; return mkEl('input', { class: 'trek-input', type: opts.type || 'text', placeholder: opts.placeholder || '', value: opts.value || '' }, null); }, + mount: function (node, target) { (target || document.body).appendChild(node); return node; } + }; + + var api = { + context: null, + ui: ui, + ready: function () { send({ type: 'trek:ready' }); }, + requestContext: function () { send({ type: 'trek:context:request' }); }, + onContext: function (cb) { + ctxHandlers.push(cb); + if (lastCtx) { try { cb(lastCtx); } catch (e) {} } + return function () { var i = ctxHandlers.indexOf(cb); if (i >= 0) { ctxHandlers.splice(i, 1); } }; + }, + notify: function (level, message) { send({ type: 'trek:notify', level: level, message: message }); }, + navigate: function (to) { send({ type: 'trek:navigate', to: to }); }, + resize: function (px) { var h = px | 0; if (h > 0) { lastH = h; send({ type: 'trek:resize', height: h }); } }, + invoke: function (sub, opts) { + opts = opts || {}; + var id = 'r' + (++seq); + return new Promise(function (resolve, reject) { + pending[id] = { resolve: resolve, reject: reject }; + send({ type: 'trek:invoke', requestId: id, sub: sub, method: opts.method, body: opts.body }); + }); + } + }; + window.trek = api; + + function boot() { + if (document.body) { document.body.classList.add('trek-ui'); } + api.ready(); + reportHeight(); + if (typeof ResizeObserver !== 'undefined' && document.body) { + new ResizeObserver(reportHeight).observe(document.body); + } + } + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } +})();`; + +/** + * Replace the `` marker in a plugin's HTML with the inlined kit + * (style + bootstrap). A no-op when the marker is absent, so it is safe to run over + * any HTML. The source file on disk is never touched — the expansion happens at + * dev-serve / pack time, so an author's `client/index.html` stays a one-line opt-in. + */ +export function injectTrekUi(html: string): string { + if (!html.includes(TREK_UI_MARKER)) return html; + const block = `\n`; + return html.split(TREK_UI_MARKER).join(block); +} diff --git a/plugin-sdk/test/cli.test.ts b/plugin-sdk/test/cli.test.ts new file mode 100644 index 000000000..6e6728db9 --- /dev/null +++ b/plugin-sdk/test/cli.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { validateManifest } from '../src/index.js'; +import { scaffold } from '../src/cli/create.js'; +import { PERMISSION_CATALOG, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js'; +import { resolveMenuChoice, PRIMARY_MENU, ADVANCED_MENU } from '../src/cli/menu.js'; + +describe('scaffold egress (http:outbound)', () => { + let tmp: string; + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'egress-')); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('writes egress so an http:outbound plugin validates', () => { + scaffold('net-plug', 'integration', tmp, { permissions: ['http:outbound'], egress: ['api.example.com'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'net-plug', 'trek-plugin.json'), 'utf8')); + expect(m.egress).toEqual(['api.example.com']); + expect(validateManifest(m).ok).toBe(true); + }); + + it('without egress the same manifest is invalid — which the wizard now prevents', () => { + scaffold('net-plug', 'integration', tmp, { permissions: ['http:outbound'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'net-plug', 'trek-plugin.json'), 'utf8')); + expect(m.egress).toBeUndefined(); + expect(validateManifest(m).ok).toBe(false); // http:outbound requires an egress allow-list + }); + + it('omits egress entirely when none is given (no empty array noise)', () => { + scaffold('plain-plug', 'integration', tmp, { permissions: ['db:own'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'plain-plug', 'trek-plugin.json'), 'utf8')); + expect('egress' in m).toBe(false); + }); +}); + +describe('permission catalog', () => { + it('covers exactly the known permission ids (none lost in the move to ui.ts)', () => { + expect(KNOWN_PERMISSIONS).toEqual([ + 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:write:costs', + 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', + 'db:meta', + 'ws:broadcast:trip', 'ws:broadcast:user', + 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound', + ]); + for (const p of PERMISSION_CATALOG) expect(p.hint.length).toBeGreaterThan(0); // every option is described + }); +}); + +describe('resolveMenuChoice', () => { + it('maps every menu value (commands + control entries) to itself', () => { + for (const item of [...PRIMARY_MENU, ...ADVANCED_MENU]) { + expect(resolveMenuChoice(item.value)).toBe(item.value); + } + }); + it('returns undefined for anything not in the menu', () => { + expect(resolveMenuChoice('nope')).toBeUndefined(); + expect(resolveMenuChoice('')).toBeUndefined(); + }); +}); + +describe('missingArgs', () => { + it('reports the absent required flags, in order', () => { + expect(missingArgs({}, ['repo', 'tag'])).toEqual(['repo', 'tag']); + expect(missingArgs({ repo: 'a/b' }, ['repo', 'tag'])).toEqual(['tag']); + expect(missingArgs({ repo: 'a/b', tag: 'v1.0.0' }, ['repo', 'tag'])).toEqual([]); + }); +}); + +describe('isInteractive', () => { + it('is false when stdin/stdout are not TTYs (CI / pipes — the parity path)', () => { + const inTTY = process.stdin.isTTY; + const outTTY = process.stdout.isTTY; + try { + (process.stdin as { isTTY?: boolean }).isTTY = undefined; + (process.stdout as { isTTY?: boolean }).isTTY = undefined; + expect(isInteractive()).toBe(false); + (process.stdin as { isTTY?: boolean }).isTTY = true; + (process.stdout as { isTTY?: boolean }).isTTY = true; + expect(isInteractive()).toBe(true); + } finally { + (process.stdin as { isTTY?: boolean }).isTTY = inTTY; + (process.stdout as { isTTY?: boolean }).isTTY = outTTY; + } + }); +}); + +describe('scaffold + validate dependencies', () => { + let tmp: string; + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deps-')); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('scaffolds empty dependency arrays that validate', () => { + scaffold('dep-plug', 'integration', tmp, { permissions: ['db:own'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'dep-plug', 'trek-plugin.json'), 'utf8')); + expect(m.requiredAddons).toEqual([]); + expect(m.pluginDependencies).toEqual([]); + expect(validateManifest(m).ok).toBe(true); + }); + + it('scaffolds requiredAddons passed as an option', () => { + scaffold('addon-plug', 'integration', tmp, { permissions: ['db:own'], requiredAddons: ['budget'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'addon-plug', 'trek-plugin.json'), 'utf8')); + expect(m.requiredAddons).toEqual(['budget']); + expect(validateManifest(m).ok).toBe(true); + }); +}); + +describe('validateManifest dependency rules', () => { + const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] }; + it('accepts valid requiredAddons + pluginDependencies', () => { + const r = validateManifest({ ...base, requiredAddons: ['budget', 'journey'], pluginDependencies: [{ id: 'koffi', version: '>=1.0.0 <2.0.0' }] }); + expect(r.ok).toBe(true); + expect(r.manifest?.requiredAddons).toEqual(['budget', 'journey']); + expect(r.manifest?.pluginDependencies).toEqual([{ id: 'koffi', version: '>=1.0.0 <2.0.0' }]); + }); + it('rejects a bad addon id, bad dep range, self-dependency, and duplicates', () => { + expect(validateManifest({ ...base, requiredAddons: ['Nope!'] }).ok).toBe(false); + expect(validateManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: 'nope' }] }).ok).toBe(false); + expect(validateManifest({ ...base, pluginDependencies: [{ id: 'my-plug', version: '*' }] }).ok).toBe(false); + expect(validateManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: '*' }, { id: 'koffi', version: '^1' }] }).ok).toBe(false); + }); +}); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index eadf27fed..1e6978dd7 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -87,6 +87,81 @@ describe('createMockHost', () => { ctx.log.info('hi'); expect(logs).toEqual([{ level: 'info', msg: 'hi' }]); }); + + it('gates costs reads/writes on the grant, addon, membership and edit permission', async () => { + const { ctx } = createMockHost({ + grants: ['db:read:costs', 'db:write:costs'], + actingUserId: 42, + trips: { + 1: { members: [42], costs: [{ id: 5, name: 'Hotel' }] }, + 2: { members: [42], costs: [{ id: 6, name: 'Food' }], canEditCosts: false }, + }, + }); + // read: trip-scoped + cross-trip aggregate + expect(await ctx.costs.getByTrip(1)).toEqual([{ id: 5, name: 'Hotel' }]); + expect(await ctx.costs.listMine()).toEqual([{ id: 5, name: 'Hotel' }, { id: 6, name: 'Food' }]); + // write: allowed where the user may edit, refused where they may not + expect(await ctx.costs.create(1, { name: 'Taxi' })).toMatchObject({ trip_id: 1, name: 'Taxi' }); + await expect(ctx.costs.create(2, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/); + // update: edits an existing item where allowed, refused without edit permission + expect(await ctx.costs.update(1, 5, { name: 'Hostel' })).toMatchObject({ id: 5, name: 'Hostel' }); + await expect(ctx.costs.update(2, 6, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/); + // update: a missing item is refused + await expect(ctx.costs.update(1, 999, { name: 'X' })).rejects.toThrow(/RESOURCE_FORBIDDEN/); + // delete: removes where allowed, refused without edit permission / when missing + expect(await ctx.costs.delete(1, 5)).toEqual({ deleted: true }); + await expect(ctx.costs.delete(2, 6)).rejects.toThrow(/RESOURCE_FORBIDDEN/); + await expect(ctx.costs.delete(1, 999)).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); + + it('gates planner writes (places/days/itinerary/trips) on grant, membership and edit permission', async () => { + const { ctx } = createMockHost({ + grants: ['db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips'], + actingUserId: 42, + trips: { + 1: { members: [42], data: { id: 1, title: 'Japan' } }, + 2: { members: [42], canEditPlaces: false }, + }, + }); + // create + assign on a trip the user may edit + const place = await ctx.places.create(1, { name: 'Fushimi Inari' }); + expect(place).toMatchObject({ trip_id: 1, name: 'Fushimi Inari' }); + const day = await ctx.days.create(1, { notes: 'Day 1' }); + expect(await ctx.itinerary.assign(1, (day as { id: number }).id, (place as { id: number }).id)) + .toMatchObject({ day_id: (day as { id: number }).id }); + expect(await ctx.trips.update(1, { title: 'Renamed' })).toMatchObject({ title: 'Renamed' }); + // refused where the user may not edit + await expect(ctx.places.create(2, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); + + it('refuses a planner write without the matching write scope', async () => { + const { ctx } = createMockHost({ grants: ['db:read:trips'], actingUserId: 42, trips: { 1: { members: [42] } } }); + await expect(ctx.places.create(1, { name: 'X' })).rejects.toThrow(/PERMISSION_DENIED/); + }); + + it('ctx.meta stores/reads/lists/deletes namespaced metadata, gated on db:meta', async () => { + const { ctx } = createMockHost({ grants: ['db:meta'], actingUserId: 42, trips: { 1: { members: [42] } } }); + await ctx.meta.set('trip', 1, 'rating', 5); + expect(await ctx.meta.get('trip', 1, 'rating')).toBe(5); + expect(await ctx.meta.list('trip', 1)).toEqual({ rating: 5 }); + expect(await ctx.meta.delete('trip', 1, 'rating')).toEqual({ deleted: true }); + expect(await ctx.meta.get('trip', 1, 'rating')).toBe(null); + const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } }); + await expect(ungranted.ctx.meta.get('trip', 1, 'x')).rejects.toThrow(/PERMISSION_DENIED/); + }); + + it('refuses costs when the permission is missing or the addon is disabled', async () => { + const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } }); + await expect(ungranted.ctx.costs.getByTrip(1)).rejects.toThrow(/PERMISSION_DENIED/); + + const addonOff = createMockHost({ + grants: ['db:read:costs'], + actingUserId: 42, + budgetAddonEnabled: false, + trips: { 1: { members: [42], costs: [] } }, + }); + await expect(addonOff.ctx.costs.getByTrip(1)).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); }); describe('scaffold + validate CLIs', () => { @@ -106,6 +181,33 @@ describe('scaffold + validate CLIs', () => { expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template }); + it('scaffolds a client that opts into the design kit via the marker (source stays one line)', () => { + scaffold('kit-plug', 'widget', tmp); + const html = fs.readFileSync(path.join(tmp, 'kit-plug', 'client', 'index.html'), 'utf8'); + expect(html).toContain(''); // the one-line opt-in + expect(html).toContain('trek.onContext'); // uses the bridge the marker installs + expect(html).not.toContain('--glass-bg'); // kit is NOT pre-inlined in the source + }); + + it('scaffolds a trip-page plugin (a tab inside the trip) with a client UI', () => { + scaffold('trip-diary', 'trip-page', tmp); + const dir = path.join(tmp, 'trip-diary'); + const m = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')); + expect(m.type).toBe('trip-page'); + expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true); // non-integration → gets a UI + expect(validatePluginDir(dir).ok).toBe(true); + }); + + it('pack expands the design-kit marker into the archived client HTML', () => { + scaffold('kit-plug', 'widget', tmp); + const out = path.join(tmp, 'kit-plug.zip'); + packPluginDir(path.join(tmp, 'kit-plug'), out); + const html = readZip(fs.readFileSync(out))['client/index.html'].toString('utf8'); + expect(html).not.toContain(''); // expanded away + expect(html).toContain('.trek-glass'); // kit CSS inlined + expect(html).toContain('window.trek'); // bridge inlined + }); + it('scaffolds a CommonJS package.json with the SDK as a devDependency only', () => { scaffold('my-widget', 'widget', tmp); const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'my-widget', 'package.json'), 'utf8')); @@ -344,3 +446,38 @@ describe('sign + keygen (author signatures, TOFU)', () => { expect(() => buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.1.0', zipPath: out, commit: 'a'.repeat(40), mergePath: existingPath, signKeyPath: key2, now: '2026-07-04T00:00:00.000Z' })).toThrow(/differs from the one already published/); }); }); + +describe('mock-host inter-plugin (plugins.call + events.emit)', () => { + it('calls a configured dependency export and records the call', async () => { + const { ctx, calls } = createMockHost({ + pluginExports: { 'dep-lib': { greet: (args: any) => ({ hi: args?.who }) } }, + }); + const r = await ctx.plugins.call('dep-lib', 'greet', { who: 'ada' }); + expect(r).toEqual({ hi: 'ada' }); + expect(calls.some((c) => c.method === 'plugins.call')).toBe(true); + }); + + it('throws RESOURCE_FORBIDDEN calling an export that is not configured', async () => { + const { ctx } = createMockHost({ pluginExports: { 'dep-lib': {} } }); + await expect(ctx.plugins.call('dep-lib', 'nope', {})).rejects.toThrow(/RESOURCE_FORBIDDEN/); + await expect(ctx.plugins.call('other', 'greet', {})).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); + + it('records emitted events', () => { + const { ctx, emitted } = createMockHost({}); + ctx.events.emit('rate.updated', { pair: 'USD/EUR' }); + expect(emitted).toEqual([{ name: 'rate.updated', payload: { pair: 'USD/EUR' } }]); + }); +}); + +describe('validateManifest capabilities.provides/emits', () => { + const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] }; + it('accepts well-formed provides + emits', () => { + const r = validateManifest({ ...base, capabilities: { provides: ['computeRate'], emits: ['rate.updated'] } }); + expect(r.ok).toBe(true); + }); + it('rejects a malformed export/event name and a non-array', () => { + expect(validateManifest({ ...base, capabilities: { provides: ['bad name!'] } }).ok).toBe(false); + expect(validateManifest({ ...base, capabilities: { emits: 'nope' } }).ok).toBe(false); + }); +}); diff --git a/plugin-sdk/test/ui-kit.test.ts b/plugin-sdk/test/ui-kit.test.ts new file mode 100644 index 000000000..a3bd09bf6 --- /dev/null +++ b/plugin-sdk/test/ui-kit.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { TREK_UI_CSS, TREK_THEME_JS, TREK_UI_MARKER, injectTrekUi } from '../src/index.js'; + +describe('design kit', () => { + it('ships a token-driven stylesheet with the signature TREK components', () => { + // Components an author leans on. + for (const cls of ['.trek-card', '.trek-glass', '.trek-btn', '.trek-input', '.trek-chip', '.trek-row']) { + expect(TREK_UI_CSS).toContain(cls); + } + // The glass recipe is baked (it can't be read over the bridge) and swaps for dark. + expect(TREK_UI_CSS).toContain('--glass-bg'); + expect(TREK_UI_CSS).toContain('--glass-highlight'); + expect(TREK_UI_CSS).toContain('[data-theme="dark"]'); + // Honours the same accessibility choices as the host. + expect(TREK_UI_CSS).toContain('prefers-reduced-motion'); + expect(TREK_UI_CSS).toContain('[data-no-transparency]'); + }); + + it('ships a bootstrap that wires the frame and never breaks inline embedding', () => { + expect(TREK_THEME_JS).toContain("type: 'trek:ready'"); + expect(TREK_THEME_JS).toContain('window.trek'); + expect(TREK_THEME_JS).toContain("'trek:context'"); + // Applies the host tokens + theme to the document. + expect(TREK_THEME_JS).toContain('setProperty'); + expect(TREK_THEME_JS).toContain("setAttribute('data-theme'"); + // Auto-sizing so a widget/page reports its own height. + expect(TREK_THEME_JS).toContain('trek:resize'); + // Trusts only the real parent window (opaque frame has a 'null' origin). + expect(TREK_THEME_JS).toContain('ev.source !== window.parent'); + }); + + it('never contains a closing tag that would break