spine-framework 0.3.75 → 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
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
|
@@ -48,19 +48,29 @@ if [ -f "$PROJECT_ROOT/.framework/index.html" ]; then
|
|
|
48
48
|
echo " ✓ Core: index.html (paths fixed)"
|
|
49
49
|
fi
|
|
50
50
|
|
|
51
|
-
# 3. Copy public assets from .framework
|
|
51
|
+
# 3. Copy public assets from .framework
|
|
52
|
+
# Vite's publicDir is set to PROJECT_ROOT/public (i.e. ../public relative to .assembled/src)
|
|
53
|
+
# so static files must go there to be served at the root URL
|
|
54
|
+
VITE_PUBLIC_DIR="$PROJECT_ROOT/public"
|
|
52
55
|
if [ -d "$PROJECT_ROOT/.framework/public" ]; then
|
|
53
|
-
mkdir -p "$
|
|
54
|
-
cp -r "$PROJECT_ROOT/.framework/public"/* "$
|
|
56
|
+
mkdir -p "$VITE_PUBLIC_DIR"
|
|
57
|
+
cp -r "$PROJECT_ROOT/.framework/public"/* "$VITE_PUBLIC_DIR/" 2>/dev/null || true
|
|
55
58
|
echo " ✓ Core: $(find "$PROJECT_ROOT/.framework/public" -type f | wc -l | tr -d ' ') public files"
|
|
56
59
|
fi
|
|
57
60
|
|
|
58
|
-
# 3.5. Copy _redirects to Vite-served
|
|
61
|
+
# 3.5. Copy _redirects to Vite-served root for Netlify dev (served from .assembled/src root)
|
|
59
62
|
if [ -f "$PROJECT_ROOT/.framework/public/_redirects" ]; then
|
|
60
63
|
cp "$PROJECT_ROOT/.framework/public/_redirects" "$TARGET_SRC_DIR/_redirects"
|
|
61
64
|
echo " ✓ Core: _redirects copied to Vite-served directory for Netlify dev"
|
|
62
65
|
fi
|
|
63
66
|
|
|
67
|
+
# 3.6. Copy spine.config.json to Vite publicDir so it's served at /spine.config.json
|
|
68
|
+
if [ -f "$PROJECT_ROOT/spine.config.json" ]; then
|
|
69
|
+
mkdir -p "$VITE_PUBLIC_DIR"
|
|
70
|
+
cp "$PROJECT_ROOT/spine.config.json" "$VITE_PUBLIC_DIR/spine.config.json"
|
|
71
|
+
echo " ✓ Config: spine.config.json copied to public/"
|
|
72
|
+
fi
|
|
73
|
+
|
|
64
74
|
# 6. Overlay custom src (overrides + additions)
|
|
65
75
|
if [ -d "$CUSTOM_SRC_DIR" ]; then
|
|
66
76
|
# Copy files and subdirectories
|
|
@@ -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
|
@@ -48,23 +48,26 @@ if [ -f "$PROJECT_ROOT/.framework/index.html" ]; then
|
|
|
48
48
|
echo " ✓ Core: index.html (paths fixed)"
|
|
49
49
|
fi
|
|
50
50
|
|
|
51
|
-
# 3. Copy public assets from .framework
|
|
51
|
+
# 3. Copy public assets from .framework
|
|
52
|
+
# Vite's publicDir is set to PROJECT_ROOT/public (i.e. ../public relative to .assembled/src)
|
|
53
|
+
# so static files must go there to be served at the root URL
|
|
54
|
+
VITE_PUBLIC_DIR="$PROJECT_ROOT/public"
|
|
52
55
|
if [ -d "$PROJECT_ROOT/.framework/public" ]; then
|
|
53
|
-
mkdir -p "$
|
|
54
|
-
cp -r "$PROJECT_ROOT/.framework/public"/* "$
|
|
56
|
+
mkdir -p "$VITE_PUBLIC_DIR"
|
|
57
|
+
cp -r "$PROJECT_ROOT/.framework/public"/* "$VITE_PUBLIC_DIR/" 2>/dev/null || true
|
|
55
58
|
echo " ✓ Core: $(find "$PROJECT_ROOT/.framework/public" -type f | wc -l | tr -d ' ') public files"
|
|
56
59
|
fi
|
|
57
60
|
|
|
58
|
-
# 3.5. Copy _redirects to Vite-served
|
|
61
|
+
# 3.5. Copy _redirects to Vite-served root for Netlify dev (served from .assembled/src root)
|
|
59
62
|
if [ -f "$PROJECT_ROOT/.framework/public/_redirects" ]; then
|
|
60
63
|
cp "$PROJECT_ROOT/.framework/public/_redirects" "$TARGET_SRC_DIR/_redirects"
|
|
61
64
|
echo " ✓ Core: _redirects copied to Vite-served directory for Netlify dev"
|
|
62
65
|
fi
|
|
63
66
|
|
|
64
|
-
# 3.6. Copy spine.config.json to
|
|
67
|
+
# 3.6. Copy spine.config.json to Vite publicDir so it's served at /spine.config.json
|
|
65
68
|
if [ -f "$PROJECT_ROOT/spine.config.json" ]; then
|
|
66
|
-
mkdir -p "$
|
|
67
|
-
cp "$PROJECT_ROOT/spine.config.json" "$
|
|
69
|
+
mkdir -p "$VITE_PUBLIC_DIR"
|
|
70
|
+
cp "$PROJECT_ROOT/spine.config.json" "$VITE_PUBLIC_DIR/spine.config.json"
|
|
68
71
|
echo " ✓ Config: spine.config.json copied to public/"
|
|
69
72
|
fi
|
|
70
73
|
|