spine-framework 0.3.76 → 0.3.77

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.
@@ -286,48 +286,66 @@ async function seedApp(appDir: string, appSlug: string) {
286
286
  const appId = appRow.id
287
287
  console.log(` ✓ App registered (id: ${appId})`)
288
288
 
289
- // Seed types
290
- const typesPath = join(appDir, 'seed/types.json')
291
- if (existsSync(typesPath)) {
292
- const types = JSON.parse(readFileSync(typesPath, 'utf8'))
293
- console.log(` Seeding ${types.length} type(s) for ${appSlug}...`)
294
- for (const type of types) {
295
- const { error } = await db
296
- .from('types')
297
- .upsert({ ...type, app_id: appId }, { onConflict: 'app_id,kind,slug' })
298
- if (error) console.warn(` ⚠ type ${type.slug}: ${error.message}`)
299
- }
300
- console.log(` ✓ Types seeded`)
289
+ // ── Manifest-driven seed loop ─────────────────────────────────────────────
290
+ // If manifest declares a `seed` array, use it. Each entry:
291
+ // { file, table, conflict, inject_app_id? }
292
+ // inject_app_id defaults to true — set false for tables with no app_id column.
293
+ //
294
+ // Fallback: if no manifest.seed, use legacy hardcoded behavior so existing
295
+ // apps without a seed block continue to work.
296
+ // ─────────────────────────────────────────────────────────────────────────
297
+
298
+ interface SeedEntry {
299
+ file: string
300
+ table: string
301
+ conflict: string
302
+ inject_app_id?: boolean
301
303
  }
302
304
 
303
- // Seed roles
304
- const rolesPath = join(appDir, 'seed/roles.json')
305
- if (existsSync(rolesPath)) {
306
- const roles = JSON.parse(readFileSync(rolesPath, 'utf8'))
307
- console.log(` Seeding ${roles.length} role(s) for ${appSlug}...`)
308
- for (const role of roles) {
309
- const { error } = await db
310
- .from('roles')
311
- .upsert({ ...role, app_id: appId }, { onConflict: 'app_id,slug' })
312
- if (error) console.warn(` ⚠ role ${role.slug}: ${error.message}`)
305
+ const seedEntries: SeedEntry[] = Array.isArray(manifest.seed)
306
+ ? manifest.seed
307
+ : [
308
+ // Legacy fallback matches pre-019 behavior
309
+ { file: 'seed/roles.json', table: 'roles', conflict: 'app_id,slug' },
310
+ { file: 'seed/types.json', table: 'types', conflict: 'app_id,kind,slug' },
311
+ { file: 'seed/link-types.json', table: 'link_types', conflict: 'app_id,slug' },
312
+ ]
313
+
314
+ for (const entry of seedEntries) {
315
+ const filePath = join(appDir, entry.file)
316
+ if (!existsSync(filePath)) {
317
+ console.log(` ○ ${entry.file} not found, skipping`)
318
+ continue
319
+ }
320
+
321
+ let records: Record<string, any>[]
322
+ try {
323
+ records = JSON.parse(readFileSync(filePath, 'utf8'))
324
+ } catch {
325
+ console.warn(` ⚠ Could not parse ${entry.file}, skipping`)
326
+ continue
313
327
  }
314
- console.log(` ✓ Roles seeded`)
315
- }
316
328
 
317
- // Seed link-types
318
- const linkTypesPath = join(appDir, 'seed/link-types.json')
319
- if (existsSync(linkTypesPath)) {
320
- const linkTypes = JSON.parse(readFileSync(linkTypesPath, 'utf8'))
321
- if (linkTypes.length > 0) {
322
- console.log(` Seeding ${linkTypes.length} link type(s) for ${appSlug}...`)
323
- for (const lt of linkTypes) {
324
- const { error } = await db
325
- .from('link_types')
326
- .upsert({ ...lt, app_id: appId }, { onConflict: 'app_id,slug' })
327
- if (error) console.warn(` ⚠ link_type ${lt.slug}: ${error.message}`)
329
+ if (!Array.isArray(records) || records.length === 0) {
330
+ console.log(` ${entry.file} is empty, skipping`)
331
+ continue
332
+ }
333
+
334
+ const injectAppId = entry.inject_app_id !== false // default true
335
+ console.log(` Seeding ${records.length} record(s) from ${entry.file} ${entry.table}...`)
336
+
337
+ for (const record of records) {
338
+ const row = injectAppId ? { ...record, app_id: appId } : { ...record }
339
+ const { error } = await db
340
+ .from(entry.table)
341
+ .upsert(row, { onConflict: entry.conflict })
342
+ if (error) {
343
+ const label = record.slug ?? record.name ?? JSON.stringify(record).slice(0, 40)
344
+ console.warn(` ⚠ ${entry.table} "${label}": ${error.message}`)
328
345
  }
329
- console.log(` ✓ Link types seeded`)
330
346
  }
347
+
348
+ console.log(` ✓ ${entry.table} seeded`)
331
349
  }
332
350
  }
333
351
 
@@ -8,11 +8,21 @@ import { fileURLToPath } from 'url'
8
8
  const __filename = fileURLToPath(import.meta.url)
9
9
  const __dirname = dirname(__filename)
10
10
 
11
+ interface SeedEntry {
12
+ file: string
13
+ table: string
14
+ conflict: string
15
+ inject_app_id?: boolean
16
+ }
17
+
11
18
  interface Manifest {
12
19
  name: string
13
20
  slug: string
14
21
  version: string
15
22
  app_type: 'full' | 'ui' | 'backend' | 'data'
23
+ entry_point?: string
24
+ sidebar_component?: string
25
+ seed?: SeedEntry[]
16
26
  registration?: {
17
27
  enabled?: boolean
18
28
  default_role?: string
@@ -25,6 +35,7 @@ interface Manifest {
25
35
  interface PackageJson {
26
36
  name: string
27
37
  version: string
38
+ private?: boolean
28
39
  files?: string[]
29
40
  spine?: {
30
41
  type?: string
@@ -170,6 +181,68 @@ function validateDevinDirectory(appPath: string): void {
170
181
  }
171
182
  }
172
183
 
184
+ function validateManifestComponents(appPath: string, manifest: Manifest): void {
185
+ // entry_point — if declared, the file must exist
186
+ if (manifest.entry_point) {
187
+ const ep = manifest.entry_point.replace(/^\.\//, '')
188
+ if (!existsSync(join(appPath, ep))) {
189
+ throw new ValidationError(`❌ manifest.json entry_point "${manifest.entry_point}" does not exist`)
190
+ }
191
+ }
192
+
193
+ // sidebar_component — if declared, the file must exist
194
+ if (manifest.sidebar_component) {
195
+ const sc = manifest.sidebar_component.replace(/^\.\//, '')
196
+ if (!existsSync(join(appPath, sc))) {
197
+ throw new ValidationError(`❌ manifest.json sidebar_component "${manifest.sidebar_component}" does not exist`)
198
+ }
199
+ }
200
+
201
+ // registration — account_strategy "existing" requires target_account
202
+ if (manifest.registration?.account_strategy === 'existing' && !manifest.registration?.target_account) {
203
+ throw new ValidationError(`❌ manifest.json registration.account_strategy is "existing" but target_account is not set`)
204
+ }
205
+ }
206
+
207
+ function validateSeedBlock(appPath: string, manifest: Manifest): void {
208
+ if (!manifest.seed) return // optional — legacy apps without seed block are fine
209
+
210
+ if (!Array.isArray(manifest.seed)) {
211
+ throw new ValidationError('❌ manifest.json seed must be an array')
212
+ }
213
+
214
+ for (const entry of manifest.seed) {
215
+ if (!entry.file || !entry.table || !entry.conflict) {
216
+ throw new ValidationError(
217
+ `❌ manifest.json seed entry missing required field(s) (file, table, conflict): ${JSON.stringify(entry)}`
218
+ )
219
+ }
220
+ const filePath = join(appPath, entry.file)
221
+ if (!existsSync(filePath)) {
222
+ throw new ValidationError(`❌ manifest.json seed entry file "${entry.file}" does not exist`)
223
+ }
224
+ // Verify the file is valid JSON and an array
225
+ try {
226
+ const contents = JSON.parse(readFileSync(filePath, 'utf8'))
227
+ if (!Array.isArray(contents)) {
228
+ throw new ValidationError(`❌ Seed file "${entry.file}" must export a JSON array`)
229
+ }
230
+ } catch (err: any) {
231
+ if (err instanceof ValidationError) throw err
232
+ throw new ValidationError(`❌ Seed file "${entry.file}" is not valid JSON: ${err.message}`)
233
+ }
234
+ }
235
+ }
236
+
237
+ function validatePackagePublishability(packageJson: PackageJson): void {
238
+ if (packageJson.private === true) {
239
+ throw new ValidationError('❌ package.json has "private": true — app cannot be published to npm')
240
+ }
241
+ if (packageJson.private === undefined) {
242
+ console.warn('⚠️ package.json is missing "private": false — add it to confirm the package is publishable')
243
+ }
244
+ }
245
+
173
246
  function validatePackageFiles(appPath: string, packageJson: PackageJson): void {
174
247
  const { files = [] } = packageJson
175
248
 
@@ -202,13 +275,22 @@ function validateApp(appPath: string): void {
202
275
 
203
276
  validateRequiredFiles(appPath, manifest)
204
277
  console.log(`✅ Required files present for app_type: ${manifest.app_type}`)
205
-
278
+
279
+ validateManifestComponents(appPath, manifest)
280
+ console.log(`✅ manifest.json entry_point, sidebar_component, and registration valid`)
281
+
282
+ validateSeedBlock(appPath, manifest)
283
+ console.log(`✅ manifest.json seed block valid`)
284
+
206
285
  validateAllowedDirectories(appPath)
207
286
  console.log(`✅ No unknown directories found`)
208
-
287
+
209
288
  validateDevinDirectory(appPath)
210
289
  console.log(`✅ .devin/ directory valid (if present)`)
211
-
290
+
291
+ validatePackagePublishability(packageJson)
292
+ console.log(`✅ package.json publishability valid`)
293
+
212
294
  validatePackageFiles(appPath, packageJson)
213
295
  console.log(`✅ package.json files array checked`)
214
296
 
@@ -0,0 +1,286 @@
1
+ # Spine Framework — Custom Apps Agent Guide
2
+
3
+ This document explains the structure, contract, and tooling for apps under `custom/apps/`. Read this before touching any app code.
4
+
5
+ ---
6
+
7
+ ## Directory Layout
8
+
9
+ Every app lives at `custom/apps/{slug}/` and must follow this structure:
10
+
11
+ ```
12
+ custom/apps/{slug}/
13
+ ├── package.json # npm identity + spine metadata block
14
+ ├── manifest.json # runtime contract — the source of truth
15
+ ├── index.tsx # default export: root React component
16
+ ├── components/ # app-level shared components (sidebar, cards, etc.)
17
+ ├── pages/ # route-mapped page components
18
+ │ └── {section}/
19
+ │ └── SomePage.tsx
20
+ ├── functions/ # Netlify function handlers scoped to this app
21
+ ├── hooks/ # React hooks
22
+ ├── utils/ # Pure utility functions
23
+ ├── public/ # Static assets copied to PROJECT_ROOT/public/ at assemble time
24
+ └── seed/ # Data seeded into the DB on install
25
+ ├── roles.json
26
+ ├── types.json
27
+ ├── link-types.json
28
+ └── (any other declared seed files)
29
+ ```
30
+
31
+ No other top-level directories are allowed. `validate-app` will fail on unknown directories.
32
+
33
+ ---
34
+
35
+ ## package.json — Required Shape
36
+
37
+ ```json
38
+ {
39
+ "name": "spine-framework-{slug}",
40
+ "version": "X.Y.Z",
41
+ "private": false,
42
+ "description": "...",
43
+ "peerDependencies": {
44
+ "spine-framework": ">=0.1.0"
45
+ },
46
+ "files": [
47
+ "index.tsx",
48
+ "manifest.json",
49
+ "seed/",
50
+ "pages/",
51
+ "components/",
52
+ "hooks/",
53
+ "utils/",
54
+ "functions/",
55
+ "public/",
56
+ "README.md"
57
+ ],
58
+ "scripts": {
59
+ "validate": "npx spine-framework validate-app ."
60
+ },
61
+ "spine": {
62
+ "type": "app",
63
+ "slug": "{slug}",
64
+ "manifestPath": "manifest.json"
65
+ }
66
+ }
67
+ ```
68
+
69
+ **Rules:**
70
+ - `"private": false` is required — apps must be publishable to npm
71
+ - `files` must include every directory that exists in the app
72
+ - `version` must match `manifest.json` version exactly
73
+ - `spine.slug` must match `manifest.json` slug exactly
74
+ - Package name convention: `spine-framework-{slug}`
75
+
76
+ ---
77
+
78
+ ## manifest.json — Full Contract
79
+
80
+ ```json
81
+ {
82
+ "name": "My App",
83
+ "slug": "my-app",
84
+ "description": "...",
85
+ "version": "X.Y.Z",
86
+ "app_type": "full",
87
+ "required_roles": ["role-slug"],
88
+ "routes": ["/my-app", "/my-app/page"],
89
+ "nav_items": [
90
+ { "title": "Home", "path": "/my-app", "icon": "Home", "order": 1 }
91
+ ],
92
+ "features": ["feature-a", "feature-b"],
93
+ "dependencies": ["items", "accounts"],
94
+ "entry_point": "./index.tsx",
95
+ "sidebar_component": "./components/MyAppSidebar.tsx",
96
+ "seed": [
97
+ { "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" },
98
+ { "file": "seed/types.json", "table": "types", "conflict": "app_id,kind,slug" },
99
+ { "file": "seed/link-types.json", "table": "link_types", "conflict": "app_id,slug" }
100
+ ],
101
+ "registration": {
102
+ "enabled": true,
103
+ "default_role": "role-slug",
104
+ "redirect_path": "/my-app",
105
+ "account_strategy": "new"
106
+ }
107
+ }
108
+ ```
109
+
110
+ ### Field Reference
111
+
112
+ | Field | Required | Notes |
113
+ |-------|----------|-------|
114
+ | `name` | yes | Display name — written to `apps.name` in DB |
115
+ | `slug` | yes | Unique identifier — written to `apps.slug` |
116
+ | `version` | yes | Must match `package.json` version |
117
+ | `app_type` | yes | One of: `full`, `ui`, `backend`, `data` |
118
+ | `required_roles` | yes | Array of role slugs required to access the app |
119
+ | `routes` | recommended | All route paths the app handles |
120
+ | `nav_items` | recommended | Sidebar nav — written to `apps.nav_items` in DB |
121
+ | `entry_point` | yes (full/ui) | Path to root React component (relative to app root) |
122
+ | `sidebar_component` | yes (full/ui) | Path to sidebar component — used by AppWrapper |
123
+ | `seed` | yes | Declares all seed files (see below) |
124
+ | `registration` | if app supports self-registration | Controls `spine.config.json` registration entry |
125
+
126
+ ### `app_type` values
127
+
128
+ - `full` — has UI (pages, components) and backend (functions). Requires `index.tsx`, `pages/`, `functions/`
129
+ - `ui` — frontend only. Requires `index.tsx`, `pages/`
130
+ - `backend` — functions only. Requires `functions/`
131
+ - `data` — schema/seed only. No UI or functions required
132
+
133
+ ---
134
+
135
+ ## Seed Block
136
+
137
+ The `seed` array in `manifest.json` declares every file to be seeded into the DB at install time. `install-app` reads this array and runs a generic upsert loop — no hardcoded file names.
138
+
139
+ ### Seed entry format
140
+
141
+ ```json
142
+ { "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" }
143
+ ```
144
+
145
+ | Field | Required | Notes |
146
+ |-------|----------|-------|
147
+ | `file` | yes | Path relative to app root |
148
+ | `table` | yes | Supabase table name |
149
+ | `conflict` | yes | Column(s) for `onConflict` upsert (comma-separated) |
150
+ | `inject_app_id` | no | Default `true` — set `false` for tables without `app_id` |
151
+
152
+ ### Common conflict keys
153
+
154
+ | Table | conflict |
155
+ |-------|----------|
156
+ | `roles` | `app_id,slug` |
157
+ | `types` | `app_id,kind,slug` |
158
+ | `link_types` | `app_id,slug` |
159
+ | `triggers` | `app_id,name` |
160
+
161
+ ### Seed file format
162
+
163
+ Every seed file must be a JSON array. Each object maps to one DB row. Do NOT include `id` or `app_id` — these are set by the framework at install time.
164
+
165
+ ```json
166
+ [
167
+ {
168
+ "slug": "my-role",
169
+ "name": "My Role",
170
+ "description": "...",
171
+ "permissions": ["*"],
172
+ "is_system": true,
173
+ "is_active": true,
174
+ "is_protected": false
175
+ }
176
+ ]
177
+ ```
178
+
179
+ All seed files must be idempotent — safe to run against an existing DB without creating duplicates.
180
+
181
+ ---
182
+
183
+ ## registration block
184
+
185
+ Controls how users self-register for this app. Written to `spine.config.json` by `install-app`.
186
+
187
+ ```json
188
+ "registration": {
189
+ "enabled": true,
190
+ "default_role": "member",
191
+ "redirect_path": "/portal",
192
+ "account_strategy": "new"
193
+ }
194
+ ```
195
+
196
+ | `account_strategy` | Behavior |
197
+ |--------------------|----------|
198
+ | `new` | Creates a new account per registering user (customer-facing apps) |
199
+ | `existing` | Assigns user to an existing account — requires `target_account` |
200
+ | `choice` | User chooses at registration |
201
+
202
+ If `account_strategy` is `existing`, `target_account` (account slug) is required.
203
+
204
+ ---
205
+
206
+ ## CLI Commands
207
+
208
+ ```bash
209
+ # Install an app from npm
210
+ npx spine-framework install-app spine-framework-{slug}
211
+ npx spine-framework install-app spine-framework-{slug}@next # pre-release
212
+
213
+ # Validate an app before publishing
214
+ npx spine-framework validate-app custom/apps/{slug}
215
+ # or from inside the app directory:
216
+ npm run validate
217
+
218
+ # Uninstall an app
219
+ npx spine-framework uninstall-app spine-framework-{slug}
220
+
221
+ # Update an app
222
+ npx spine-framework update-app spine-framework-{slug}
223
+ ```
224
+
225
+ `validate-app` checks:
226
+ - `manifest.json` present, valid JSON, all required fields
227
+ - `package.json` present, versions match, `"private": false`
228
+ - Required files exist for `app_type`
229
+ - `entry_point` and `sidebar_component` files exist (if declared)
230
+ - `seed` block entries are valid and all declared files exist
231
+ - `registration.account_strategy: "existing"` has `target_account`
232
+ - No unknown directories
233
+ - `.devin/` present → must contain `AGENTS.md` or `AGENT.md`
234
+ - All directories in `files` array are present
235
+
236
+ ---
237
+
238
+ ## Assemble
239
+
240
+ `assemble-frontend.sh` and `assemble-functions.sh` pick up app files at build time:
241
+
242
+ - `functions/` → assembled into the Netlify functions directory
243
+ - `public/` → copied to `PROJECT_ROOT/public/` (Vite's publicDir)
244
+ - `pages/`, `components/`, `hooks/`, `utils/` → assembled into `.assembled/src/{slug}/`
245
+
246
+ **Important:** `public/` assets must go to `PROJECT_ROOT/public/`, not `.assembled/src/public/`. This was a known bug fixed in `spine-framework@0.3.76`.
247
+
248
+ ---
249
+
250
+ ## Publishing
251
+
252
+ Apps are published independently to npm, separate from `spine-framework`.
253
+
254
+ ```bash
255
+ # From inside custom/apps/{slug}/
256
+ npm version patch # bump version (must match manifest.json!)
257
+ # Also bump version in manifest.json to match
258
+ npm publish --tag next --access public # pre-release
259
+ npm publish --tag latest --access public # stable release
260
+ ```
261
+
262
+ Always publish with `--tag next` for development/testing. Promote to `--tag latest` only when stable and tested E2E in a dogfood project.
263
+
264
+ Version must be kept in sync between `package.json` and `manifest.json` — `validate-app` will fail if they differ.
265
+
266
+ ---
267
+
268
+ ## Current Apps
269
+
270
+ | Slug | Package | Description |
271
+ |------|---------|-------------|
272
+ | `cortex` | `spine-framework-cortex` | Internal workspace: CRM, Support, Community, KB |
273
+ | `portal` | `spine-framework-portal` | Customer-facing: Tickets, KB, Courses, Community |
274
+
275
+ ---
276
+
277
+ ## Adding a New App
278
+
279
+ 1. Create `custom/apps/{slug}/` with the directory structure above
280
+ 2. Write `package.json` and `manifest.json` following the templates above
281
+ 3. Write `index.tsx` exporting the root React component
282
+ 4. Write `components/{Slug}Sidebar.tsx` and declare it as `sidebar_component`
283
+ 5. Write seed files and declare them in the `seed` block
284
+ 6. Run `npm run validate` — fix all errors before proceeding
285
+ 7. Publish with `--tag next`
286
+ 8. Test E2E: `npx spine-framework install-app spine-framework-{slug}@next`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.76",
3
+ "version": "0.3.77",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {