@ossy/app 1.40.3 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +6 -0
  2. package/cli/get-platform-files.task.js +4 -6
  3. package/cli/manifest-plugin.js +111 -36
  4. package/package.json +23 -14
  5. package/runtime/merge-shell-slots.js +148 -0
  6. package/runtime/page-runtime.js +32 -16
  7. package/runtime/resolve-app-slots.js +48 -11
  8. package/src/en.translations.json +8 -0
  9. package/src/manifest/build-actions-schema.js +1 -36
  10. package/src/manifest/build-capabilities.js +1 -190
  11. package/src/manifest/build-manifest-summary.js +1 -121
  12. package/src/manifest/discover-package-definitions.js +1 -100
  13. package/src/manifest/resolve-page-layout.js +51 -0
  14. package/src/manifest/serialize-package-definition.js +1 -30
  15. package/src/shell/App.jsx +13 -7
  16. package/src/shell/AppSettings.jsx +6 -0
  17. package/src/shell/DevPagesPanel.jsx +1 -1
  18. package/src/shell/HeaderAuthActions.jsx +49 -0
  19. package/src/shell/LanguageList.jsx +83 -0
  20. package/src/shell/LanguageSelect.jsx +73 -0
  21. package/src/shell/ThemeEditor.jsx +2 -2
  22. package/src/shell/ThemeSelect.jsx +101 -0
  23. package/src/shell/buildSidebarNav.js +86 -0
  24. package/src/shell/index.js +7 -0
  25. package/src/shell/languageCode.js +31 -0
  26. package/src/shell/resolvePackageHomePageId.js +14 -0
  27. package/src/shell/useCompactShellLayout.js +24 -0
  28. package/src/shell-registry/blank.layout.jsx +8 -0
  29. package/src/shell-registry/default.layout.jsx +101 -0
  30. package/src/shell-registry/footer-default.component.jsx +39 -0
  31. package/src/shell-registry/head-default.component.jsx +17 -0
  32. package/src/shell-registry/header-default.component.jsx +50 -0
  33. package/src/shell-registry/logo-logomark.component.jsx +8 -0
  34. package/src/shell-registry/logo-logotype.component.jsx +15 -0
  35. package/src/shell-registry/minimal.layout.jsx +58 -0
  36. package/src/shell-registry/sidebar-default.component.jsx +281 -0
  37. package/src/sv.translations.json +8 -0
  38. package/src/manifest/action-id-to-tool-name.js +0 -29
  39. package/src/manifest/build-action-input-schema.js +0 -220
  40. package/src/manifest/template-to-json-schema.js +0 -103
@@ -1,7 +1,8 @@
1
1
  import { createElement } from 'react'
2
2
  import { pageIdToDocumentTitleKey, resolveMessage } from '@ossy/locale'
3
+ import { Slot } from '@ossy/design-system'
3
4
  import { App } from '../src/shell/App.jsx'
4
- import { resolvePageSlots } from './resolve-app-slots.js'
5
+ import { resolvePageSlots, CONTENT_SLOT_NAME } from './resolve-app-slots.js'
5
6
 
6
7
  /**
7
8
  * Dynamically imports each component bundle listed in `entries` and returns a
@@ -11,14 +12,17 @@ import { resolvePageSlots } from './resolve-app-slots.js'
11
12
  * skipped so a broken component never prevents the page from rendering.
12
13
  *
13
14
  * @param {Array<{ id: string, entry: string }>} entries
15
+ * @param {(entry: string) => string} [resolveEntry] — map manifest `/static/…` paths to
16
+ * absolute `file://` URLs on the server; omit in the browser (paths resolve via HTTP).
14
17
  * @returns {Promise<Record<string, import('react').ComponentType>>}
15
18
  */
16
- export async function loadComponents (entries = []) {
19
+ export async function loadComponents (entries = [], resolveEntry) {
17
20
  const map = {}
21
+ const resolve = typeof resolveEntry === 'function' ? resolveEntry : (entry) => entry
18
22
  await Promise.all(
19
23
  entries.map(async ({ id, entry }) => {
20
24
  try {
21
- const mod = await import(entry)
25
+ const mod = await import(resolve(entry))
22
26
  if (mod && mod.default) map[id] = mod.default
23
27
  } catch {
24
28
  // silently skip broken component bundles
@@ -29,10 +33,10 @@ export async function loadComponents (entries = []) {
29
33
  }
30
34
 
31
35
  /**
32
- * Dynamically imports the app's single layout bundle. Layout components cannot
36
+ * Dynamically imports the layout bundle for the current page. Layout components cannot
33
37
  * be serialized into the bootstrap JSON (functions are stripped), so the server
34
- * passes `layoutEntry` and the client loads the same module here. When present,
35
- * every page is wrapped route-specific chrome is decided inside the layout.
38
+ * passes `layoutEntry` and the client loads the same module here. Each page may
39
+ * reference a different layout id (resolved at build time per ADR 0012).
36
40
  *
37
41
  * @param {string | null | undefined} layoutEntry
38
42
  * @returns {Promise<import('react').ComponentType | null>}
@@ -76,20 +80,22 @@ function resolveDocumentTitle (metadata, props) {
76
80
  return metadata.title || props.documentTitle || ''
77
81
  }
78
82
 
79
- function buildTree ({ Component, Layout, metadata, props }) {
83
+ /** Page body is registered on `app:content`; layouts only gate shell chrome around it. */
84
+ function buildTree ({ Layout, metadata, props }) {
80
85
  const lang = props.language || props.defaultLanguage || 'en'
81
- const pageEl = createElement(Component, props)
82
- const contentEl = Layout ? createElement(Layout, props, pageEl) : pageEl
86
+ const contentEl = Layout
87
+ ? createElement(Layout, { shellSlots: props.layoutSlots ?? {}, ...props })
88
+ : createElement(Slot, { view: CONTENT_SLOT_NAME })
83
89
  return createElement(
84
90
  'html',
85
- { lang },
91
+ { lang, style: { height: '100%' } },
86
92
  createElement(
87
93
  'head',
88
94
  null,
89
95
  createElement('meta', { charSet: 'utf-8' }),
90
96
  createElement('title', null, resolveDocumentTitle(metadata, props)),
91
97
  ),
92
- createElement('body', null, createElement(App, props, contentEl)),
98
+ createElement('body', { style: { height: '100%', margin: 0 } }, createElement(App, props, contentEl)),
93
99
  )
94
100
  }
95
101
 
@@ -127,20 +133,26 @@ export function createPageEntry (pageModule, options = {}) {
127
133
  import('node:stream'),
128
134
  ])
129
135
 
130
- const { Layout = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
131
- const componentsById = await loadComponents(componentEntries)
136
+ const {
137
+ Layout = null,
138
+ componentEntries = [],
139
+ layoutSlots = {},
140
+ resolveComponentEntry,
141
+ ...pageProps
142
+ } = props
143
+ const componentsById = await loadComponents(componentEntries, resolveComponentEntry)
132
144
  const PageContent = (slotProps = {}) => createElement(Component, { ...pageProps, ...slotProps })
133
145
  const components = resolvePageSlots({
134
146
  layoutSlots,
135
147
  componentsById,
136
148
  pageComponent: PageContent,
137
149
  })
138
- const tree = buildTree({ Component, Layout, metadata, props: { ...pageProps, components } })
150
+ const tree = buildTree({ Layout, metadata, props: { ...pageProps, components, layoutSlots } })
139
151
  const bootstrapUrl = toBootstrapUrl(entryUrl)
140
152
  const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
141
153
  // Layout is a React component — JSON.stringify drops functions, so hydration
142
154
  // would render without the shell unless we pass the serializable entry URL.
143
- const { Layout: _layout, ...bootstrapProps } = props
155
+ const { Layout: _layout, resolveComponentEntry: _resolveComponentEntry, ...bootstrapProps } = props
144
156
  const bootstrapScriptContent =
145
157
  'window.__OSSY__=' + escapeBootstrapJson(JSON.stringify(bootstrapProps ?? {}))
146
158
 
@@ -182,7 +194,11 @@ export function createPageEntry (pageModule, options = {}) {
182
194
  componentsById,
183
195
  pageComponent: PageContent,
184
196
  })
185
- hydrateRoot(document, buildTree({ Component, Layout, metadata, props: { ...pageProps, components } }))
197
+ hydrateRoot(document, buildTree({
198
+ Layout,
199
+ metadata,
200
+ props: { ...pageProps, components, layoutSlots },
201
+ }))
186
202
  }).catch((err) => {
187
203
  if (typeof console !== 'undefined' && typeof console.error === 'function') {
188
204
  console.error('[@ossy/app][page-runtime] Hydration failed:', err)
@@ -1,10 +1,15 @@
1
+ import { createElement } from 'react'
2
+ import { coerceShellSlotSpec, shellSlotViewId } from './merge-shell-slots.js'
3
+
1
4
  /** Canonical app chrome slot names (namespaced). App layout maps chrome only — not content. */
2
5
  export const APP_SLOT_NAMES = [
6
+ 'app:head',
3
7
  'app:header',
4
8
  'app:sidebar',
5
9
  'app:toolbar',
6
10
  'app:notifications',
7
11
  'app:system-messages',
12
+ 'app:footer',
8
13
  ]
9
14
 
10
15
  /** Platform-owned slot filled with the current route page component. */
@@ -14,14 +19,29 @@ const APP_SLOT_PREFIX = 'app'
14
19
 
15
20
  /** Bare app region → namespaced key (legacy fallback for app components only). */
16
21
  const BARE_APP_REGION = {
22
+ head: 'app:head',
17
23
  header: 'app:header',
18
24
  sidebar: 'app:sidebar',
19
25
  toolbar: 'app:toolbar',
20
26
  notifications: 'app:notifications',
21
27
  'system-messages': 'app:system-messages',
28
+ footer: 'app:footer',
22
29
  content: CONTENT_SLOT_NAME,
23
30
  }
24
31
 
32
+ /**
33
+ * @param {import('react').ComponentType<any>} Component
34
+ * @param {Record<string, unknown>} defaultProps
35
+ * @returns {import('react').ComponentType<any>}
36
+ */
37
+ function withShellSlotProps (Component, defaultProps) {
38
+ function ShellSlotTarget (props) {
39
+ return createElement(Component, { ...defaultProps, ...props })
40
+ }
41
+ ShellSlotTarget.displayName = `ShellSlot(${Component.displayName || Component.name || 'Component'})`
42
+ return ShellSlotTarget
43
+ }
44
+
25
45
  /**
26
46
  * Normalize a slot map key to the canonical namespaced form when it is a bare app region.
27
47
  *
@@ -34,10 +54,10 @@ export function normalizeAppSlotName (slotName) {
34
54
  }
35
55
 
36
56
  /**
37
- * Build `Record<slotName, Component>` from the app layout's static `slots` map
38
- * (`slotName → componentId`) and components loaded by `metadata.id`.
57
+ * Build `Record<slotName, Component>` from merged shell slot specs
58
+ * (`slotName → component id + default props`) and components loaded by `metadata.id`.
39
59
  *
40
- * @param {Record<string, string> | null | undefined} layoutSlotsMap
60
+ * @param {Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec | undefined> | null | undefined} layoutSlotsMap
41
61
  * @param {Record<string, import('react').ComponentType>} componentsById
42
62
  * @returns {Record<string, import('react').ComponentType>}
43
63
  */
@@ -46,16 +66,33 @@ export function resolveAppSlots (layoutSlotsMap, componentsById) {
46
66
  const resolved = {}
47
67
 
48
68
  const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
49
- for (const [rawSlot, componentId] of Object.entries(map)) {
69
+ for (const [rawSlot, rawSpec] of Object.entries(map)) {
50
70
  const slotName = normalizeAppSlotName(rawSlot)
51
- const id = typeof componentId === 'string' ? componentId.trim() : ''
52
- if (!slotName || !id || slotName === CONTENT_SLOT_NAME) continue
53
- const Component = componentsById[id]
54
- if (Component) resolved[slotName] = Component
71
+ if (!slotName || slotName === CONTENT_SLOT_NAME) continue
72
+
73
+ const spec = coerceShellSlotSpec(rawSpec) ?? (
74
+ rawSpec && typeof rawSpec === 'object' && !Array.isArray(rawSpec) ? rawSpec : undefined
75
+ )
76
+
77
+ if (spec?.view === null) {
78
+ resolved[slotName] = null
79
+ continue
80
+ }
81
+
82
+ const componentId = shellSlotViewId(spec ?? rawSpec)
83
+ if (!componentId) continue
84
+
85
+ const Component = componentsById[componentId]
86
+ if (!Component) continue
87
+
88
+ const defaultProps = spec?.props ?? {}
89
+ resolved[slotName] = Object.keys(defaultProps).length > 0
90
+ ? withShellSlotProps(Component, defaultProps)
91
+ : Component
55
92
  }
56
93
 
57
94
  for (const slotName of APP_SLOT_NAMES) {
58
- if (resolved[slotName]) continue
95
+ if (slotName in resolved) continue
59
96
  if (componentsById[slotName]) {
60
97
  resolved[slotName] = componentsById[slotName]
61
98
  continue
@@ -69,10 +106,10 @@ export function resolveAppSlots (layoutSlotsMap, componentsById) {
69
106
 
70
107
  /**
71
108
  * Full provider slot map for a page request: app chrome and page content.
72
- * Feature components register at canonical ADR 0006 ids (`@ossy/…/view|form/…`).
109
+ * Feature components register at canonical ADR 0006 ids (`@ossy/…/view|form|…`).
73
110
  *
74
111
  * @param {{
75
- * layoutSlots?: Record<string, string> | null,
112
+ * layoutSlots?: Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec> | null,
76
113
  * componentsById?: Record<string, import('react').ComponentType>,
77
114
  * pageComponent?: import('react').ComponentType | null,
78
115
  * }} options
@@ -0,0 +1,8 @@
1
+ {
2
+ "app.shell.sidebar.expand": "Expand sidebar",
3
+ "app.shell.sidebar.collapse": "Collapse sidebar",
4
+ "app.shell.header.languagePicker": "Language",
5
+ "app.shell.footer.languages": "Languages",
6
+ "app.shell.header.themeSwitch": "{theme} theme. Switch theme.",
7
+ "app.shell.header.profile": "Profile"
8
+ }
@@ -1,36 +1 @@
1
- import { buildCapabilities, capabilitiesToActionsSchema } from './build-capabilities.js'
2
- import { taskIdFromActionId } from '@ossy/schema'
3
-
4
- /**
5
- * @param {Array<{ id: string, access?: string, package?: string }>} actions
6
- * @param {{ schemas?: object[], tasks?: object[], taskCatalog?: object[], taskGraphEdges?: object[], generatedAt?: string }} [options]
7
- * @returns {{ version: number, generatedAt: string, actions: Array<object> }}
8
- */
9
- export function buildActionsSchema (actions = [], options = {}) {
10
- const capabilities = buildCapabilities(
11
- {
12
- actions,
13
- tasks: options.tasks || actions.flatMap(a => {
14
- try {
15
- return [{ id: taskIdFromActionId(a.id) }]
16
- } catch {
17
- return []
18
- }
19
- }),
20
- schemas: options.schemas || [],
21
- taskCatalog: options.taskCatalog || [],
22
- taskGraphEdges: options.taskGraphEdges || [],
23
- },
24
- { generatedAt: options.generatedAt },
25
- )
26
- return capabilitiesToActionsSchema(capabilities, actions)
27
- }
28
-
29
- export {
30
- buildCapabilities,
31
- buildCapabilitiesGraph,
32
- capabilitiesTaskTopologyResource,
33
- capabilitiesToActionsSchema,
34
- projectTaskForCapabilities,
35
- TASK_TOPOLOGY_RESOURCE_URI,
36
- } from './build-capabilities.js'
1
+ export * from '@ossy/manifest/build-actions-schema'
@@ -1,190 +1 @@
1
- import { actionIdToToolName, humanizeActionId } from './action-id-to-tool-name.js'
2
- import { buildActionInputSchema } from './build-action-input-schema.js'
3
- import { taskIdFromActionId } from '@ossy/schema'
4
-
5
- const MCP_ACCESS = new Set(['workspace', 'public'])
6
-
7
- /** MCP resource URI for read-only task topology (tasks + graph edges). */
8
- export const TASK_TOPOLOGY_RESOURCE_URI = 'ossy://capabilities/task-topology'
9
-
10
- /**
11
- * @param {object} task
12
- */
13
- export function projectTaskForCapabilities (task) {
14
- return {
15
- id: task.id,
16
- package: task.package ?? null,
17
- moduleId: task.moduleId ?? null,
18
- triggers: task.triggers ?? [],
19
- schedule: task.schedule ?? null,
20
- inputSchemaIds: task.inputSchemaIds ?? [],
21
- actionIds: task.actionIds ?? [],
22
- primaryActionId: task.primaryActionId ?? null,
23
- outputs: task.outputs ?? [],
24
- }
25
- }
26
-
27
- /**
28
- * Structured trigger → task edges for agent planning (ADR 0011).
29
- *
30
- * @param {object[]} taskCatalog
31
- */
32
- export function buildCapabilitiesGraph (taskCatalog = []) {
33
- /** @type {Array<{ kind: string, from: object, to: { taskId: string }, label?: string }>} */
34
- const edges = []
35
-
36
- for (const task of taskCatalog) {
37
- for (const trigger of task.triggers ?? []) {
38
- if (trigger.kind === 'on_action' && trigger.action) {
39
- edges.push({
40
- kind: 'on_action',
41
- from: { kind: 'action', actionId: trigger.action },
42
- to: { taskId: task.id },
43
- label: trigger.action,
44
- })
45
- }
46
- if (trigger.kind === 'on_event' && trigger.type) {
47
- edges.push({
48
- kind: 'on_event',
49
- from: {
50
- kind: 'trigger',
51
- type: trigger.type,
52
- ...(trigger.event ? { event: trigger.event } : {}),
53
- },
54
- to: { taskId: task.id },
55
- label: [trigger.type, trigger.event].filter(Boolean).join(' · '),
56
- })
57
- }
58
- if (trigger.kind === 'on_schedule' && trigger.schedule) {
59
- edges.push({
60
- kind: 'on_schedule',
61
- from: { kind: 'schedule', schedule: trigger.schedule },
62
- to: { taskId: task.id },
63
- label: trigger.schedule,
64
- })
65
- }
66
- }
67
- if (task.primaryActionId) {
68
- edges.push({
69
- kind: 'primary',
70
- from: { kind: 'action', actionId: task.primaryActionId },
71
- to: { taskId: task.id },
72
- label: 'invoke',
73
- })
74
- }
75
- }
76
-
77
- return { edges }
78
- }
79
-
80
- /**
81
- * Build agent capabilities (MCP tools + resource template catalog) from manifest data.
82
- *
83
- * @param {{
84
- * actions?: Array<{ id: string, access?: string, package?: string }>,
85
- * tasks?: Array<{ id: string }>,
86
- * schemas?: Array<object>,
87
- * taskCatalog?: object[],
88
- * taskGraphEdges?: object[],
89
- * }} manifest
90
- * @param {{ generatedAt?: string }} [options]
91
- */
92
- export function buildCapabilities (manifest, options = {}) {
93
- const actions = manifest.actions || []
94
- const tasks = manifest.tasks || []
95
- const schemas = manifest.schemas || []
96
- const taskCatalog = manifest.taskCatalog || []
97
- const taskIds = new Set(tasks.map(t => t.id))
98
-
99
- const tools = actions
100
- .filter(action => {
101
- try {
102
- return taskIds.has(taskIdFromActionId(action.id))
103
- } catch {
104
- return false
105
- }
106
- })
107
- .filter(action => MCP_ACCESS.has(action.access || 'authenticated'))
108
- .map(action => ({
109
- actionId: action.id,
110
- name: actionIdToToolName(action.id),
111
- title: humanizeActionId(action.id),
112
- description: `Invoke ${action.id}`,
113
- access: action.access || 'authenticated',
114
- package: action.package,
115
- channels: ['sdk', 'api', 'mcp'],
116
- inputSchema: buildActionInputSchema(action.id, { schemas }),
117
- }))
118
- .sort((a, b) => a.actionId.localeCompare(b.actionId))
119
-
120
- const projectedTasks = taskCatalog.map(projectTaskForCapabilities)
121
-
122
- return {
123
- version: 2,
124
- generatedAt: options.generatedAt || new Date().toISOString(),
125
- tools,
126
- schemas: schemas.map(t => ({
127
- id: t.id,
128
- name: t.name,
129
- package: t.package,
130
- fields: t.fields,
131
- })),
132
- tasks: projectedTasks,
133
- graph: buildCapabilitiesGraph(taskCatalog),
134
- /** Legacy string-edge projection kept for UI parity — prefer `graph.edges`. */
135
- taskGraphEdges: manifest.taskGraphEdges ?? [],
136
- }
137
- }
138
-
139
- /**
140
- * Payload exposed via MCP resource {@link TASK_TOPOLOGY_RESOURCE_URI}.
141
- *
142
- * @param {ReturnType<typeof buildCapabilities>} capabilities
143
- */
144
- export function capabilitiesTaskTopologyResource (capabilities) {
145
- return {
146
- version: capabilities.version,
147
- generatedAt: capabilities.generatedAt,
148
- tasks: capabilities.tasks ?? [],
149
- graph: capabilities.graph ?? { edges: [] },
150
- }
151
- }
152
-
153
- /**
154
- * Legacy actions.schema.json shape for package detail / docs.
155
- *
156
- * @param {ReturnType<typeof buildCapabilities>} capabilities
157
- * @param {Array<{ id: string, access?: string, package?: string }>} [allActions]
158
- */
159
- export function capabilitiesToActionsSchema (capabilities, allActions) {
160
- const toolById = Object.fromEntries(capabilities.tools.map(t => [t.actionId, t]))
161
- const actions = (allActions || capabilities.tools.map(t => ({
162
- id: t.actionId,
163
- access: t.access,
164
- package: t.package,
165
- })))
166
-
167
- return {
168
- version: capabilities.version,
169
- generatedAt: capabilities.generatedAt,
170
- actions: actions.map(action => {
171
- const tool = toolById[action.id]
172
- const inputSchema = tool?.inputSchema
173
- ?? buildActionInputSchema(action.id, {
174
- schemas: capabilities.schemas,
175
- })
176
- const hasKnownEnvelope = inputSchema?.properties && !inputSchema.properties.payload
177
-
178
- return {
179
- id: action.id,
180
- access: action.access ?? 'authenticated',
181
- package: action.package,
182
- channels: tool ? tool.channels : ['sdk', 'api'],
183
- ...(tool?.title ? { title: tool.title } : {}),
184
- ...(tool?.description ? { description: tool.description } : {}),
185
- ...(tool?.name ? { mcpTool: tool.name } : {}),
186
- ...(hasKnownEnvelope ? { inputSchema } : {}),
187
- }
188
- }).sort((a, b) => a.id.localeCompare(b.id)),
189
- }
190
- }
1
+ export * from '@ossy/manifest/build-capabilities'
@@ -1,121 +1 @@
1
- /**
2
- * @param {string} packageName npm package name (e.g. `@ossy/booking`).
3
- * @returns {string} URL slug (e.g. `booking`).
4
- */
5
- export function packageNameToSlug (packageName) {
6
- const slash = packageName.indexOf('/')
7
- return slash === -1 ? packageName : packageName.slice(slash + 1)
8
- }
9
-
10
- /**
11
- * @param {string} slug URL slug (e.g. `booking`).
12
- * @returns {string} Scoped package name (e.g. `@ossy/booking`).
13
- */
14
- export function slugToPackageName (slug) {
15
- return `@ossy/${slug}`
16
- }
17
-
18
- /**
19
- * @param {object} manifest Loaded manifest from `@ossy/platform` `loadManifest`.
20
- * @returns {{ packages: Array<object> }} Grouped, JSON-serializable package summary.
21
- */
22
- export function buildManifestSummary (manifest) {
23
- /** @type {Map<string, object>} */
24
- const groups = new Map()
25
-
26
- const ensure = (pkg) => {
27
- if (!groups.has(pkg)) {
28
- groups.set(pkg, {
29
- package: pkg,
30
- slug: packageNameToSlug(pkg),
31
- pages: [],
32
- apis: [],
33
- actions: [],
34
- components: [],
35
- schemas: [],
36
- tasks: [],
37
- integrations: [],
38
- emails: [],
39
- aggregates: [],
40
- })
41
- }
42
- return groups.get(pkg)
43
- }
44
-
45
- const add = (pkg, key, item) => {
46
- ensure(pkg || '@ossy/app')[key].push(item)
47
- }
48
-
49
- for (const page of manifest.pages || []) {
50
- add(page.package, 'pages', {
51
- id: page.id,
52
- path: page.path,
53
- ...(page.title ? { title: page.title } : {}),
54
- })
55
- }
56
-
57
- for (const api of manifest.apis || []) {
58
- add(api.package, 'apis', { id: api.id, path: api.path })
59
- }
60
-
61
- for (const action of manifest.actions || []) {
62
- add(action.package, 'actions', {
63
- id: action.id,
64
- ...(action.access ? { access: action.access } : {}),
65
- })
66
- }
67
-
68
- for (const component of manifest.components || []) {
69
- add(component.package, 'components', { id: component.id })
70
- }
71
-
72
- for (const template of manifest.schemas || []) {
73
- add(template.package, 'schemas', {
74
- id: template.id,
75
- ...(template.title ? { title: template.title } : {}),
76
- })
77
- }
78
-
79
- for (const task of manifest.taskCatalog || manifest.tasks || []) {
80
- add(task.package, 'tasks', {
81
- id: task.id,
82
- ...(task.moduleId ? { moduleId: task.moduleId } : {}),
83
- ...(task.triggers ? { triggers: task.triggers } : {}),
84
- ...(task.inputSchemaIds ? { inputSchemaIds: task.inputSchemaIds } : {}),
85
- ...(task.primaryActionId ? { primaryActionId: task.primaryActionId } : {}),
86
- })
87
- }
88
-
89
- for (const integration of manifest.integrations || []) {
90
- add(integration.package, 'integrations', {
91
- id: integration.id,
92
- ...(integration.credentials?.length ? { credentials: integration.credentials } : {}),
93
- })
94
- }
95
-
96
- for (const email of manifest.emails || []) {
97
- add(email.package, 'emails', { id: email.id })
98
- }
99
-
100
- for (const aggregate of manifest.aggregates || []) {
101
- add(aggregate.package, 'aggregates', { id: aggregate.id })
102
- }
103
-
104
- const packages = Array.from(groups.values())
105
- .sort((a, b) => a.package.localeCompare(b.package))
106
-
107
- for (const pkg of packages) {
108
- for (const key of Object.keys(pkg)) {
109
- if (Array.isArray(pkg[key])) {
110
- pkg[key].sort((a, b) => a.id.localeCompare(b.id))
111
- }
112
- }
113
- }
114
-
115
- const definitions =
116
- manifest.definitions && typeof manifest.definitions === 'object' && !Array.isArray(manifest.definitions)
117
- ? manifest.definitions
118
- : {}
119
-
120
- return { packages, definitions }
121
- }
1
+ export * from '@ossy/manifest/build-manifest-summary'
@@ -1,100 +1 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { pathToFileURL } from 'node:url'
4
-
5
- import { packageNameToSlug } from './build-manifest-summary.js'
6
- import { serializePackageDefinition } from './serialize-package-definition.js'
7
-
8
- /**
9
- * @param {string} projectRoot
10
- * @returns {string[]}
11
- */
12
- function collectNodeModulesDirs (projectRoot) {
13
- const dirs = []
14
- let current = projectRoot
15
- while (true) {
16
- const nm = path.join(current, 'node_modules')
17
- if (fs.existsSync(nm) && fs.statSync(nm).isDirectory()) dirs.push(nm)
18
- const parent = path.dirname(current)
19
- if (parent === current) break
20
- current = parent
21
- }
22
- return dirs
23
- }
24
-
25
- /**
26
- * Load `Definition.js` from disk only — never import the package main entry,
27
- * which may pull CLI side effects (e.g. `@ossy/cli` runs on import).
28
- *
29
- * @param {string} pkgDir
30
- * @param {object} pkg
31
- * @returns {Promise<object | null>}
32
- */
33
- async function loadPackageDefinition (pkgDir, pkg) {
34
- if (!pkg.ossy?.src) return null
35
-
36
- const defPath = path.join(pkgDir, pkg.ossy.src, 'Definition.js')
37
- if (!fs.existsSync(defPath)) return null
38
-
39
- try {
40
- const mod = await import(pathToFileURL(defPath).href + `?t=${Date.now()}`)
41
- if (mod?.Definition && typeof mod.Definition === 'object') {
42
- return mod.Definition
43
- }
44
- } catch {
45
- // skip packages without a readable Definition
46
- }
47
-
48
- return null
49
- }
50
-
51
- /**
52
- * Discover `Definition.js` from installed `@ossy/*` packages.
53
- *
54
- * @param {string} projectRoot Absolute path to the consuming app root.
55
- * @returns {Promise<Record<string, object>>} Map keyed by package slug.
56
- */
57
- export async function discoverPackageDefinitions (projectRoot) {
58
- const nmDirs = collectNodeModulesDirs(projectRoot)
59
- /** @type {Record<string, object>} */
60
- const definitions = {}
61
- const seen = new Set()
62
-
63
- const tryPackageDir = async (pkgDir) => {
64
- const real = fs.realpathSync(pkgDir)
65
- if (seen.has(real)) return
66
- seen.add(real)
67
-
68
- const pkgJsonPath = path.join(pkgDir, 'package.json')
69
- if (!fs.existsSync(pkgJsonPath)) return
70
-
71
- let pkg
72
- try {
73
- pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
74
- } catch {
75
- return
76
- }
77
-
78
- const name = typeof pkg.name === 'string' ? pkg.name.trim() : ''
79
- if (!name.startsWith('@ossy/')) return
80
-
81
- const slug = packageNameToSlug(name)
82
- if (!slug || definitions[slug]) return
83
-
84
- const raw = await loadPackageDefinition(real, pkg)
85
- const serialized = serializePackageDefinition(raw)
86
- if (serialized) definitions[slug] = serialized
87
- }
88
-
89
- for (const nmDir of nmDirs) {
90
- const scopeDir = path.join(nmDir, '@ossy')
91
- if (!fs.existsSync(scopeDir) || !fs.statSync(scopeDir).isDirectory()) continue
92
-
93
- for (const entry of fs.readdirSync(scopeDir, { withFileTypes: true })) {
94
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
95
- await tryPackageDir(path.join(scopeDir, entry.name))
96
- }
97
- }
98
-
99
- return definitions
100
- }
1
+ export * from '@ossy/manifest/discover-package-definitions'