spine-framework 0.3.145 → 0.4.1

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 (451) hide show
  1. package/.env.example +76 -0
  2. package/.framework/.spine-manifest.json +19 -0
  3. package/.framework/cli/commands/agents.ts +61 -33
  4. package/.framework/cli/commands/auth.ts +60 -28
  5. package/.framework/cli/commands/create-app.ts +212 -80
  6. package/.framework/cli/commands/dev.ts +184 -68
  7. package/.framework/cli/commands/doctor.ts +324 -152
  8. package/.framework/cli/commands/generate.ts +119 -68
  9. package/.framework/cli/commands/init.ts +141 -242
  10. package/.framework/cli/commands/install-app.ts +1336 -314
  11. package/.framework/cli/commands/items.ts +31 -29
  12. package/.framework/cli/commands/migrate.ts +299 -156
  13. package/.framework/cli/commands/migrations.ts +62 -30
  14. package/.framework/cli/commands/pipelines.ts +31 -29
  15. package/.framework/cli/commands/status.ts +105 -45
  16. package/.framework/cli/commands/system.ts +47 -42
  17. package/.framework/cli/commands/test.ts +161 -102
  18. package/.framework/cli/commands/upgrade.ts +260 -0
  19. package/.framework/cli/commands/validate-app.ts +517 -161
  20. package/.framework/cli/context.ts +117 -85
  21. package/.framework/cli/env-loader.ts +74 -43
  22. package/.framework/cli/index.ts +70 -46
  23. package/.framework/cli/lib/project-files.ts +404 -0
  24. package/.framework/docs/guides/testing-guide.md +1 -1
  25. package/.framework/docs/runtime-types.md +72 -72
  26. package/.framework/functions/_shared/access.ts +84 -0
  27. package/.framework/functions/_shared/agent-runner.ts +5067 -1026
  28. package/.framework/functions/_shared/app-manifest.ts +214 -94
  29. package/.framework/functions/_shared/ast.ts +214 -0
  30. package/.framework/functions/_shared/audit.ts +91 -55
  31. package/.framework/functions/_shared/cil.ts +368 -127
  32. package/.framework/functions/_shared/db.ts +337 -68
  33. package/.framework/functions/_shared/funnel.ts +228 -0
  34. package/.framework/functions/_shared/geo.ts +105 -0
  35. package/.framework/functions/_shared/hash-api-key.ts +94 -17
  36. package/.framework/functions/_shared/index.ts +22 -224
  37. package/.framework/functions/_shared/kb.ts +832 -58
  38. package/.framework/functions/_shared/llm.ts +118 -18
  39. package/.framework/functions/_shared/logging.ts +317 -19
  40. package/.framework/functions/_shared/middleware.ts +701 -131
  41. package/.framework/functions/_shared/notify.ts +70 -0
  42. package/.framework/functions/_shared/outcomes.ts +34 -19
  43. package/.framework/functions/_shared/permissions.ts +501 -315
  44. package/.framework/functions/_shared/pipeline-runner.ts +704 -155
  45. package/.framework/functions/_shared/principal.ts +436 -263
  46. package/.framework/functions/_shared/rate-limit.ts +233 -0
  47. package/.framework/functions/_shared/resolve-credentials.ts +86 -19
  48. package/.framework/functions/_shared/resolve-ids.ts +203 -139
  49. package/.framework/functions/_shared/retry-deliveries.ts +51 -16
  50. package/.framework/functions/_shared/schema-utils.ts +602 -521
  51. package/.framework/functions/_shared/security.ts +345 -0
  52. package/.framework/functions/_shared/testing.ts +197 -172
  53. package/.framework/functions/_shared/tracing.ts +351 -21
  54. package/.framework/functions/_shared/trigger-engine.ts +425 -136
  55. package/.framework/functions/_shared/webhook-registration.ts +71 -70
  56. package/.framework/functions/_shared/webhook-registry.ts +126 -83
  57. package/.framework/functions/account-nodes.ts +30 -30
  58. package/.framework/functions/adapters/generic-webhook-inbound.ts +49 -16
  59. package/.framework/functions/adapters/generic-webhook-outbound.ts +17 -17
  60. package/.framework/functions/adapters/openai-outbound.ts +15 -16
  61. package/.framework/functions/admin-data.ts +596 -173
  62. package/.framework/functions/admin-funnel.ts +64 -60
  63. package/.framework/functions/admin-install-funnel.ts +84 -66
  64. package/.framework/functions/admin-signals.ts +69 -51
  65. package/.framework/functions/ai-agents.ts +431 -106
  66. package/.framework/functions/ai-interactions.ts +186 -112
  67. package/.framework/functions/api-keys.ts +107 -107
  68. package/.framework/functions/apps.ts +154 -136
  69. package/.framework/functions/auth.ts +83 -65
  70. package/.framework/functions/cil-agent-ingest.ts +189 -232
  71. package/.framework/functions/cil-agent.ts +433 -177
  72. package/.framework/functions/cil-callmap.ts +642 -430
  73. package/.framework/functions/cil-config.ts +139 -135
  74. package/.framework/functions/cil-context.ts +214 -127
  75. package/.framework/functions/cil-doc.ts +587 -458
  76. package/.framework/functions/cil-embeddings.ts +453 -364
  77. package/.framework/functions/cil-graph.ts +427 -142
  78. package/.framework/functions/cil-ingest.ts +1418 -1154
  79. package/.framework/functions/cil-pipeline.ts +276 -21
  80. package/.framework/functions/cil-project.ts +659 -132
  81. package/.framework/functions/cil-validate.ts +198 -164
  82. package/.framework/functions/debug-auth.ts +57 -55
  83. package/.framework/functions/deliveries.ts +80 -21
  84. package/.framework/functions/embeddings.ts +1023 -318
  85. package/.framework/functions/eval-harness.ts +146 -108
  86. package/.framework/functions/integration-outbound.ts +205 -75
  87. package/.framework/functions/integration-routes.ts +128 -23
  88. package/.framework/functions/integrations.ts +125 -100
  89. package/.framework/functions/invites.ts +1095 -460
  90. package/.framework/functions/item-progress.ts +158 -102
  91. package/.framework/functions/kb.ts +34 -10
  92. package/.framework/functions/logs.ts +133 -133
  93. package/.framework/functions/observability.ts +152 -122
  94. package/.framework/functions/pipeline-executions.ts +147 -132
  95. package/.framework/functions/pipelines.ts +134 -119
  96. package/.framework/functions/prompt-configs.ts +96 -80
  97. package/.framework/functions/roles.ts +112 -82
  98. package/.framework/functions/security.ts +450 -0
  99. package/.framework/functions/system-cron.ts +117 -80
  100. package/.framework/functions/system.ts +158 -98
  101. package/.framework/functions/tests.ts +29 -29
  102. package/.framework/functions/timers.ts +94 -95
  103. package/.framework/functions/trigger-scheduler.ts +10 -0
  104. package/.framework/functions/triggers.ts +179 -149
  105. package/.framework/functions/types.ts +137 -125
  106. package/.framework/mcp/index.ts +133 -0
  107. package/.framework/mcp/load-env.ts +60 -0
  108. package/.framework/mcp/shared/app-manifest.ts +275 -0
  109. package/.framework/mcp/shared/db.ts +477 -0
  110. package/.framework/mcp/shared/kb.ts +118 -0
  111. package/.framework/mcp/tools/architect.ts +431 -0
  112. package/.framework/mcp/tools/cil/context.ts +345 -0
  113. package/.framework/mcp/tools/cil/graph.ts +242 -0
  114. package/.framework/mcp/tools/cil/ingest.ts +1217 -0
  115. package/.framework/mcp/tools/cil/pipeline-orchestrator.ts +693 -0
  116. package/.framework/mcp/tools/cil/pipeline.ts +767 -0
  117. package/.framework/mcp/tools/cil/project.ts +791 -0
  118. package/.framework/mcp/tools/cil/retrieval.ts +336 -0
  119. package/.framework/mcp/tools/cil/search-core.ts +454 -0
  120. package/.framework/mcp/tools/cil/search.ts +97 -0
  121. package/.framework/mcp/tools/cil/suggest.ts +186 -0
  122. package/.framework/mcp/tools/cil/validate.ts +1369 -0
  123. package/.framework/mcp/tools/kb/delete.ts +139 -0
  124. package/.framework/mcp/tools/kb/index.ts +52 -0
  125. package/.framework/mcp/tools/kb/ingest.ts +84 -0
  126. package/.framework/mcp/tools/kb/list-types.ts +102 -0
  127. package/.framework/mcp/tools/kb/publish.ts +100 -0
  128. package/.framework/mcp/tools/kb/reindex.ts +86 -0
  129. package/.framework/mcp/tools/kb/revoke.ts +85 -0
  130. package/.framework/mcp/tools/kb/search.ts +101 -0
  131. package/.framework/mcp/tools/kb/upsert.ts +125 -0
  132. package/.framework/mcp/tools/observability.ts +510 -0
  133. package/.framework/migrations/001_core_schema.sql +15 -0
  134. package/.framework/migrations/002_core_rls_and_functions.sql +15 -0
  135. package/.framework/migrations/003_core_system_data.sql +16 -1
  136. package/.framework/migrations/004_core_rpc_and_broker.sql +17 -1
  137. package/.framework/migrations/005_core_observability.sql +17 -1
  138. package/.framework/migrations/006_security_hardening_rls.sql +15 -0
  139. package/.framework/migrations/007_kb_article_list.sql +17 -1
  140. package/.framework/migrations/008_items_target.sql +15 -0
  141. package/.framework/migrations/009_roles_slug_unique.sql +15 -0
  142. package/.framework/migrations/010_auth_user_trigger.sql +15 -0
  143. package/.framework/migrations/011_spine_system_account.sql +16 -0
  144. package/.framework/migrations/012_service_role_grants.sql +16 -0
  145. package/.framework/migrations/013_get_account_apps_role_filter.sql +15 -0
  146. package/.framework/migrations/014_apps_required_roles.sql +16 -0
  147. package/.framework/migrations/015_types_kind_slug_unique.sql +15 -0
  148. package/.framework/migrations/016_cil_types.sql +281 -0
  149. package/.framework/migrations/017_cil_stable_ids.sql +37 -0
  150. package/.framework/migrations/018_runtime_tables.sql +129 -0
  151. package/.framework/migrations/019_agent_prompt_columns.sql +56 -0
  152. package/.framework/migrations/020_schema_reconciliation.sql +67 -0
  153. package/.framework/migrations/021_kb_embedding_lifecycle.sql +31 -0
  154. package/.framework/migrations/032_cil_status_migration.sql +73 -0
  155. package/.framework/migrations/033_cil_versioning.sql +73 -0
  156. package/.framework/migrations/034_match_embeddings_knn.sql +115 -0
  157. package/.framework/migrations/035_rate_limits.sql +55 -0
  158. package/.framework/migrations/036_demo_record_flags.sql +72 -0
  159. package/.framework/migrations/037_kb_types_canonical.sql +244 -0
  160. package/.framework/migrations/038_drop_demo_record_flags.sql +38 -0
  161. package/.framework/migrations/042_registration_hierarchy.sql +309 -0
  162. package/.framework/migrations/043_policy_actor_cleanup.sql +39 -0
  163. package/.framework/migrations/044_match_embeddings_filtered_knn.sql +151 -0
  164. package/.framework/migrations/045_security_critical_rls.sql +139 -0
  165. package/.framework/migrations/046_security_missing_rls.sql +79 -0
  166. package/.framework/migrations/047_people_updated_by.sql +27 -0
  167. package/.framework/migrations/048_watcher_last_seen_at.sql +24 -0
  168. package/.framework/migrations/049_security_blocklist.sql +148 -0
  169. package/.framework/migrations/050_notifications.sql +49 -0
  170. package/.framework/migrations/051_message_agent_fields.sql +74 -0
  171. package/.framework/migrations/052_registration_security.sql +421 -0
  172. package/.framework/migrations/053_builtin_action_seeds.sql +66 -0
  173. package/.framework/migrations/054_kb_ingest_lease.sql +157 -0
  174. package/.framework/migrations/055_type_id_drift_reconciliation.sql +33 -0
  175. package/.framework/migrations/056_slug_column_reconciliation.sql +38 -0
  176. package/.framework/migrations/057_pipelines_slug_identity.sql +14 -0
  177. package/.framework/migrations/058_account_data_field_declarations.sql +53 -0
  178. package/.framework/migrations/059_registration_placement_fixes.sql +269 -0
  179. package/.framework/migrations/060_account_person_record_permissions.sql +38 -0
  180. package/.framework/migrations/061_fk_dedup.sql +32 -0
  181. package/.framework/public/spine-logo.svg +10 -3
  182. package/.framework/scripts/app-install-cli.ts +190 -93
  183. package/.framework/scripts/assemble-frontend.sh +127 -44
  184. package/.framework/scripts/assemble-functions.sh +128 -30
  185. package/.framework/scripts/boundary-check.sh +6 -2
  186. package/.framework/scripts/build-manifest.sh +24 -68
  187. package/.framework/scripts/check-core-integrity.sh +15 -76
  188. package/.framework/scripts/checksum-manifest.mts +343 -0
  189. package/.framework/scripts/cil-migrate-markers.ts +403 -0
  190. package/.framework/scripts/fix-cil-markers.mjs +176 -0
  191. package/.framework/scripts/verify-integrity.sh +21 -57
  192. package/.framework/scripts/verify-manifest.mts +305 -0
  193. package/.framework/seeds/types/funnel_signal_use.json +74 -21
  194. package/.framework/seeds/types/spine_instance.json +73 -21
  195. package/.framework/src/App.tsx +118 -72
  196. package/.framework/src/apps/admin/index.tsx +855 -58
  197. package/.framework/src/components/AppWrapper.tsx +43 -45
  198. package/.framework/src/components/CustomAppLoader.tsx +160 -116
  199. package/.framework/src/components/admin/AdminListPage.tsx +46 -44
  200. package/.framework/src/components/admin/AdminSidebar.tsx +57 -47
  201. package/.framework/src/components/admin/AdminStatsCard.tsx +46 -44
  202. package/.framework/src/components/admin/PageInstructions.tsx +98 -2
  203. package/.framework/src/components/admin/SortableTableHeader.tsx +44 -45
  204. package/.framework/src/components/app-shell/GenericAppShell.tsx +64 -65
  205. package/.framework/src/components/app-shell/GenericDetailPage.tsx +44 -47
  206. package/.framework/src/components/app-shell/GenericListPage.tsx +45 -49
  207. package/.framework/src/components/app-sidebar.tsx +42 -45
  208. package/.framework/src/components/auth/ProtectedRoute.tsx +48 -41
  209. package/.framework/src/components/auth/TurnstileWidget.tsx +27 -0
  210. package/.framework/src/components/kb/KBArticleEditor.tsx +231 -22
  211. package/.framework/src/components/kb/KBArticleList.tsx +251 -10
  212. package/.framework/src/components/layout/AppShell.tsx +51 -45
  213. package/.framework/src/components/layout/Header.tsx +42 -43
  214. package/.framework/src/components/layout/Layout.tsx +46 -46
  215. package/.framework/src/components/layout/Sidebar.tsx +196 -96
  216. package/.framework/src/components/runtime/DataDetailHeader.tsx +55 -30
  217. package/.framework/src/components/runtime/DataDetailPage.tsx +31 -269
  218. package/.framework/src/components/runtime/DataFilters.tsx +49 -74
  219. package/.framework/src/components/runtime/DataHeader.tsx +53 -32
  220. package/.framework/src/components/runtime/DataListPage.tsx +51 -155
  221. package/.framework/src/components/runtime/DataStats.tsx +66 -44
  222. package/.framework/src/components/runtime/DataTable.tsx +49 -149
  223. package/.framework/src/components/runtime/SchemaDetailForm.tsx +45 -75
  224. package/.framework/src/components/runtime/index.ts +119 -16
  225. package/.framework/src/components/search-form.tsx +46 -29
  226. package/.framework/src/components/shared/AgentView.tsx +65 -48
  227. package/.framework/src/components/shared/FieldRenderer.tsx +144 -50
  228. package/.framework/src/components/shared/InviteManager.tsx +207 -97
  229. package/.framework/src/components/shared/MemberList.tsx +97 -0
  230. package/.framework/src/components/shared/SchemaFields.tsx +107 -86
  231. package/.framework/src/components/ui/DataTable.tsx +80 -68
  232. package/.framework/src/components/ui/Form.tsx +157 -146
  233. package/.framework/src/components/ui/ItemCard.tsx +74 -67
  234. package/.framework/src/components/ui/ItemListView.tsx +73 -68
  235. package/.framework/src/components/ui/LoadingSpinner.tsx +43 -48
  236. package/.framework/src/components/ui/Modal.tsx +78 -68
  237. package/.framework/src/components/ui/RichTextEditor.tsx +89 -69
  238. package/.framework/src/components/ui/accordion.tsx +85 -86
  239. package/.framework/src/components/ui/alert-dialog.tsx +197 -184
  240. package/.framework/src/components/ui/alert.tsx +105 -94
  241. package/.framework/src/components/ui/aspect-ratio.tsx +57 -43
  242. package/.framework/src/components/ui/avatar.tsx +122 -107
  243. package/.framework/src/components/ui/badge.tsx +60 -55
  244. package/.framework/src/components/ui/breadcrumb.tsx +127 -128
  245. package/.framework/src/components/ui/button-group.tsx +86 -72
  246. package/.framework/src/components/ui/button.tsx +71 -50
  247. package/.framework/src/components/ui/calendar.tsx +72 -57
  248. package/.framework/src/components/ui/card.tsx +136 -129
  249. package/.framework/src/components/ui/carousel.tsx +226 -107
  250. package/.framework/src/components/ui/chart.tsx +252 -147
  251. package/.framework/src/components/ui/checkbox.tsx +43 -44
  252. package/.framework/src/components/ui/collapsible.tsx +86 -71
  253. package/.framework/src/components/ui/combobox.tsx +282 -249
  254. package/.framework/src/components/ui/command.tsx +158 -146
  255. package/.framework/src/components/ui/context-menu.tsx +239 -240
  256. package/.framework/src/components/ui/dialog.tsx +174 -157
  257. package/.framework/src/components/ui/direction.tsx +72 -56
  258. package/.framework/src/components/ui/drawer.tsx +168 -169
  259. package/.framework/src/components/ui/dropdown-menu.tsx +254 -223
  260. package/.framework/src/components/ui/empty.tsx +131 -120
  261. package/.framework/src/components/ui/field.tsx +205 -173
  262. package/.framework/src/components/ui/hover-card.tsx +71 -72
  263. package/.framework/src/components/ui/input-group.tsx +148 -133
  264. package/.framework/src/components/ui/input-otp.tsx +99 -86
  265. package/.framework/src/components/ui/input.tsx +43 -41
  266. package/.framework/src/components/ui/item.tsx +207 -185
  267. package/.framework/src/components/ui/kbd.tsx +57 -56
  268. package/.framework/src/components/ui/label.tsx +45 -41
  269. package/.framework/src/components/ui/menubar.tsx +268 -236
  270. package/.framework/src/components/ui/native-select.tsx +85 -86
  271. package/.framework/src/components/ui/navigation-menu.tsx +166 -156
  272. package/.framework/src/components/ui/pagination.tsx +131 -141
  273. package/.framework/src/components/ui/popover.tsx +125 -128
  274. package/.framework/src/components/ui/progress.tsx +47 -44
  275. package/.framework/src/components/ui/radio-group.tsx +60 -55
  276. package/.framework/src/components/ui/resizable.tsx +85 -72
  277. package/.framework/src/components/ui/scroll-area.tsx +57 -58
  278. package/.framework/src/components/ui/select.tsx +182 -169
  279. package/.framework/src/components/ui/separator.tsx +42 -43
  280. package/.framework/src/components/ui/sheet.tsx +170 -159
  281. package/.framework/src/components/ui/sidebar.tsx +395 -398
  282. package/.framework/src/components/ui/skeleton.tsx +42 -43
  283. package/.framework/src/components/ui/slider.tsx +61 -44
  284. package/.framework/src/components/ui/sonner.tsx +44 -44
  285. package/.framework/src/components/ui/spinner.tsx +47 -42
  286. package/.framework/src/components/ui/switch.tsx +56 -42
  287. package/.framework/src/components/ui/table-primitives.tsx +272 -132
  288. package/.framework/src/components/ui/table.tsx +140 -142
  289. package/.framework/src/components/ui/tabs.tsx +110 -93
  290. package/.framework/src/components/ui/textarea.tsx +43 -44
  291. package/.framework/src/components/ui/toggle-group.tsx +87 -71
  292. package/.framework/src/components/ui/toggle.tsx +56 -56
  293. package/.framework/src/components/ui/tooltip.tsx +99 -85
  294. package/.framework/src/contexts/AppContext.tsx +166 -114
  295. package/.framework/src/contexts/AuthContext.tsx +232 -137
  296. package/.framework/src/hooks/use-mobile.ts +43 -44
  297. package/.framework/src/hooks/useApi.ts +185 -172
  298. package/.framework/src/hooks/useAppPath.ts +31 -29
  299. package/.framework/src/hooks/useApps.ts +77 -42
  300. package/.framework/src/hooks/useEntityList.ts +66 -29
  301. package/.framework/src/hooks/useEntityRecord.ts +76 -32
  302. package/.framework/src/hooks/useForm.ts +62 -30
  303. package/.framework/src/hooks/useListSchema.ts +60 -43
  304. package/.framework/src/hooks/useSchemaRecord.ts +115 -95
  305. package/.framework/src/lib/api.ts +258 -134
  306. package/.framework/src/lib/registration.ts +181 -0
  307. package/.framework/src/lib/supabase.ts +118 -81
  308. package/.framework/src/lib/utils.ts +266 -266
  309. package/.framework/src/main.tsx +43 -44
  310. package/.framework/src/pages/DashboardPage.tsx +44 -43
  311. package/.framework/src/pages/NoAppPage.tsx +50 -0
  312. package/.framework/src/pages/NotFoundPage.tsx +32 -29
  313. package/.framework/src/pages/SetupPage.tsx +34 -33
  314. package/.framework/src/pages/admin/AIAgentDetailPage.tsx +69 -58
  315. package/.framework/src/pages/admin/AIAgentsPage.tsx +43 -40
  316. package/.framework/src/pages/admin/APIKeyDetailPage.tsx +63 -55
  317. package/.framework/src/pages/admin/APIKeysPage.tsx +44 -42
  318. package/.framework/src/pages/admin/AlertsConfigPage.tsx +117 -94
  319. package/.framework/src/pages/admin/AppDetailPage.tsx +48 -42
  320. package/.framework/src/pages/admin/AppsPage.tsx +45 -42
  321. package/.framework/src/pages/admin/DeliveriesPage.tsx +49 -17
  322. package/.framework/src/pages/admin/DesignedPage.tsx +31 -30
  323. package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +58 -0
  324. package/.framework/src/pages/admin/EmbeddingsPage.tsx +49 -0
  325. package/.framework/src/pages/admin/ExtendedShadcnTestPage.tsx +31 -29
  326. package/.framework/src/pages/admin/IncrementalShadcnTestPage.tsx +46 -29
  327. package/.framework/src/pages/admin/IntegratedDashboard.tsx +32 -29
  328. package/.framework/src/pages/admin/IntegrationDetailPage.tsx +68 -55
  329. package/.framework/src/pages/admin/IntegrationsPage.tsx +45 -42
  330. package/.framework/src/pages/admin/InvitesPage.tsx +60 -44
  331. package/.framework/src/pages/admin/LogsPage.tsx +87 -137
  332. package/.framework/src/pages/admin/MinimalShadcnTestPage.tsx +31 -29
  333. package/.framework/src/pages/admin/ObservabilityDashboard.tsx +150 -89
  334. package/.framework/src/pages/admin/PipelineDetailPage.tsx +77 -104
  335. package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +44 -43
  336. package/.framework/src/pages/admin/PipelinesPage.tsx +59 -145
  337. package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +53 -42
  338. package/.framework/src/pages/admin/PromptConfigsPage.tsx +47 -117
  339. package/.framework/src/pages/admin/ProperlyDesignedPage.tsx +31 -44
  340. package/.framework/src/pages/admin/RoleDetailPage.tsx +49 -92
  341. package/.framework/src/pages/admin/RolesPage.tsx +47 -164
  342. package/.framework/src/pages/admin/SecurityPage.tsx +310 -0
  343. package/.framework/src/pages/admin/SelectTestPage.tsx +30 -32
  344. package/.framework/src/pages/admin/ShadcnTestPage.tsx +37 -44
  345. package/.framework/src/pages/admin/SimpleDashboard.tsx +32 -44
  346. package/.framework/src/pages/admin/TestRunDetailPage.tsx +97 -44
  347. package/.framework/src/pages/admin/TestingDashboard.tsx +126 -71
  348. package/.framework/src/pages/admin/TimerDetailPage.tsx +67 -55
  349. package/.framework/src/pages/admin/TimersPage.tsx +44 -42
  350. package/.framework/src/pages/admin/TriggerDetailPage.tsx +47 -42
  351. package/.framework/src/pages/admin/TriggersPage.tsx +58 -57
  352. package/.framework/src/pages/admin/TypeDetailPage.tsx +91 -55
  353. package/.framework/src/pages/admin/TypesPage.tsx +45 -42
  354. package/.framework/src/pages/admin/cil/CILAgentPage.tsx +258 -128
  355. package/.framework/src/pages/admin/cil/CILChunkDetailPage.tsx +127 -123
  356. package/.framework/src/pages/admin/cil/CILDashboardPage.tsx +308 -235
  357. package/.framework/src/pages/admin/cil/CILFileListPage.tsx +159 -110
  358. package/.framework/src/pages/admin/cil/CILFileSelectPage.tsx +432 -0
  359. package/.framework/src/pages/admin/cil/CILGraphPage.tsx +200 -178
  360. package/.framework/src/pages/admin/cil/CILIngestionPage.tsx +286 -239
  361. package/.framework/src/pages/admin/cil/CILProjectsPage.tsx +211 -90
  362. package/.framework/src/pages/admin/cil/CILSearchPage.tsx +156 -143
  363. package/.framework/src/pages/admin/cil/CILSuggestionsPage.tsx +379 -0
  364. package/.framework/src/pages/admin/cil/CILVersionsPage.tsx +337 -0
  365. package/.framework/src/pages/admin/cil/FlexiblePipelinePage.tsx +219 -214
  366. package/.framework/src/pages/admin/kb/KBAdminPage.tsx +64 -0
  367. package/.framework/src/pages/auth/AuthConfirmPage.tsx +135 -65
  368. package/.framework/src/pages/auth/ForgotPasswordPage.tsx +39 -31
  369. package/.framework/src/pages/auth/LoginPage.tsx +38 -32
  370. package/.framework/src/pages/auth/PendingApprovalPage.tsx +31 -28
  371. package/.framework/src/pages/auth/RegisterPage.tsx +429 -155
  372. package/.framework/src/pages/spine-framework/APIPage.tsx +33 -30
  373. package/.framework/src/pages/spine-framework/CLIPage.tsx +32 -30
  374. package/.framework/src/types/auth.ts +101 -93
  375. package/.framework/src/types/types.ts +283 -262
  376. package/.framework/templates/project.json +87 -0
  377. package/.framework/templates/spine.config.neutral.json +7 -0
  378. package/.framework/tests/api/api-surface.test.ts +381 -0
  379. package/.framework/tests/fixtures/seed.ts +427 -0
  380. package/.framework/tests/fixtures/teardown.ts +228 -0
  381. package/.framework/tests/reporter.ts +351 -0
  382. package/.framework/tests/setup.ts +135 -0
  383. package/.framework/tests/ui/ui-sweep.ts +278 -0
  384. package/.framework/tests/unit/admin-data-logger.test.ts +310 -0
  385. package/.framework/tests/unit/agent-runner.test.ts +2389 -0
  386. package/.framework/tests/unit/ai-interactions.test.ts +81 -0
  387. package/.framework/tests/unit/app-manifest.test.ts +359 -0
  388. package/.framework/tests/unit/assembly-scripts.test.ts +172 -0
  389. package/.framework/tests/unit/cil-determinism.test.ts +85 -0
  390. package/.framework/tests/unit/core-isolation.test.ts +153 -0
  391. package/.framework/tests/unit/funnel-signals.test.ts +135 -0
  392. package/.framework/tests/unit/install-app.test.ts +1759 -0
  393. package/.framework/tests/unit/invites-security.test.ts +572 -0
  394. package/.framework/tests/unit/kb-ingest-lease.test.ts +287 -0
  395. package/.framework/tests/unit/kb-search.test.ts +119 -0
  396. package/.framework/tests/unit/migrate.test.ts +509 -0
  397. package/.framework/tests/unit/notify.test.ts +136 -0
  398. package/.framework/tests/unit/permissions.test.ts +269 -0
  399. package/.framework/tests/unit/pipeline-runner.test.ts +755 -0
  400. package/.framework/tests/unit/principal-cache.test.ts +138 -0
  401. package/.framework/tests/unit/retrieval-golden.test.ts +520 -0
  402. package/.framework/tests/unit/runtime-isolation.test.ts +642 -0
  403. package/.framework/tests/unit/schema-utils.test.ts +225 -0
  404. package/.framework/tests/unit/trigger-engine.test.ts +513 -0
  405. package/.framework/tests/unit/validate-app.test.ts +527 -0
  406. package/README.md +34 -3
  407. package/bin/spine-framework.cjs +1 -0
  408. package/config/tailwind.config.cjs +7 -0
  409. package/config/tsconfig.functions.json +42 -0
  410. package/config/tsconfig.json +37 -9
  411. package/config/vite.config.ts +31 -2
  412. package/netlify.toml +31 -3
  413. package/package.json +46 -29
  414. package/scripts/assemble-frontend.sh +127 -44
  415. package/scripts/assemble-functions.sh +127 -29
  416. package/scripts/boundary-check.sh +6 -2
  417. package/scripts/build-manifest.sh +24 -68
  418. package/scripts/checksum-manifest.mts +345 -0
  419. package/scripts/dev-clean.sh +86 -0
  420. package/scripts/dev-netlify.sh +49 -0
  421. package/scripts/netlify-dev-wrapper.sh +2 -0
  422. package/scripts/verify-install.ts +358 -0
  423. package/scripts/verify-integrity.sh +21 -57
  424. package/scripts/verify-manifest.mts +305 -0
  425. package/templates/project/gitignore +45 -0
  426. package/{config → templates/project}/package-lock.json +22335 -9366
  427. package/vitest.config.ts +46 -0
  428. package/.framework/public/spine.config.json +0 -91
  429. package/config/deno.lock +0 -108
  430. package/config/tsconfig.node.tsbuildinfo +0 -1
  431. package/config/tsconfig.tsbuildinfo +0 -1
  432. package/config/vite.config.d.ts +0 -2
  433. package/custom/.devin/AGENT.md +0 -357
  434. package/custom/.devin/skills/install-app/SKILL.md +0 -39
  435. package/custom/.devin/skills/spine-workbench-discovery/SKILL.md +0 -39
  436. package/custom/.devin/skills/spine-workbench-idea/SKILL.md +0 -36
  437. package/custom/.devin/skills/spine-workbench-implement/SKILL.md +0 -46
  438. package/custom/.devin/skills/spine-workbench-initiative/SKILL.md +0 -28
  439. package/custom/.devin/skills/spine-workbench-plan/SKILL.md +0 -38
  440. package/custom/.devin/workflows/app-install.yaml +0 -83
  441. package/custom/README.md +0 -79
  442. package/docs/README.md +0 -15
  443. package/scripts/app-install-cli.ts +0 -286
  444. package/scripts/check-core-integrity.sh +0 -82
  445. package/scripts/ingest-chunks.cjs +0 -202
  446. package/scripts/kb-chunk-parser.cjs +0 -312
  447. package/scripts/kb-chunk-parser.ts +0 -330
  448. package/scripts/kb-ingest-docs.ts +0 -203
  449. package/scripts/load-test-app-install.ts +0 -484
  450. package/scripts/test-login.js +0 -33
  451. package/spine.config.json +0 -91
@@ -0,0 +1,1759 @@
1
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/tests/unit/install-app.test.ts"]
2
+ // CIL[DOC-MACRO purpose="Import Vitest and filesystem fixture utilities for isolated CLI tests."]
3
+ // CIL[DOC-RESPONSIBILITY responsibility="Import Vitest and filesystem fixture utilities for isolated CLI tests."]
4
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
5
+ // CIL[DOC-CONTRACT inputs="none" outputs="none" errors="none" side_effects="none"]
6
+ // CIL[DOC-MICRO logic_summary="Import Vitest and filesystem fixture utilities for isolated CLI tests." notable_decisions=""]
7
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
8
+ // CIL[DOC-STABILITY stability="experimental"]
9
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
10
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
11
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { Command } from 'commander'
15
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:module_header:module_header"]
16
+
17
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:dbcall:type" type="type" identity="DbCall" parent="none"]
18
+ // CIL[DOC-MACRO purpose="Describe an operation recorded by a fake query builder."]
19
+ // CIL[DOC-RESPONSIBILITY responsibility="Describe an operation recorded by a fake query builder."]
20
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
21
+ // CIL[DOC-CONTRACT inputs="none" outputs="DbCall type" errors="none" side_effects="none"]
22
+ // CIL[DOC-MICRO logic_summary="Describe an operation recorded by a fake query builder." notable_decisions=""]
23
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
24
+ // CIL[DOC-STABILITY stability="experimental"]
25
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
26
+ type DbCall = { op: string; args: any[] }
27
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:dbcall:type"]
28
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:dbquery:type" type="type" identity="DbQuery" parent="none"]
29
+ // CIL[DOC-MACRO purpose="Describe a table and its recorded fake query operations."]
30
+ // CIL[DOC-RESPONSIBILITY responsibility="Describe a table and its recorded fake query operations."]
31
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
32
+ // CIL[DOC-CONTRACT inputs="none" outputs="DbQuery type" errors="none" side_effects="none"]
33
+ // CIL[DOC-MICRO logic_summary="Describe a table and its recorded fake query operations." notable_decisions=""]
34
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
35
+ // CIL[DOC-STABILITY stability="experimental"]
36
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
37
+ type DbQuery = { table: string; calls: DbCall[] }
38
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:dbquery:type"]
39
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:dbresult:type" type="type" identity="DbResult" parent="none"]
40
+ // CIL[DOC-MACRO purpose="Describe data and error returned by a fake database responder."]
41
+ // CIL[DOC-RESPONSIBILITY responsibility="Describe data and error returned by a fake database responder."]
42
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
43
+ // CIL[DOC-CONTRACT inputs="none" outputs="DbResult type" errors="none" side_effects="none"]
44
+ // CIL[DOC-MICRO logic_summary="Describe data and error returned by a fake database responder." notable_decisions=""]
45
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
46
+ // CIL[DOC-STABILITY stability="experimental"]
47
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
48
+ type DbResult = { data?: any; error?: any }
49
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:dbresult:type"]
50
+
51
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:supastate:constant" type="constant" identity="supaState" parent="none"]
52
+ // CIL[DOC-MACRO purpose="Initialize hoisted state for fake Supabase clients and query history."]
53
+ // CIL[DOC-RESPONSIBILITY responsibility="Initialize hoisted state for fake Supabase clients and query history."]
54
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
55
+ // CIL[DOC-CONTRACT inputs="none" outputs="Mock state" errors="none" side_effects="Initializes local mock state"]
56
+ // CIL[DOC-MICRO logic_summary="Initialize hoisted state for fake Supabase clients and query history." notable_decisions=""]
57
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
58
+ // CIL[DOC-STABILITY stability="experimental"]
59
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
60
+ const supaState = vi.hoisted(() => ({
61
+ createClientCalls: [] as any[],
62
+ responder: null as null | ((q: DbQuery) => DbResult),
63
+ history: [] as DbQuery[],
64
+ }))
65
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:supastate:constant"]
66
+
67
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:supabase_mock:block" type="block" identity="supabase_mock" parent="none"]
68
+ // CIL[DOC-MACRO purpose="Register a chainable fake Supabase client that records operations without network access."]
69
+ // CIL[DOC-RESPONSIBILITY responsibility="Register a chainable fake Supabase client that records operations without network access."]
70
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
71
+ // CIL[DOC-CONTRACT inputs="supaState" outputs="Vitest module mock" errors="none" side_effects="Registers a mock"]
72
+ // CIL[DOC-MICRO logic_summary="Register a chainable fake Supabase client that records operations without network access." notable_decisions=""]
73
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
74
+ // CIL[DOC-STABILITY stability="experimental"]
75
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
76
+ vi.mock('@supabase/supabase-js', () => ({
77
+ createClient: (...args: any[]) => {
78
+ supaState.createClientCalls.push(args)
79
+ return {
80
+ from(table: string) {
81
+ const calls: DbCall[] = []
82
+ const query: DbQuery = { table, calls }
83
+ const builder: any = {
84
+ select(...a: any[]) { calls.push({ op: 'select', args: a }); return builder },
85
+ upsert(...a: any[]) { calls.push({ op: 'upsert', args: a }); return builder },
86
+ update(...a: any[]) { calls.push({ op: 'update', args: a }); return builder },
87
+ insert(...a: any[]) { calls.push({ op: 'insert', args: a }); return builder },
88
+ eq(...a: any[]) { calls.push({ op: 'eq', args: a }); return builder },
89
+ single() { supaState.history.push(query); return Promise.resolve(supaState.responder!(query)) },
90
+ maybeSingle() { supaState.history.push(query); return Promise.resolve(supaState.responder!(query)) },
91
+ then(res: any, rej: any) {
92
+ supaState.history.push(query)
93
+ return Promise.resolve(supaState.responder!(query)).then(res, rej)
94
+ },
95
+ }
96
+ return builder
97
+ },
98
+ }
99
+ },
100
+ }))
101
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:supabase_mock:block"]
102
+
103
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:pgstate:constant" type="constant" identity="pgState" parent="none"]
104
+ // CIL[DOC-MACRO purpose="Initialize hoisted state for fake migration clients."]
105
+ // CIL[DOC-RESPONSIBILITY responsibility="Initialize hoisted state for fake migration clients."]
106
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
107
+ // CIL[DOC-CONTRACT inputs="none" outputs="Mock state" errors="none" side_effects="Initializes local mock state"]
108
+ // CIL[DOC-MICRO logic_summary="Initialize hoisted state for fake migration clients." notable_decisions=""]
109
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
110
+ // CIL[DOC-STABILITY stability="experimental"]
111
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
112
+ const pgState = vi.hoisted(() => ({
113
+ instances: [] as any[],
114
+ handler: null as null | ((sql: string, params?: any[]) => any),
115
+ }))
116
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:pgstate:constant"]
117
+
118
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:pg_mock:block" type="block" identity="pg_mock" parent="none"]
119
+ // CIL[DOC-MACRO purpose="Register a fake Postgres client used by mocked installer migrations."]
120
+ // CIL[DOC-RESPONSIBILITY responsibility="Register a fake Postgres client used by mocked installer migrations."]
121
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
122
+ // CIL[DOC-CONTRACT inputs="pgState" outputs="Vitest module mock" errors="none" side_effects="Registers a mock"]
123
+ // CIL[DOC-MICRO logic_summary="Register a fake Postgres client used by mocked installer migrations." notable_decisions=""]
124
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
125
+ // CIL[DOC-STABILITY stability="experimental"]
126
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
127
+ vi.mock('pg', () => ({
128
+ default: {
129
+ Client: class {
130
+ config: any
131
+ calls: { sql: string; params?: any[] }[] = []
132
+ ended = false
133
+ constructor(config: any) { this.config = config; pgState.instances.push(this) }
134
+ async connect() {}
135
+ async end() { this.ended = true }
136
+ async query(sql: string, params?: any[]) {
137
+ this.calls.push({ sql, params })
138
+ if (pgState.handler) return pgState.handler(sql, params)
139
+ return { rows: [] }
140
+ }
141
+ },
142
+ },
143
+ }))
144
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:pg_mock:block"]
145
+
146
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:execstate:constant" type="constant" identity="execState" parent="none"]
147
+ // CIL[DOC-MACRO purpose="Hold recorded shell commands for the fake npm executor."]
148
+ // CIL[DOC-RESPONSIBILITY responsibility="Hold recorded shell commands for the fake npm executor."]
149
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
150
+ // CIL[DOC-CONTRACT inputs="none" outputs="Mock command list" errors="none" side_effects="Initializes local mock state"]
151
+ // CIL[DOC-MICRO logic_summary="Hold recorded shell commands for the fake npm executor." notable_decisions=""]
152
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
153
+ // CIL[DOC-STABILITY stability="experimental"]
154
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
155
+ const execState = vi.hoisted(() => ({ calls: [] as string[] }))
156
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:execstate:constant"]
157
+
158
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:exec_mock:block" type="block" identity="exec_mock" parent="none"]
159
+ // CIL[DOC-MACRO purpose="Register a fake command executor so installer tests never run npm."]
160
+ // CIL[DOC-RESPONSIBILITY responsibility="Register a fake command executor so installer tests never run npm."]
161
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
162
+ // CIL[DOC-CONTRACT inputs="execState" outputs="Vitest module mock" errors="none" side_effects="Registers a mock"]
163
+ // CIL[DOC-MICRO logic_summary="Register a fake command executor so installer tests never run npm." notable_decisions=""]
164
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
165
+ // CIL[DOC-STABILITY stability="experimental"]
166
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
167
+ vi.mock('child_process', () => ({
168
+ execSync: (cmd: string) => { execState.calls.push(cmd); return Buffer.from('') },
169
+ }))
170
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:exec_mock:block"]
171
+
172
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:rlstate:constant" type="constant" identity="rlState" parent="none"]
173
+ // CIL[DOC-MACRO purpose="Hold the answer the fake readline prompt returns."]
174
+ // CIL[DOC-RESPONSIBILITY responsibility="Hold the answer the fake readline prompt returns."]
175
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
176
+ // CIL[DOC-CONTRACT inputs="none" outputs="Mock prompt state" errors="none" side_effects="Initializes local mock state"]
177
+ // CIL[DOC-MICRO logic_summary="Hold the answer the fake readline prompt returns." notable_decisions=""]
178
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
179
+ // CIL[DOC-STABILITY stability="experimental"]
180
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
181
+ const rlState = vi.hoisted(() => ({ answer: 'r' }))
182
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:rlstate:constant"]
183
+
184
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:readline_mock:block" type="block" identity="readline_mock" parent="none"]
185
+ // CIL[DOC-MACRO purpose="Register a fake prompt that returns the rlState answer."]
186
+ // CIL[DOC-RESPONSIBILITY responsibility="Register a fake prompt that returns the rlState answer."]
187
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
188
+ // CIL[DOC-CONTRACT inputs="none" outputs="Vitest module mock" errors="none" side_effects="Registers a mock"]
189
+ // CIL[DOC-MICRO logic_summary="Register a fake prompt that answers with rlState.answer — 'r' (Replace) by default; tests may set 'u' or 'k' for owned-type reconcile prompts." notable_decisions=""]
190
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
191
+ // CIL[DOC-STABILITY stability="experimental"]
192
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
193
+ vi.mock('readline', () => ({
194
+ default: {
195
+ createInterface: () => ({
196
+ question: (_q: string, cb: (answer: string) => void) => cb(rlState.answer),
197
+ close: () => {},
198
+ }),
199
+ },
200
+ createInterface: () => ({
201
+ question: (_q: string, cb: (answer: string) => void) => cb(rlState.answer),
202
+ close: () => {},
203
+ }),
204
+ }))
205
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:readline_mock:block"]
206
+
207
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:orig_cwd:constant" type="constant" identity="ORIG_CWD" parent="none"]
208
+ // CIL[DOC-MACRO purpose="Capture cwd for restoring fixture isolation."]
209
+ // CIL[DOC-RESPONSIBILITY responsibility="Capture cwd for restoring fixture isolation."]
210
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
211
+ // CIL[DOC-CONTRACT inputs="none" outputs="Original cwd" errors="none" side_effects="Reads process cwd"]
212
+ // CIL[DOC-MICRO logic_summary="Capture cwd for restoring fixture isolation." notable_decisions=""]
213
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
214
+ // CIL[DOC-STABILITY stability="experimental"]
215
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
216
+ const ORIG_CWD = process.cwd()
217
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:orig_cwd:constant"]
218
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:env_keys:constant" type="constant" identity="ENV_KEYS" parent="none"]
219
+ // CIL[DOC-MACRO purpose="List environment values saved and restored by installer fixtures."]
220
+ // CIL[DOC-RESPONSIBILITY responsibility="List environment values saved and restored by installer fixtures."]
221
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
222
+ // CIL[DOC-CONTRACT inputs="none" outputs="Readonly key list" errors="none" side_effects="none"]
223
+ // CIL[DOC-MICRO logic_summary="List environment values saved and restored by installer fixtures." notable_decisions=""]
224
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
225
+ // CIL[DOC-STABILITY stability="experimental"]
226
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
227
+ const ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_DB_PASSWORD'] as const
228
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:env_keys:constant"]
229
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:savedenv:variable" type="variable" identity="savedEnv" parent="none"]
230
+ // CIL[DOC-MACRO purpose="Hold original environment values for restoration."]
231
+ // CIL[DOC-RESPONSIBILITY responsibility="Hold original environment values for restoration."]
232
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
233
+ // CIL[DOC-CONTRACT inputs="none" outputs="Mutable saved environment map" errors="none" side_effects="Initializes local state"]
234
+ // CIL[DOC-MICRO logic_summary="Hold original environment values for restoration." notable_decisions=""]
235
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
236
+ // CIL[DOC-STABILITY stability="experimental"]
237
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
238
+ let savedEnv: Record<string, string | undefined> = {}
239
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:savedenv:variable"]
240
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:workdir:variable" type="variable" identity="workdir" parent="none"]
241
+ // CIL[DOC-MACRO purpose="Hold the temporary installer project path."]
242
+ // CIL[DOC-RESPONSIBILITY responsibility="Hold the temporary installer project path."]
243
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
244
+ // CIL[DOC-CONTRACT inputs="none" outputs="Mutable fixture path" errors="none" side_effects="Initializes local state"]
245
+ // CIL[DOC-MICRO logic_summary="Hold the temporary installer project path." notable_decisions=""]
246
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
247
+ // CIL[DOC-STABILITY stability="experimental"]
248
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
249
+ let workdir = ''
250
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:workdir:variable"]
251
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:exitspy:variable" type="variable" identity="exitSpy" parent="none"]
252
+ // CIL[DOC-MACRO purpose="Hold the process-exit spy used by CLI failure tests."]
253
+ // CIL[DOC-RESPONSIBILITY responsibility="Hold the process-exit spy used by CLI failure tests."]
254
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
255
+ // CIL[DOC-CONTRACT inputs="none" outputs="Spy reference" errors="none" side_effects="Declares local state"]
256
+ // CIL[DOC-MICRO logic_summary="Hold the process-exit spy used by CLI failure tests." notable_decisions=""]
257
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
258
+ // CIL[DOC-STABILITY stability="experimental"]
259
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
260
+ let exitSpy: any
261
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:exitspy:variable"]
262
+
263
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:writejson:function" type="function" identity="writeJson" parent="none"]
264
+ // CIL[DOC-MACRO purpose="Create parent fixture directories and write a JSON fixture."]
265
+ // CIL[DOC-RESPONSIBILITY responsibility="Create parent fixture directories and write a JSON fixture."]
266
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
267
+ // CIL[DOC-CONTRACT inputs="path: string, value: any" outputs="void" errors="none" side_effects="Writes temporary fixture files"]
268
+ // CIL[DOC-MICRO logic_summary="Create parent fixture directories and write a JSON fixture." notable_decisions=""]
269
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
270
+ // CIL[DOC-STABILITY stability="experimental"]
271
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
272
+ function writeJson(path: string, value: any) {
273
+ mkdirSync(join(path, '..'), { recursive: true })
274
+ writeFileSync(path, JSON.stringify(value, null, 2))
275
+ }
276
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:writejson:function"]
277
+
278
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:basemanifest:function" type="function" identity="baseManifest" parent="none"]
279
+ // CIL[DOC-MACRO purpose="Build a minimal data-app manifest with supplied overrides."]
280
+ // CIL[DOC-RESPONSIBILITY responsibility="Build a minimal data-app manifest with supplied overrides."]
281
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
282
+ // CIL[DOC-CONTRACT inputs="overrides: Record<string, any> default empty object" outputs="Manifest fixture" errors="none" side_effects="none"]
283
+ // CIL[DOC-MICRO logic_summary="Build a minimal data-app manifest with supplied overrides." notable_decisions=""]
284
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
285
+ // CIL[DOC-STABILITY stability="experimental"]
286
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
287
+ function baseManifest(overrides: Record<string, any> = {}) {
288
+ return {
289
+ name: 'Test App',
290
+ slug: 'testapp',
291
+ version: '1.0.0',
292
+ app_type: 'data',
293
+ ...overrides,
294
+ }
295
+ }
296
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:basemanifest:function"]
297
+
298
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:manifestwithregistration:function" type="function" identity="manifestWithRegistration" parent="none"]
299
+ // CIL[DOC-MACRO purpose="Build an enabled new-account registration fixture with optional overrides."]
300
+ // CIL[DOC-RESPONSIBILITY responsibility="Build an enabled new-account registration fixture with optional overrides."]
301
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
302
+ // CIL[DOC-CONTRACT inputs="overrides: Record<string, any> default empty object" outputs="Manifest fixture" errors="none" side_effects="none"]
303
+ // CIL[DOC-MICRO logic_summary="Build an enabled new-account registration fixture with optional overrides." notable_decisions=""]
304
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
305
+ // CIL[DOC-STABILITY stability="experimental"]
306
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
307
+ function manifestWithRegistration(overrides: Record<string, any> = {}) {
308
+ return baseManifest({
309
+ registration: {
310
+ enabled: true,
311
+ default_role: 'member',
312
+ redirect_path: '/testapp',
313
+ account_strategy: 'new',
314
+ },
315
+ required_roles: ['member', 'admin'],
316
+ ...overrides,
317
+ })
318
+ }
319
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:manifestwithregistration:function"]
320
+
321
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:makeapp:function" type="function" identity="makeApp" parent="none"]
322
+ // CIL[DOC-MACRO purpose="Write an isolated app, package, and optional seed fixtures."]
323
+ // CIL[DOC-RESPONSIBILITY responsibility="Write an isolated app, package, and optional seed fixtures."]
324
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
325
+ // CIL[DOC-CONTRACT inputs="dir: string, manifest: Record<string, any>, files: Record<string, string> default empty object" outputs="App directory path" errors="none" side_effects="Creates temporary fixtures"]
326
+ // CIL[DOC-MICRO logic_summary="Write an isolated app, package, and optional seed fixtures." notable_decisions=""]
327
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
328
+ // CIL[DOC-STABILITY stability="experimental"]
329
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
330
+ function makeApp(dir: string, manifest: Record<string, any>, files: Record<string, string> = {}) {
331
+ mkdirSync(dir, { recursive: true })
332
+ writeJson(join(dir, 'manifest.json'), manifest)
333
+ writeJson(join(dir, 'package.json'), { name: 'testapp', version: manifest.version })
334
+ for (const [rel, content] of Object.entries(files)) {
335
+ const p = join(dir, rel)
336
+ mkdirSync(join(p, '..'), { recursive: true })
337
+ writeFileSync(p, content)
338
+ }
339
+ return dir
340
+ }
341
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:makeapp:function"]
342
+
343
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:freshmodule:function" type="function" identity="freshModule" parent="none"]
344
+ // CIL[DOC-MACRO purpose="Reset module cache and import installer code against fixture cwd."]
345
+ // CIL[DOC-RESPONSIBILITY responsibility="Reset module cache and import installer code against fixture cwd."]
346
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
347
+ // CIL[DOC-CONTRACT inputs="none" outputs="Promise of installer module" errors="none" side_effects="Resets module cache and imports source"]
348
+ // CIL[DOC-MICRO logic_summary="Reset module cache and import installer code against fixture cwd." notable_decisions=""]
349
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
350
+ // CIL[DOC-STABILITY stability="experimental"]
351
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
352
+ async function freshModule() {
353
+ vi.resetModules()
354
+ return await import('../../cli/commands/install-app.ts')
355
+ }
356
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:freshmodule:function"]
357
+
358
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:setseedenv:function" type="function" identity="setSeedEnv" parent="none"]
359
+ // CIL[DOC-MACRO purpose="Set fake backend Supabase values for isolated seeding tests."]
360
+ // CIL[DOC-RESPONSIBILITY responsibility="Set fake backend Supabase values for isolated seeding tests."]
361
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
362
+ // CIL[DOC-CONTRACT inputs="none" outputs="void" errors="none" side_effects="Sets fixture environment"]
363
+ // CIL[DOC-MICRO logic_summary="Set fake backend Supabase values for isolated seeding tests." notable_decisions=""]
364
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
365
+ // CIL[DOC-STABILITY stability="experimental"]
366
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
367
+ function setSeedEnv() {
368
+ process.env.SUPABASE_URL = 'https://abc123.supabase.co'
369
+ process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
370
+ }
371
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:setseedenv:function"]
372
+
373
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:setmigrateenv:function" type="function" identity="setMigrateEnv" parent="none"]
374
+ // CIL[DOC-MACRO purpose="Set fake seed and Postgres values for installer tests."]
375
+ // CIL[DOC-RESPONSIBILITY responsibility="Set fake seed and Postgres values for installer tests."]
376
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
377
+ // CIL[DOC-CONTRACT inputs="none" outputs="void" errors="none" side_effects="Sets fixture environment"]
378
+ // CIL[DOC-MICRO logic_summary="Set fake seed and Postgres values for installer tests." notable_decisions=""]
379
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
380
+ // CIL[DOC-STABILITY stability="experimental"]
381
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
382
+ function setMigrateEnv() {
383
+ setSeedEnv()
384
+ process.env.SUPABASE_DB_PASSWORD = 'dbpass'
385
+ }
386
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:setmigrateenv:function"]
387
+
388
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:dbresponder:function" type="function" identity="dbResponder" parent="none"]
389
+ // CIL[DOC-MACRO purpose="Install the fake query responder for a test."]
390
+ // CIL[DOC-RESPONSIBILITY responsibility="Install the fake query responder for a test."]
391
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
392
+ // CIL[DOC-CONTRACT inputs="handler: (q: DbQuery) => DbResult" outputs="void" errors="none" side_effects="Mutates mock state"]
393
+ // CIL[DOC-MICRO logic_summary="Install the fake query responder for a test." notable_decisions=""]
394
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
395
+ // CIL[DOC-STABILITY stability="experimental"]
396
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
397
+ function dbResponder(handler: (q: DbQuery) => DbResult) {
398
+ supaState.responder = handler
399
+ }
400
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:dbresponder:function"]
401
+
402
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:defaultresponder:function" type="function" identity="defaultResponder" parent="none"]
403
+ // CIL[DOC-MACRO purpose="Return standard fake app reads, app IDs, and success results."]
404
+ // CIL[DOC-RESPONSIBILITY responsibility="Return standard fake app reads, app IDs, and success results."]
405
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
406
+ // CIL[DOC-CONTRACT inputs="q: DbQuery" outputs="DbResult" errors="none" side_effects="none"]
407
+ // CIL[DOC-MICRO logic_summary="Return standard fake app reads, app IDs, and success results." notable_decisions=""]
408
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
409
+ // CIL[DOC-STABILITY stability="experimental"]
410
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
411
+ function defaultResponder(q: DbQuery): DbResult {
412
+ const first = q.calls[0]?.op
413
+ if (q.table === 'apps' && first === 'select') return { data: null, error: null }
414
+ if (first === 'upsert' && q.table === 'apps') return { data: { id: 'app-1' }, error: null }
415
+ return { data: null, error: null }
416
+ }
417
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:defaultresponder:function"]
418
+
419
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:queriesto:function" type="function" identity="queriesTo" parent="none"]
420
+ // CIL[DOC-MACRO purpose="Select recorded mock queries by table and optional operation."]
421
+ // CIL[DOC-RESPONSIBILITY responsibility="Select recorded mock queries by table and optional operation."]
422
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
423
+ // CIL[DOC-CONTRACT inputs="table: string, op?: string" outputs="DbQuery array" errors="none" side_effects="Reads mock history"]
424
+ // CIL[DOC-MICRO logic_summary="Select recorded mock queries by table and optional operation." notable_decisions=""]
425
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
426
+ // CIL[DOC-STABILITY stability="experimental"]
427
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
428
+ function queriesTo(table: string, op?: string) {
429
+ return supaState.history.filter(q => q.table === table && (!op || q.calls.some(c => c.op === op)))
430
+ }
431
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:queriesto:function"]
432
+
433
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:makeprogram:function" type="function" identity="makeProgram" parent="none"]
434
+ // CIL[DOC-MACRO purpose="Create an in-memory Commander program with installer commands."]
435
+ // CIL[DOC-RESPONSIBILITY responsibility="Create an in-memory Commander program with installer commands."]
436
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
437
+ // CIL[DOC-CONTRACT inputs="none" outputs="Promise of Command" errors="none" side_effects="Resets module cache and registers commands"]
438
+ // CIL[DOC-MICRO logic_summary="Create an in-memory Commander program with installer commands." notable_decisions=""]
439
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
440
+ // CIL[DOC-STABILITY stability="experimental"]
441
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
442
+ async function makeProgram() {
443
+ const mod = await freshModule()
444
+ const program = new Command()
445
+ program.exitOverride()
446
+ mod.registerInstallAppCommands(program)
447
+ return program
448
+ }
449
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:makeprogram:function"]
450
+
451
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:writespineconfig:function" type="function" identity="writeSpineConfig" parent="none"]
452
+ // CIL[DOC-MACRO purpose="Write a fixture configuration as JSON or raw malformed text."]
453
+ // CIL[DOC-RESPONSIBILITY responsibility="Write a fixture configuration as JSON or raw malformed text."]
454
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
455
+ // CIL[DOC-CONTRACT inputs="content: any" outputs="void" errors="none" side_effects="Writes fixture config"]
456
+ // CIL[DOC-MICRO logic_summary="Write a fixture configuration as JSON or raw malformed text." notable_decisions=""]
457
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
458
+ // CIL[DOC-STABILITY stability="experimental"]
459
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
460
+ function writeSpineConfig(content: any) {
461
+ writeFileSync(join(workdir, 'spine.config.json'), typeof content === 'string' ? content : JSON.stringify(content))
462
+ }
463
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:writespineconfig:function"]
464
+
465
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:readspineconfig:function" type="function" identity="readSpineConfig" parent="none"]
466
+ // CIL[DOC-MACRO purpose="Read and parse a fixture configuration."]
467
+ // CIL[DOC-RESPONSIBILITY responsibility="Read and parse a fixture configuration."]
468
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
469
+ // CIL[DOC-CONTRACT inputs="path: string default fixture spine.config.json" outputs="Parsed config" errors="none" side_effects="Reads fixture config"]
470
+ // CIL[DOC-MICRO logic_summary="Read and parse a fixture configuration." notable_decisions=""]
471
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
472
+ // CIL[DOC-STABILITY stability="experimental"]
473
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
474
+ function readSpineConfig(path = join(workdir, 'spine.config.json')) {
475
+ return JSON.parse(readFileSync(path, 'utf8'))
476
+ }
477
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:readspineconfig:function"]
478
+
479
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:fixture_setup:block" type="block" identity="fixture_setup" parent="none"]
480
+ // CIL[DOC-MACRO purpose="Register isolated cwd, environment, client, process-exit, and console setup."]
481
+ // CIL[DOC-RESPONSIBILITY responsibility="Register isolated cwd, environment, client, process-exit, and console setup."]
482
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
483
+ // CIL[DOC-CONTRACT inputs="Vitest lifecycle" outputs="Fixture hook" errors="none" side_effects="Registers setup that creates a temp root, changes cwd, and installs mocks"]
484
+ // CIL[DOC-MICRO logic_summary="Register isolated cwd, environment, client, process-exit, and console setup." notable_decisions=""]
485
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
486
+ // CIL[DOC-STABILITY stability="experimental"]
487
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
488
+ beforeEach(() => {
489
+ savedEnv = {}
490
+ for (const k of ENV_KEYS) savedEnv[k] = process.env[k]
491
+ workdir = mkdtempSync(join(tmpdir(), 'spine-install-test-'))
492
+ process.chdir(workdir)
493
+ supaState.createClientCalls = []
494
+ supaState.history = []
495
+ supaState.responder = defaultResponder
496
+ pgState.instances = []
497
+ pgState.handler = null
498
+ execState.calls = []
499
+ rlState.answer = 'r'
500
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
501
+ throw new Error(`process.exit(${code})`)
502
+ }) as any)
503
+ vi.spyOn(console, 'log').mockImplementation(() => {})
504
+ vi.spyOn(console, 'error').mockImplementation(() => {})
505
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
506
+ vi.spyOn(process.stdout, 'write').mockImplementation((() => true) as any)
507
+ })
508
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:fixture_setup:block"]
509
+
510
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:fixture_cleanup:block" type="block" identity="fixture_cleanup" parent="none"]
511
+ // CIL[DOC-MACRO purpose="Register fixture removal and cwd, environment, and spy restoration."]
512
+ // CIL[DOC-RESPONSIBILITY responsibility="Register fixture removal and cwd, environment, and spy restoration."]
513
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
514
+ // CIL[DOC-CONTRACT inputs="Fixture state" outputs="Cleanup hook" errors="none" side_effects="Registers fixture cleanup"]
515
+ // CIL[DOC-MICRO logic_summary="Register fixture removal and cwd, environment, and spy restoration." notable_decisions=""]
516
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
517
+ // CIL[DOC-STABILITY stability="experimental"]
518
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
519
+ afterEach(() => {
520
+ process.chdir(ORIG_CWD)
521
+ rmSync(workdir, { recursive: true, force: true })
522
+ for (const k of ENV_KEYS) {
523
+ if (savedEnv[k] === undefined) delete process.env[k]
524
+ else process.env[k] = savedEnv[k]
525
+ }
526
+ vi.restoreAllMocks()
527
+ })
528
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:fixture_cleanup:block"]
529
+
530
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:seed_preflight_suite:block" type="block" identity="seed_preflight_suite" parent="none"]
531
+ // CIL[DOC-MACRO purpose="Register missing credentials, mandatory seed, malformed fallback, path escape, and slug tests."]
532
+ // CIL[DOC-RESPONSIBILITY responsibility="Register missing credentials, mandatory seed, malformed fallback, path escape, and slug tests."]
533
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
534
+ // CIL[DOC-CONTRACT inputs="Fixture helpers and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers fixture tests with fake clients"]
535
+ // CIL[DOC-MICRO logic_summary="Register missing credentials, mandatory seed, malformed fallback, path escape, and slug tests." notable_decisions=""]
536
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
537
+ // CIL[DOC-STABILITY stability="experimental"]
538
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
539
+ describe('seedApp preflight', () => {
540
+ it('throws when Supabase env is missing without creating a client', async () => {
541
+ const { seedApp } = await freshModule()
542
+ delete process.env.SUPABASE_URL
543
+ delete process.env.SUPABASE_SERVICE_ROLE_KEY
544
+ const appDir = makeApp(join(workdir, 'app'), baseManifest())
545
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'spine.config.json'))).rejects.toThrow(/SUPABASE_URL/)
546
+ expect(supaState.createClientCalls.length).toBe(0)
547
+ })
548
+
549
+ it('rejects a missing declared seed file before creating a client', async () => {
550
+ const { seedApp } = await freshModule()
551
+ setSeedEnv()
552
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
553
+ seed: [{ file: 'seed/missing.json', table: 'roles', conflict: 'slug' }],
554
+ }))
555
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
556
+ expect(supaState.createClientCalls.length).toBe(0)
557
+ })
558
+
559
+ it('rejects a seed path escaping the app root before creating a client', async () => {
560
+ const { seedApp } = await freshModule()
561
+ setSeedEnv()
562
+ writeJson(join(workdir, 'outside.json'), [])
563
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
564
+ seed: [{ file: '../outside.json', table: 'roles', conflict: 'slug' }],
565
+ }))
566
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
567
+ expect(supaState.createClientCalls.length).toBe(0)
568
+ })
569
+
570
+ it('rejects a malformed declared seed file before creating a client', async () => {
571
+ const { seedApp } = await freshModule()
572
+ setSeedEnv()
573
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
574
+ seed: [{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' }],
575
+ }), { 'seed/roles.json': JSON.stringify([null]) })
576
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
577
+ expect(supaState.createClientCalls.length).toBe(0)
578
+ })
579
+
580
+ it('rejects a malformed optional fallback seed file before creating a client', async () => {
581
+ const { seedApp } = await freshModule()
582
+ setSeedEnv()
583
+ const appDir = makeApp(join(workdir, 'app'), baseManifest(), {
584
+ 'seed/roles.json': JSON.stringify({ not: 'an array' }),
585
+ })
586
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
587
+ expect(supaState.createClientCalls.length).toBe(0)
588
+ })
589
+
590
+ it('throws on manifest slug mismatch before creating a client', async () => {
591
+ const { seedApp } = await freshModule()
592
+ setSeedEnv()
593
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({ slug: 'other-app' }))
594
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/slug/i)
595
+ expect(supaState.createClientCalls.length).toBe(0)
596
+ })
597
+ })
598
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:seed_preflight_suite:block"]
599
+
600
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:config_preflight_suite:block" type="block" identity="config_preflight_suite" parent="none"]
601
+ // CIL[DOC-MACRO purpose="Register config shape, slug grammar, enabled policy, and zero-client preflight tests."]
602
+ // CIL[DOC-RESPONSIBILITY responsibility="Register config shape, slug grammar, enabled policy, and zero-client preflight tests."]
603
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
604
+ // CIL[DOC-CONTRACT inputs="Fixture helpers and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers fake-client tests"]
605
+ // CIL[DOC-MICRO logic_summary="Register config shape, slug grammar, enabled policy, and zero-client preflight tests." notable_decisions=""]
606
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
607
+ // CIL[DOC-STABILITY stability="experimental"]
608
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
609
+ describe('seedApp config and manifest preflight', () => {
610
+ it.each([
611
+ '{ not json',
612
+ 'null',
613
+ '[]',
614
+ '"text"',
615
+ '42',
616
+ JSON.stringify({ registration: 'x' }),
617
+ JSON.stringify({ registration: [] }),
618
+ JSON.stringify({ registration: null }),
619
+ JSON.stringify({ registration: { apps: 'x' } }),
620
+ JSON.stringify({ registration: { apps: [] } }),
621
+ JSON.stringify({ registration: { apps: null } }),
622
+ ])('rejects malformed local config %s before creating a client', async (content) => {
623
+ const { seedApp } = await freshModule()
624
+ setSeedEnv()
625
+ const configPath = join(workdir, 'spine.config.json')
626
+ writeSpineConfig(content)
627
+ const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration())
628
+ await expect(seedApp(appDir, 'testapp', configPath)).rejects.toThrow()
629
+ expect(supaState.createClientCalls.length).toBe(0)
630
+ })
631
+
632
+ it('never reads the local config when the manifest has no registration block', async () => {
633
+ const { seedApp } = await freshModule()
634
+ setSeedEnv()
635
+ const configPath = join(workdir, 'spine.config.json')
636
+ writeSpineConfig('{ not json')
637
+ const appDir = makeApp(join(workdir, 'app'), baseManifest())
638
+ await seedApp(appDir, 'testapp', configPath)
639
+ expect(supaState.createClientCalls.length).toBe(1)
640
+ expect(readFileSync(configPath, 'utf8')).toBe('{ not json')
641
+ })
642
+
643
+ it.each(['"plain"', '[1,2]', '5'])('rejects a non-object manifest.config %s before creating a client', async (cfg) => {
644
+ const { seedApp } = await freshModule()
645
+ setSeedEnv()
646
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({ config: JSON.parse(cfg) }))
647
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
648
+ expect(supaState.createClientCalls.length).toBe(0)
649
+ })
650
+
651
+ it('treats manifest.config null as absent', async () => {
652
+ const { seedApp } = await freshModule()
653
+ setSeedEnv()
654
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({ config: null }))
655
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
656
+ const upsert = queriesTo('apps', 'upsert')[0].calls.find(c => c.op === 'upsert')!.args[0]
657
+ expect(upsert.config).toBeUndefined()
658
+ })
659
+
660
+ it('rejects a malformed existing apps.config before the app upsert', async () => {
661
+ const { seedApp } = await freshModule()
662
+ setSeedEnv()
663
+ dbResponder(q => {
664
+ if (q.table === 'apps' && q.calls[0]?.op === 'select') return { data: { config: 'broken' }, error: null }
665
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
666
+ return { data: null, error: null }
667
+ })
668
+ const appDir = makeApp(join(workdir, 'app'), baseManifest())
669
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
670
+ expect(queriesTo('apps', 'upsert').length).toBe(0)
671
+ })
672
+
673
+ it.each(['__proto__', 'Bad_Slug', 'UPPER', '-lead', 'has space', 'test_app', '9start'])(
674
+ 'rejects invalid app slug %s before validation and client creation',
675
+ async (slug) => {
676
+ const { seedApp } = await freshModule()
677
+ setSeedEnv()
678
+ const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration())
679
+ await expect(seedApp(appDir, slug as any, join(workdir, 'c.json'))).rejects.toThrow(/slug/i)
680
+ expect(supaState.createClientCalls.length).toBe(0)
681
+ },
682
+ )
683
+
684
+ it('rejects a non-string app slug before creating a client', async () => {
685
+ const { seedApp } = await freshModule()
686
+ setSeedEnv()
687
+ const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration())
688
+ await expect(seedApp(appDir, 42 as any, join(workdir, 'c.json'))).rejects.toThrow()
689
+ expect(supaState.createClientCalls.length).toBe(0)
690
+ })
691
+
692
+ it.each([{ enabled: 'yes' }, { enabled: 1 }, { enabled: {} }])(
693
+ 'rejects non-boolean registration.enabled %j before creating a client',
694
+ async (registration) => {
695
+ const { seedApp } = await freshModule()
696
+ setSeedEnv()
697
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
698
+ registration: { ...registration, default_role: 'member', redirect_path: '/x' },
699
+ }))
700
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
701
+ expect(supaState.createClientCalls.length).toBe(0)
702
+ },
703
+ )
704
+
705
+ it.each([
706
+ { enabled: true, redirect_path: '/x' },
707
+ { enabled: true, default_role: 'member' },
708
+ { enabled: true, default_role: ' ', redirect_path: '/x' },
709
+ { enabled: true, default_role: 'member', redirect_path: '' },
710
+ ])('requires nonempty default_role and redirect_path when enabled %j before creating a client', async (registration) => {
711
+ const { seedApp } = await freshModule()
712
+ setSeedEnv()
713
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({ registration }))
714
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
715
+ expect(supaState.createClientCalls.length).toBe(0)
716
+ })
717
+ })
718
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:config_preflight_suite:block"]
719
+
720
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:permissions_preflight_suite:block" type="block" identity="permissions_preflight_suite" parent="none"]
721
+ // CIL[DOC-MACRO purpose="Register valid empty grants and malformed permissions-patch preflight tests."]
722
+ // CIL[DOC-RESPONSIBILITY responsibility="Register valid empty grants and malformed permissions-patch preflight tests."]
723
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
724
+ // CIL[DOC-CONTRACT inputs="Fixture helpers, seedApp, records: parameterized permission-patch fixture rows" outputs="Vitest assertions" errors="none" side_effects="Registers fake-client tests"]
725
+ // CIL[DOC-MICRO logic_summary="Register valid empty grants and malformed permissions-patch preflight tests." notable_decisions=""]
726
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
727
+ // CIL[DOC-STABILITY stability="experimental"]
728
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
729
+ describe('permissions patch preflight', () => {
730
+ const patchEntry = {
731
+ file: 'seed/type_permissions.json',
732
+ table: 'types',
733
+ conflict: 'kind,slug',
734
+ inject_app_id: false,
735
+ permissions_patch: true,
736
+ }
737
+
738
+ function patchApp(records: any[]) {
739
+ return makeApp(join(workdir, 'app'), baseManifest({ seed: [patchEntry] }), {
740
+ 'seed/type_permissions.json': JSON.stringify(records),
741
+ })
742
+ }
743
+
744
+ it.each<{ records: any[] }>([
745
+ { records: [{ slug: 'task', record_permissions: { admin: ['read'] } }] },
746
+ { records: [{ kind: '', slug: 'task', record_permissions: {} }] },
747
+ { records: [{ kind: 'item', slug: ' ', record_permissions: {} }] },
748
+ { records: [{ kind: 'item', slug: 'task' }] },
749
+ { records: [{ kind: 'item', slug: 'task', record_permissions: null }] },
750
+ { records: [{ kind: 'item', slug: 'task', record_permissions: ['read'] }] },
751
+ { records: [{ kind: 'item', slug: 'task', record_permissions: 'x' }] },
752
+ { records: [{ kind: 'item', slug: 'task', record_permissions: { admin: 'read' } }] },
753
+ { records: [{ kind: 'item', slug: 'task', record_permissions: { admin: ['read', 'drop'] } }] },
754
+ ])('rejects malformed patch row %# before creating a client', async ({ records }) => {
755
+ const { seedApp } = await freshModule()
756
+ setSeedEnv()
757
+ await expect(seedApp(patchApp(records), 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
758
+ expect(supaState.createClientCalls.length).toBe(0)
759
+ })
760
+
761
+ it('accepts an empty record_permissions map and treats it as an explicit no-grant', async () => {
762
+ const { seedApp } = await freshModule()
763
+ setSeedEnv()
764
+ dbResponder(q => {
765
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
766
+ if (q.table === 'types' && q.calls[0]?.op === 'select') {
767
+ return { data: { id: 't-1', design_schema: { record_permissions: {} } }, error: null }
768
+ }
769
+ return { data: null, error: null }
770
+ })
771
+ await seedApp(patchApp([{ kind: 'item', slug: 'task', record_permissions: {} }]), 'testapp', join(workdir, 'c.json'))
772
+ expect(supaState.createClientCalls.length).toBe(1)
773
+ expect(queriesTo('types', 'update').length).toBe(0)
774
+ })
775
+ })
776
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:permissions_preflight_suite:block"]
777
+
778
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:seed_writes_suite:block" type="block" identity="seed_writes_suite" parent="none"]
779
+ // CIL[DOC-MACRO purpose="Register seed write failures, pipeline resolution, generated validation, and permission-patch tests."]
780
+ // CIL[DOC-RESPONSIBILITY responsibility="Register seed write failures, pipeline resolution, generated validation, and permission-patch tests."]
781
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
782
+ // CIL[DOC-CONTRACT inputs="Mock responders and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
783
+ // CIL[DOC-MICRO logic_summary="Register seed write failures, pipeline resolution, generated validation, and permission-patch tests." notable_decisions=""]
784
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
785
+ // CIL[DOC-STABILITY stability="experimental"]
786
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
787
+ describe('seedApp fail-closed writes', () => {
788
+ it('seeds an empty declared seed array successfully', async () => {
789
+ const { seedApp } = await freshModule()
790
+ setSeedEnv()
791
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
792
+ seed: [{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' }],
793
+ }), { 'seed/roles.json': '[]' })
794
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
795
+ expect(supaState.createClientCalls.length).toBe(1)
796
+ expect(queriesTo('apps', 'upsert').length).toBe(1)
797
+ expect(queriesTo('roles').length).toBe(0)
798
+ })
799
+
800
+ it('throws when the app upsert errors', async () => {
801
+ const { seedApp } = await freshModule()
802
+ setSeedEnv()
803
+ dbResponder(q => {
804
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: null, error: { message: 'upsert blew up' } }
805
+ return { data: null, error: null }
806
+ })
807
+ const appDir = makeApp(join(workdir, 'app'), baseManifest())
808
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow('upsert blew up')
809
+ expect(queriesTo('roles').length).toBe(0)
810
+ })
811
+
812
+ it('throws when the app upsert returns no id', async () => {
813
+ const { seedApp } = await freshModule()
814
+ setSeedEnv()
815
+ dbResponder(q => {
816
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: null, error: null }
817
+ return { data: null, error: null }
818
+ })
819
+ const appDir = makeApp(join(workdir, 'app'), baseManifest())
820
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/no id/i)
821
+ })
822
+
823
+ it('throws on an ordinary seed row upsert failure', async () => {
824
+ const { seedApp } = await freshModule()
825
+ setSeedEnv()
826
+ dbResponder(q => {
827
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
828
+ if (q.table === 'roles' && q.calls[0]?.op === 'upsert') return { data: null, error: { message: 'row conflict' } }
829
+ return { data: null, error: null }
830
+ })
831
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
832
+ seed: [{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' }],
833
+ }), { 'seed/roles.json': JSON.stringify([{ slug: 'member', name: 'Member' }]) })
834
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow('row conflict')
835
+ })
836
+
837
+ it('throws when a trigger seed references a missing pipeline', async () => {
838
+ const { seedApp } = await freshModule()
839
+ setSeedEnv()
840
+ dbResponder(q => {
841
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
842
+ if (q.table === 'pipelines') return { data: null, error: null }
843
+ return { data: null, error: null }
844
+ })
845
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
846
+ seed: [{ file: 'seed/triggers.json', table: 'triggers', conflict: 'slug' }],
847
+ }), { 'seed/triggers.json': JSON.stringify([{ slug: 't1', pipeline_slug: 'missing-pipe' }]) })
848
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/pipeline/i)
849
+ })
850
+
851
+ it('generates validation_schema for types rows and keeps insert-only conflict contract', async () => {
852
+ const { seedApp } = await freshModule()
853
+ setSeedEnv()
854
+ dbResponder(q => {
855
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
856
+ return { data: null, error: null }
857
+ })
858
+ const design = { fields: { title: { data_type: 'text', required: true } } }
859
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
860
+ seed: [{ file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }],
861
+ }), { 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: design }]) })
862
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
863
+ const typeUpserts = queriesTo('types', 'upsert')
864
+ expect(typeUpserts.length).toBe(1)
865
+ const [row, opts] = typeUpserts[0].calls.find(c => c.op === 'upsert')!.args
866
+ expect(row.app_id).toBeUndefined()
867
+ expect(row.validation_schema).toEqual({ fields: { title: { data_type: 'text', required: true } } })
868
+ expect(opts).toEqual({ onConflict: 'kind,slug', ignoreDuplicates: true })
869
+ })
870
+
871
+ it('throws when a types row carries a non-object design_schema before creating a client', async () => {
872
+ const { seedApp } = await freshModule()
873
+ setSeedEnv()
874
+ dbResponder(q => {
875
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
876
+ return { data: null, error: null }
877
+ })
878
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
879
+ seed: [{ file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }],
880
+ }), { 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: 'nope' }]) })
881
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/design_schema/)
882
+ expect(supaState.createClientCalls.length).toBe(0)
883
+ expect(queriesTo('apps', 'upsert').length).toBe(0)
884
+ expect(queriesTo('types', 'upsert').length).toBe(0)
885
+ })
886
+
887
+ it('throws when an explicit permissions patch targets a missing type', async () => {
888
+ const { seedApp } = await freshModule()
889
+ setSeedEnv()
890
+ dbResponder(q => {
891
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
892
+ if (q.table === 'types' && q.calls[0]?.op === 'select') return { data: null, error: null }
893
+ return { data: null, error: null }
894
+ })
895
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
896
+ seed: [{ file: 'seed/type_permissions.json', table: 'types', conflict: 'kind,slug', inject_app_id: false, permissions_patch: true }],
897
+ }), { 'seed/type_permissions.json': JSON.stringify([{ kind: 'item', slug: 'ghost', record_permissions: { admin: ['read'] } }]) })
898
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/ghost|not found/i)
899
+ })
900
+
901
+ it('throws on a malformed permissions patch row', async () => {
902
+ const { seedApp } = await freshModule()
903
+ setSeedEnv()
904
+ dbResponder(q => {
905
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
906
+ return { data: null, error: null }
907
+ })
908
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
909
+ seed: [{ file: 'seed/type_permissions.json', table: 'types', conflict: 'kind,slug', inject_app_id: false, permissions_patch: true }],
910
+ }), { 'seed/type_permissions.json': JSON.stringify([{ kind: 'item' }]) })
911
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
912
+ })
913
+
914
+ it('regenerates validation_schema from the merged design schema on patch replace and throws on patch DB failure', async () => {
915
+ const { seedApp } = await freshModule()
916
+ setSeedEnv()
917
+ const existingSchema = { fields: { title: { data_type: 'text', required: true } }, record_permissions: { admin: ['read'] } }
918
+ dbResponder(q => {
919
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
920
+ if (q.table === 'types' && q.calls[0]?.op === 'select') return { data: { id: 't-1', design_schema: existingSchema }, error: null }
921
+ if (q.table === 'types' && q.calls[0]?.op === 'update') return { data: null, error: { message: 'patch write failed' } }
922
+ return { data: null, error: null }
923
+ })
924
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
925
+ seed: [{ file: 'seed/type_permissions.json', table: 'types', conflict: 'kind,slug', inject_app_id: false, permissions_patch: true }],
926
+ }), { 'seed/type_permissions.json': JSON.stringify([{ kind: 'item', slug: 'task', record_permissions: { admin: ['read', 'update'] } }]) })
927
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow('patch write failed')
928
+ const updateCall = queriesTo('types', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
929
+ expect(updateCall.design_schema.record_permissions).toEqual({ admin: ['read', 'update'] })
930
+ expect(updateCall.validation_schema).toEqual({ fields: { title: { data_type: 'text', required: true } } })
931
+ })
932
+ })
933
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:seed_writes_suite:block"]
934
+
935
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:registration_suite:block" type="block" identity="registration_suite" parent="none"]
936
+ // CIL[DOC-MACRO purpose="Register policy parity, config preservation, disabled policy, and error handling tests."]
937
+ // CIL[DOC-RESPONSIBILITY responsibility="Register policy parity, config preservation, disabled policy, and error handling tests."]
938
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
939
+ // CIL[DOC-CONTRACT inputs="Mock responders, fixture config, overrides: local manifest helper options" outputs="Vitest assertions" errors="none" side_effects="Registers fake DB and temporary file tests"]
940
+ // CIL[DOC-MICRO logic_summary="Register policy parity, config preservation, disabled policy, and error handling tests." notable_decisions=""]
941
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
942
+ // CIL[DOC-STABILITY stability="experimental"]
943
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
944
+ describe('registration sync', () => {
945
+ function seededRegManifest(overrides: Record<string, any> = {}) {
946
+ return baseManifest({
947
+ registration: {
948
+ enabled: true,
949
+ default_role: 'member',
950
+ redirect_path: '/testapp',
951
+ account_strategy: 'new',
952
+ },
953
+ required_roles: ['member', 'admin'],
954
+ ...overrides,
955
+ })
956
+ }
957
+
958
+ it('writes server registration policy and the local config file after seeds', async () => {
959
+ const { seedApp } = await freshModule()
960
+ setSeedEnv()
961
+ const configPath = join(workdir, 'spine.config.json')
962
+ const appDir = makeApp(join(workdir, 'app'), seededRegManifest(), { 'seed/roles.json': '[]' })
963
+ await seedApp(appDir, 'testapp', configPath)
964
+ const updates = queriesTo('apps', 'update')
965
+ expect(updates.length).toBe(1)
966
+ const updatePayload = updates[0].calls.find(c => c.op === 'update')!.args[0]
967
+ expect(updatePayload.config.registration).toEqual({
968
+ roles: ['member', 'admin'],
969
+ default_role: 'member',
970
+ path: '/testapp',
971
+ enabled: true,
972
+ account_strategy: 'new',
973
+ target_account: undefined,
974
+ parent_account: undefined,
975
+ default_workspace_name: undefined,
976
+ allow_domain_join: undefined,
977
+ })
978
+ const fileCfg = readSpineConfig(configPath)
979
+ expect(fileCfg.registration.apps.testapp.default_role).toBe('member')
980
+ expect(fileCfg.registration.default_app).toBe('testapp')
981
+ expect(fileCfg.registration.allow_url_override).toBe(true)
982
+ })
983
+
984
+ it('merges existing apps.config instead of erasing unrelated keys', async () => {
985
+ const { seedApp } = await freshModule()
986
+ setSeedEnv()
987
+ dbResponder(q => {
988
+ if (q.table === 'apps' && q.calls[0]?.op === 'select') {
989
+ return { data: { config: { other_key: 'keep', feature_flags: { x: 1 } } }, error: null }
990
+ }
991
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
992
+ return { data: null, error: null }
993
+ })
994
+ const appDir = makeApp(join(workdir, 'app'), seededRegManifest({ config: { manifest_key: true } }))
995
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
996
+ const upsertPayload = queriesTo('apps', 'upsert')[0].calls.find(c => c.op === 'upsert')!.args[0]
997
+ expect(upsertPayload.config).toEqual({ other_key: 'keep', feature_flags: { x: 1 }, manifest_key: true })
998
+ const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
999
+ expect(updatePayload.config.other_key).toBe('keep')
1000
+ expect(updatePayload.config.registration.default_role).toBe('member')
1001
+ })
1002
+
1003
+ it('does not write server registration when the manifest has no registration block', async () => {
1004
+ const { seedApp } = await freshModule()
1005
+ setSeedEnv()
1006
+ const appDir = makeApp(join(workdir, 'app'), baseManifest())
1007
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
1008
+ expect(queriesTo('apps', 'update').length).toBe(0)
1009
+ expect(existsSync(join(workdir, 'c.json'))).toBe(false)
1010
+ })
1011
+
1012
+ it('preserves a preexisting default_app and unrelated config entries on reseed', async () => {
1013
+ const { seedApp } = await freshModule()
1014
+ setSeedEnv()
1015
+ const configPath = join(workdir, 'spine.config.json')
1016
+ writeSpineConfig({
1017
+ registration: {
1018
+ default_app: 'other-app',
1019
+ default_role: 'admin',
1020
+ allow_url_override: false,
1021
+ apps: { 'other-app': { roles: ['admin'], default_role: 'admin', path: '/other', enabled: true, account_strategy: 'new' } },
1022
+ },
1023
+ unrelated_root: 'keep-me',
1024
+ })
1025
+ const appDir = makeApp(join(workdir, 'app'), seededRegManifest())
1026
+ await seedApp(appDir, 'testapp', configPath)
1027
+ const fileCfg = readSpineConfig(configPath)
1028
+ expect(fileCfg.unrelated_root).toBe('keep-me')
1029
+ expect(fileCfg.registration.default_app).toBe('other-app')
1030
+ expect(fileCfg.registration.default_role).toBe('admin')
1031
+ expect(fileCfg.registration.allow_url_override).toBe(false)
1032
+ expect(fileCfg.registration.apps['other-app'].path).toBe('/other')
1033
+ expect(fileCfg.registration.apps.testapp.default_role).toBe('member')
1034
+ })
1035
+
1036
+ it('throws when the server registration policy write fails', async () => {
1037
+ const { seedApp } = await freshModule()
1038
+ setSeedEnv()
1039
+ dbResponder(q => {
1040
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1041
+ if (q.table === 'apps' && q.calls[0]?.op === 'update') return { data: null, error: { message: 'policy write denied' } }
1042
+ return { data: null, error: null }
1043
+ })
1044
+ const appDir = makeApp(join(workdir, 'app'), seededRegManifest())
1045
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow('policy write denied')
1046
+ })
1047
+
1048
+ it('throws when the local spine.config.json is corrupt before creating a client', async () => {
1049
+ const { seedApp } = await freshModule()
1050
+ setSeedEnv()
1051
+ const configPath = join(workdir, 'spine.config.json')
1052
+ writeSpineConfig('{ not json')
1053
+ const appDir = makeApp(join(workdir, 'app'), seededRegManifest())
1054
+ await expect(seedApp(appDir, 'testapp', configPath)).rejects.toThrow()
1055
+ expect(supaState.createClientCalls.length).toBe(0)
1056
+ })
1057
+
1058
+ it('persists an explicit enabled:false policy to server and file with neutral fallback', async () => {
1059
+ const { seedApp } = await freshModule()
1060
+ setSeedEnv()
1061
+ const configPath = join(workdir, 'spine.config.json')
1062
+ const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration({
1063
+ route_prefix: '/testapp',
1064
+ registration: { enabled: false },
1065
+ required_roles: [],
1066
+ }))
1067
+ await seedApp(appDir, 'testapp', configPath)
1068
+ const updates = queriesTo('apps', 'update')
1069
+ expect(updates.length).toBe(1)
1070
+ const updatePayload = updates[0].calls.find(c => c.op === 'update')!.args[0]
1071
+ expect(updatePayload.config.registration).toEqual({
1072
+ roles: ['member'],
1073
+ default_role: 'member',
1074
+ path: '/testapp',
1075
+ enabled: false,
1076
+ account_strategy: 'new',
1077
+ target_account: undefined,
1078
+ parent_account: undefined,
1079
+ default_workspace_name: undefined,
1080
+ allow_domain_join: undefined,
1081
+ })
1082
+ const fileCfg = readSpineConfig(configPath)
1083
+ expect(fileCfg.registration.apps.testapp).toEqual(updatePayload.config.registration)
1084
+ expect(fileCfg.registration.default_app).toBeUndefined()
1085
+ })
1086
+
1087
+ it('preserves previous server and local policy fields when toggling enabled:false', async () => {
1088
+ const { seedApp } = await freshModule()
1089
+ setSeedEnv()
1090
+ const configPath = join(workdir, 'spine.config.json')
1091
+ const prior = {
1092
+ roles: ['admin', 'member'],
1093
+ default_role: 'admin',
1094
+ path: '/prior',
1095
+ enabled: true,
1096
+ account_strategy: 'existing',
1097
+ target_account: 'acme',
1098
+ allow_domain_join: true,
1099
+ }
1100
+ writeSpineConfig({ registration: { default_app: 'testapp', apps: { testapp: prior } } })
1101
+ dbResponder(q => {
1102
+ if (q.table === 'apps' && q.calls[0]?.op === 'select') {
1103
+ return { data: { config: { registration: prior } }, error: null }
1104
+ }
1105
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1106
+ return { data: null, error: null }
1107
+ })
1108
+ const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration({
1109
+ registration: { enabled: false },
1110
+ required_roles: [],
1111
+ }))
1112
+ await seedApp(appDir, 'testapp', configPath)
1113
+ const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
1114
+ expect(updatePayload.config.registration).toEqual({ ...prior, enabled: false })
1115
+ const fileCfg = readSpineConfig(configPath)
1116
+ expect(fileCfg.registration.apps.testapp).toEqual({ ...prior, enabled: false })
1117
+ expect(fileCfg.registration.default_app).toBe('testapp')
1118
+ })
1119
+
1120
+ it('writes one canonical disabled entry to both sides when prior server/local policies diverge', async () => {
1121
+ const { seedApp } = await freshModule()
1122
+ setSeedEnv()
1123
+ const configPath = join(workdir, 'spine.config.json')
1124
+ const localPrior = {
1125
+ roles: ['local-role'],
1126
+ default_role: 'local-role',
1127
+ path: '/local-path',
1128
+ enabled: true,
1129
+ account_strategy: 'existing',
1130
+ target_account: 'local-acct',
1131
+ }
1132
+ const serverPrior = {
1133
+ roles: ['server-role'],
1134
+ default_role: 'server-role',
1135
+ path: '/server-path',
1136
+ enabled: true,
1137
+ account_strategy: 'new',
1138
+ }
1139
+ writeSpineConfig({ registration: { apps: { testapp: localPrior } } })
1140
+ dbResponder(q => {
1141
+ if (q.table === 'apps' && q.calls[0]?.op === 'select') {
1142
+ return { data: { config: { registration: serverPrior } }, error: null }
1143
+ }
1144
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1145
+ return { data: null, error: null }
1146
+ })
1147
+ const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration({
1148
+ registration: { enabled: false },
1149
+ required_roles: [],
1150
+ }))
1151
+ await seedApp(appDir, 'testapp', configPath)
1152
+ const expected = {
1153
+ roles: ['server-role'],
1154
+ default_role: 'server-role',
1155
+ path: '/server-path',
1156
+ enabled: false,
1157
+ account_strategy: 'new',
1158
+ target_account: 'local-acct',
1159
+ parent_account: undefined,
1160
+ default_workspace_name: undefined,
1161
+ allow_domain_join: undefined,
1162
+ }
1163
+ const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
1164
+ expect(updatePayload.config.registration).toEqual(expected)
1165
+ const fileCfg = readSpineConfig(configPath)
1166
+ expect(fileCfg.registration.apps.testapp).toEqual(expected)
1167
+ expect(fileCfg.registration.default_app).toBeUndefined()
1168
+ })
1169
+
1170
+ it('copies allow_domain_join when declared without forcing it on other apps', async () => {
1171
+ const { seedApp } = await freshModule()
1172
+ setSeedEnv()
1173
+ const configPath = join(workdir, 'spine.config.json')
1174
+ writeSpineConfig({
1175
+ registration: {
1176
+ default_app: 'other-app',
1177
+ apps: { 'other-app': { roles: ['admin'], default_role: 'admin', path: '/o', enabled: true, account_strategy: 'new' } },
1178
+ },
1179
+ })
1180
+ const manifest = seededRegManifest()
1181
+ manifest.registration.allow_domain_join = true
1182
+ const appDir = makeApp(join(workdir, 'app'), manifest)
1183
+ await seedApp(appDir, 'testapp', configPath)
1184
+ const fileCfg = readSpineConfig(configPath)
1185
+ expect(fileCfg.registration.apps.testapp.allow_domain_join).toBe(true)
1186
+ expect(fileCfg.registration.apps['other-app'].allow_domain_join).toBeUndefined()
1187
+ })
1188
+ })
1189
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:registration_suite:block"]
1190
+
1191
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:update_app_suite:block" type="block" identity="update_app_suite" parent="none"]
1192
+ // CIL[DOC-MACRO purpose="Register update-db-app policy parity and preflight failure tests."]
1193
+ // CIL[DOC-RESPONSIBILITY responsibility="Register update-db-app policy parity and preflight failure tests."]
1194
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
1195
+ // CIL[DOC-CONTRACT inputs="Commander and fixture helpers" outputs="Vitest assertions" errors="none" side_effects="Registers isolated CLI tests"]
1196
+ // CIL[DOC-MICRO logic_summary="Register update-db-app policy parity and preflight failure tests." notable_decisions=""]
1197
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
1198
+ // CIL[DOC-STABILITY stability="experimental"]
1199
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
1200
+ describe('update-db-app', () => {
1201
+ it('produces the same server registration as a fresh seed', async () => {
1202
+ setSeedEnv()
1203
+ const manifest = baseManifest({
1204
+ registration: { enabled: true, default_role: 'member', redirect_path: '/testapp', account_strategy: 'new' },
1205
+ required_roles: ['member'],
1206
+ })
1207
+ makeApp(join(workdir, 'custom/apps/testapp'), manifest)
1208
+ const program = await makeProgram()
1209
+ await program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])
1210
+ const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
1211
+ expect(updatePayload.config.registration).toEqual({
1212
+ roles: ['member'],
1213
+ default_role: 'member',
1214
+ path: '/testapp',
1215
+ enabled: true,
1216
+ account_strategy: 'new',
1217
+ target_account: undefined,
1218
+ parent_account: undefined,
1219
+ default_workspace_name: undefined,
1220
+ allow_domain_join: undefined,
1221
+ })
1222
+ const fileCfg = readSpineConfig()
1223
+ expect(fileCfg.registration.apps.testapp.roles).toEqual(['member'])
1224
+ })
1225
+
1226
+ it('exits nonzero when the registration policy write fails', async () => {
1227
+ setSeedEnv()
1228
+ dbResponder(q => {
1229
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1230
+ if (q.table === 'apps' && q.calls[0]?.op === 'update') return { data: null, error: { message: 'denied' } }
1231
+ return { data: null, error: null }
1232
+ })
1233
+ const manifest = baseManifest({
1234
+ registration: { enabled: true, default_role: 'member', redirect_path: '/testapp' },
1235
+ })
1236
+ makeApp(join(workdir, 'custom/apps/testapp'), manifest)
1237
+ const program = await makeProgram()
1238
+ await expect(program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])).rejects.toThrow('process.exit(1)')
1239
+ expect(exitSpy).toHaveBeenCalledWith(1)
1240
+ })
1241
+
1242
+ it('exits before creating a client when manifest slug does not match the command slug', async () => {
1243
+ setSeedEnv()
1244
+ makeApp(join(workdir, 'custom/apps/testapp'), baseManifest({ slug: 'other-app' }))
1245
+ const program = await makeProgram()
1246
+ await expect(program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])).rejects.toThrow('process.exit(1)')
1247
+ expect(supaState.createClientCalls.length).toBe(0)
1248
+ })
1249
+
1250
+ it('exits before creating a client when the local config is corrupt and registration is declared', async () => {
1251
+ setSeedEnv()
1252
+ writeSpineConfig('{ not json')
1253
+ makeApp(join(workdir, 'custom/apps/testapp'), manifestWithRegistration())
1254
+ const program = await makeProgram()
1255
+ await expect(program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])).rejects.toThrow('process.exit(1)')
1256
+ expect(supaState.createClientCalls.length).toBe(0)
1257
+ })
1258
+
1259
+ it('syncs an explicit enabled:false policy like seedApp does', async () => {
1260
+ setSeedEnv()
1261
+ makeApp(join(workdir, 'custom/apps/testapp'), manifestWithRegistration({
1262
+ route_prefix: '/testapp',
1263
+ registration: { enabled: false },
1264
+ required_roles: [],
1265
+ }))
1266
+ const program = await makeProgram()
1267
+ await program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])
1268
+ const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
1269
+ expect(updatePayload.config.registration.enabled).toBe(false)
1270
+ expect(updatePayload.config.registration.default_role).toBe('member')
1271
+ expect(updatePayload.config.registration.path).toBe('/testapp')
1272
+ const fileCfg = readSpineConfig()
1273
+ expect(fileCfg.registration.apps.testapp).toEqual(updatePayload.config.registration)
1274
+ })
1275
+ })
1276
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:update_app_suite:block"]
1277
+
1278
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:install_cli_suite:block" type="block" identity="install_cli_suite" parent="none"]
1279
+ // CIL[DOC-MACRO purpose="Register default seeding, no-seed, migration failure, and file-only installer tests."]
1280
+ // CIL[DOC-RESPONSIBILITY responsibility="Register default seeding, no-seed, migration failure, and file-only installer tests."]
1281
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
1282
+ // CIL[DOC-CONTRACT inputs="Mock npm, Postgres, Commander, manifest: package fixture metadata, files: package fixture contents" outputs="Vitest assertions" errors="none" side_effects="Registers isolated CLI tests"]
1283
+ // CIL[DOC-MICRO logic_summary="Register default seeding, no-seed, migration failure, and file-only installer tests." notable_decisions=""]
1284
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
1285
+ // CIL[DOC-STABILITY stability="experimental"]
1286
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
1287
+ describe('install-app CLI', () => {
1288
+ function makePkg(manifest: Record<string, any>, files: Record<string, string> = {}) {
1289
+ const pkgDir = join(workdir, 'node_modules/spine-framework-testapp')
1290
+ mkdirSync(join(pkgDir, 'seed'), { recursive: true })
1291
+ writeJson(join(pkgDir, 'manifest.json'), manifest)
1292
+ writeJson(join(pkgDir, 'package.json'), { name: 'spine-framework-testapp', version: manifest.version })
1293
+ for (const [rel, content] of Object.entries(files)) {
1294
+ const p = join(pkgDir, rel)
1295
+ mkdirSync(join(p, '..'), { recursive: true })
1296
+ writeFileSync(p, content)
1297
+ }
1298
+ return pkgDir
1299
+ }
1300
+
1301
+ const regManifest = () => baseManifest({
1302
+ seed: [{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' }],
1303
+ registration: { enabled: true, default_role: 'member', redirect_path: '/testapp' },
1304
+ })
1305
+
1306
+ it('seeds by default after successful migrations', async () => {
1307
+ setMigrateEnv()
1308
+ makePkg(regManifest(), { 'seed/roles.json': JSON.stringify([{ slug: 'member', name: 'Member' }]) })
1309
+ const program = await makeProgram()
1310
+ await program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp'])
1311
+ expect(execState.calls.some(c => c.startsWith('npm install'))).toBe(true)
1312
+ expect(supaState.createClientCalls.length).toBe(1)
1313
+ expect(queriesTo('roles', 'upsert').length).toBe(1)
1314
+ const fileCfg = readSpineConfig()
1315
+ expect(fileCfg.registration.apps.testapp.enabled).toBe(true)
1316
+ })
1317
+
1318
+ it('--no-seed skips all DB seed work but still updates local registration config', async () => {
1319
+ setMigrateEnv()
1320
+ makePkg(regManifest(), { 'seed/roles.json': '[]' })
1321
+ const program = await makeProgram()
1322
+ await program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp', '--no-seed'])
1323
+ expect(supaState.createClientCalls.length).toBe(0)
1324
+ expect(queriesTo('roles').length).toBe(0)
1325
+ const fileCfg = readSpineConfig()
1326
+ expect(fileCfg.registration.apps.testapp.enabled).toBe(true)
1327
+ })
1328
+
1329
+ it('aborts before seeding when a migration fails', async () => {
1330
+ setMigrateEnv()
1331
+ mkdirSync(join(workdir, 'custom/migrations'), { recursive: true })
1332
+ writeFileSync(join(workdir, 'custom/migrations/001_mig.sql'), 'BOOM;')
1333
+ pgState.handler = (sql: string) => {
1334
+ if (sql === 'BOOM;') throw new Error('migration exploded')
1335
+ return { rows: [] }
1336
+ }
1337
+ makePkg(regManifest(), { 'seed/roles.json': '[]' })
1338
+ const program = await makeProgram()
1339
+ await expect(program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp'])).rejects.toThrow('process.exit(1)')
1340
+ expect(supaState.createClientCalls.length).toBe(0)
1341
+ expect(queriesTo('roles').length).toBe(0)
1342
+ expect(existsSync(join(workdir, 'spine.config.json'))).toBe(false)
1343
+ })
1344
+
1345
+ it('aborts with a clear error when DB credentials are absent and seeding is requested', async () => {
1346
+ setSeedEnv()
1347
+ delete process.env.SUPABASE_DB_PASSWORD
1348
+ makePkg(regManifest(), { 'seed/roles.json': '[]' })
1349
+ const program = await makeProgram()
1350
+ await expect(program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp'])).rejects.toThrow('process.exit(1)')
1351
+ expect(supaState.createClientCalls.length).toBe(0)
1352
+ })
1353
+
1354
+ it('installs files only with --no-seed when DB credentials are absent', async () => {
1355
+ setSeedEnv()
1356
+ delete process.env.SUPABASE_DB_PASSWORD
1357
+ makePkg(regManifest(), { 'seed/roles.json': '[]' })
1358
+ const program = await makeProgram()
1359
+ await program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp', '--no-seed'])
1360
+ expect(pgState.instances.length).toBe(0)
1361
+ expect(supaState.createClientCalls.length).toBe(0)
1362
+ expect(existsSync(join(workdir, 'custom/apps/testapp/manifest.json'))).toBe(true)
1363
+ expect(readSpineConfig().registration.apps.testapp.enabled).toBe(true)
1364
+ })
1365
+ })
1366
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:install_cli_suite:block"]
1367
+
1368
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:seed_ordering_suite:block" type="block" identity="seed_ordering_suite" parent="none"]
1369
+ // CIL[DOC-MACRO purpose="Register deterministic seed-ordering tests independent of manifest declaration order."]
1370
+ // CIL[DOC-RESPONSIBILITY responsibility="Register deterministic seed-ordering tests independent of manifest declaration order."]
1371
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts"]
1372
+ // CIL[DOC-CONTRACT inputs="Fixture helpers and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
1373
+ // CIL[DOC-MICRO logic_summary="Register deterministic seed-ordering tests independent of manifest declaration order." notable_decisions=""]
1374
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
1375
+ // CIL[DOC-STABILITY stability="experimental"]
1376
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
1377
+ describe('seedApp deterministic ordering', () => {
1378
+ it('seeds in dependency order regardless of manifest declaration order', async () => {
1379
+ const { seedApp } = await freshModule()
1380
+ setSeedEnv()
1381
+ dbResponder(q => {
1382
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1383
+ if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'update_item' }], error: null }
1384
+ if (q.table === 'pipelines' && q.calls[0]?.op === 'select') return { data: { id: 'pipe-1' }, error: null }
1385
+ return { data: null, error: null }
1386
+ })
1387
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
1388
+ seed: [
1389
+ { file: 'seed/triggers.json', table: 'triggers', conflict: 'app_id,slug' },
1390
+ { file: 'seed/pipelines.json', table: 'pipelines', conflict: 'app_id,slug' },
1391
+ { file: 'seed/roles.json', table: 'roles', conflict: 'slug' },
1392
+ ],
1393
+ }), {
1394
+ 'seed/triggers.json': JSON.stringify([{ slug: 'trg', pipeline_slug: 'pipe' }]),
1395
+ 'seed/pipelines.json': JSON.stringify([{ slug: 'pipe', stages: [{ stage_type: 'update_item', config: {} }] }]),
1396
+ 'seed/roles.json': JSON.stringify([{ slug: 'member', name: 'Member' }]),
1397
+ })
1398
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
1399
+ const upsertOrder = supaState.history
1400
+ .filter(q => q.calls.some(c => c.op === 'upsert'))
1401
+ .map(q => q.table)
1402
+ expect(upsertOrder).toEqual(['apps', 'roles', 'pipelines', 'triggers'])
1403
+ })
1404
+
1405
+ it('runs permissions_patch entries after types even when declared first', async () => {
1406
+ const { seedApp } = await freshModule()
1407
+ setSeedEnv()
1408
+ dbResponder(q => {
1409
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1410
+ if (q.table === 'types' && q.calls[0]?.op === 'select') {
1411
+ const slugEq = q.calls.find(c => c.op === 'eq' && c.args[0] === 'slug')
1412
+ if (slugEq?.args[1] === 'task-patch') {
1413
+ return { data: { id: 't-9', design_schema: { record_permissions: { admin: ['read'] } } }, error: null }
1414
+ }
1415
+ return { data: null, error: null }
1416
+ }
1417
+ return { data: null, error: null }
1418
+ })
1419
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({
1420
+ seed: [
1421
+ { file: 'seed/type_permissions.json', table: 'types', conflict: 'kind,slug', inject_app_id: false, permissions_patch: true },
1422
+ { file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false },
1423
+ ],
1424
+ }), {
1425
+ 'seed/type_permissions.json': JSON.stringify([{ kind: 'item', slug: 'task-patch', record_permissions: { admin: ['read', 'update'] } }]),
1426
+ 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task-seed', design_schema: { fields: { title: { data_type: 'text', required: true } } } }]),
1427
+ })
1428
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
1429
+ const typeUpsertIdx = supaState.history.findIndex(q => q.table === 'types' && q.calls.some(c => c.op === 'upsert'))
1430
+ const patchUpdateIdx = supaState.history.findIndex(q => q.table === 'types' && q.calls.some(c => c.op === 'update'))
1431
+ expect(typeUpsertIdx).toBeGreaterThanOrEqual(0)
1432
+ expect(patchUpdateIdx).toBeGreaterThan(typeUpsertIdx)
1433
+ })
1434
+ })
1435
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:seed_ordering_suite:block"]
1436
+
1437
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:type_conflict_suite:block" type="block" identity="type_conflict_suite" parent="none"]
1438
+ // CIL[DOC-MACRO purpose="Register strict type-conflict detection tests for the types seed path."]
1439
+ // CIL[DOC-RESPONSIBILITY responsibility="Register strict type-conflict detection tests for the types seed path."]
1440
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts"]
1441
+ // CIL[DOC-CONTRACT inputs="Mock responders and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
1442
+ // CIL[DOC-MICRO logic_summary="Register strict type-conflict detection tests for the types seed path." notable_decisions=""]
1443
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
1444
+ // CIL[DOC-STABILITY stability="experimental"]
1445
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
1446
+ describe('seedApp type conflict detection', () => {
1447
+ const typesEntry = { file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }
1448
+
1449
+ it('skips an identical existing type row without upserting, ignoring key order', async () => {
1450
+ const { seedApp } = await freshModule()
1451
+ setSeedEnv()
1452
+ dbResponder(q => {
1453
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1454
+ if (q.table === 'types' && q.calls[0]?.op === 'select') {
1455
+ // Same schema, different key order — jsonb key order is not significant.
1456
+ return { data: { id: 't-1', design_schema: { record_permissions: { admin: ['read'] }, fields: { title: { required: true, data_type: 'text' } } } }, error: null }
1457
+ }
1458
+ return { data: null, error: null }
1459
+ })
1460
+ const design = { fields: { title: { data_type: 'text', required: true } }, record_permissions: { admin: ['read'] } }
1461
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({ seed: [typesEntry] }), {
1462
+ 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: design }]),
1463
+ })
1464
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
1465
+ expect(queriesTo('types', 'upsert').length).toBe(0)
1466
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('item/task already seeded — identical'))
1467
+ })
1468
+
1469
+ it('throws naming kind/slug and --reconcile-owned-types when the DB row differs', async () => {
1470
+ const { seedApp } = await freshModule()
1471
+ setSeedEnv()
1472
+ dbResponder(q => {
1473
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1474
+ if (q.table === 'types' && q.calls[0]?.op === 'select') {
1475
+ return { data: { id: 't-1', design_schema: { fields: { title: { data_type: 'textarea' } } } }, error: null }
1476
+ }
1477
+ return { data: null, error: null }
1478
+ })
1479
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({ seed: [typesEntry] }), {
1480
+ 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: { fields: { title: { data_type: 'text' } } } }]),
1481
+ })
1482
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/item\/task/)
1483
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/--reconcile-owned-types/)
1484
+ expect(queriesTo('types', 'upsert').length).toBe(0)
1485
+ expect(queriesTo('types', 'update').length).toBe(0)
1486
+ })
1487
+
1488
+ it('still inserts absent types with the insert-only conflict contract', async () => {
1489
+ const { seedApp } = await freshModule()
1490
+ setSeedEnv()
1491
+ dbResponder(q => {
1492
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1493
+ return { data: null, error: null }
1494
+ })
1495
+ const appDir = makeApp(join(workdir, 'app'), baseManifest({ seed: [typesEntry] }), {
1496
+ 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: { fields: {} } }]),
1497
+ })
1498
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
1499
+ const upserts = queriesTo('types', 'upsert')
1500
+ expect(upserts.length).toBe(1)
1501
+ const [, opts] = upserts[0].calls.find(c => c.op === 'upsert')!.args
1502
+ expect(opts).toEqual({ onConflict: 'kind,slug', ignoreDuplicates: true })
1503
+ })
1504
+ })
1505
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:type_conflict_suite:block"]
1506
+
1507
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:reconcile_owned_types_suite:block" type="block" identity="reconcile_owned_types_suite" parent="none"]
1508
+ // CIL[DOC-MACRO purpose="Register --reconcile-owned-types declaration, diff-prompt, merge, and non-owned tests."]
1509
+ // CIL[DOC-RESPONSIBILITY responsibility="Register --reconcile-owned-types declaration, diff-prompt, merge, and non-owned tests."]
1510
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts"]
1511
+ // CIL[DOC-CONTRACT inputs="Mock responders, rlState answers, seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
1512
+ // CIL[DOC-MICRO logic_summary="Register --reconcile-owned-types declaration, diff-prompt, merge, and non-owned tests." notable_decisions=""]
1513
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
1514
+ // CIL[DOC-STABILITY stability="experimental"]
1515
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
1516
+ describe('seedApp --reconcile-owned-types', () => {
1517
+ const typesEntry = { file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }
1518
+
1519
+ function typesApp(records: any[], overrides: Record<string, any> = {}) {
1520
+ return makeApp(join(workdir, 'app'), baseManifest({
1521
+ seed: [typesEntry],
1522
+ ...overrides,
1523
+ }), { 'seed/types.json': JSON.stringify(records) })
1524
+ }
1525
+
1526
+ function typeSelectResponder(bySlug: Record<string, any>) {
1527
+ dbResponder(q => {
1528
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1529
+ if (q.table === 'types' && q.calls[0]?.op === 'select') {
1530
+ const slugEq = q.calls.find(c => c.op === 'eq' && c.args[0] === 'slug')
1531
+ const found = bySlug[slugEq?.args[1]]
1532
+ return { data: found ? { id: `t-${found}`, design_schema: found } : null, error: null }
1533
+ }
1534
+ return { data: null, error: null }
1535
+ })
1536
+ }
1537
+
1538
+ it('fails before creating a client when the manifest does not declare owned_types', async () => {
1539
+ const { seedApp } = await freshModule()
1540
+ setSeedEnv()
1541
+ const appDir = typesApp([{ kind: 'item', slug: 'task', design_schema: { fields: {} } }])
1542
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })).rejects.toThrow(/owned_types/)
1543
+ expect(supaState.createClientCalls.length).toBe(0)
1544
+ })
1545
+
1546
+ it('fails before creating a client when an owned slug is absent from the types seed', async () => {
1547
+ const { seedApp } = await freshModule()
1548
+ setSeedEnv()
1549
+ const appDir = typesApp(
1550
+ [{ kind: 'item', slug: 'task', design_schema: { fields: {} } }],
1551
+ { owned_types: ['ghost'] },
1552
+ )
1553
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })).rejects.toThrow(/ghost/)
1554
+ expect(supaState.createClientCalls.length).toBe(0)
1555
+ })
1556
+
1557
+ it('still throws for a differing type the manifest does not own', async () => {
1558
+ const { seedApp } = await freshModule()
1559
+ setSeedEnv()
1560
+ const design = { fields: { title: { data_type: 'text', required: true } } }
1561
+ typeSelectResponder({ task: design, other: { fields: { title: { data_type: 'textarea' } } } })
1562
+ const appDir = typesApp(
1563
+ [
1564
+ { kind: 'item', slug: 'task', design_schema: design },
1565
+ { kind: 'item', slug: 'other', design_schema: { fields: { title: { data_type: 'text' } } } },
1566
+ ],
1567
+ { owned_types: ['task'] },
1568
+ )
1569
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })).rejects.toThrow(/item\/other/)
1570
+ expect(queriesTo('types', 'update').length).toBe(0)
1571
+ })
1572
+
1573
+ it('updates the owned type on Update, preserving DB-only fields and regenerating validation_schema', async () => {
1574
+ const { seedApp } = await freshModule()
1575
+ setSeedEnv()
1576
+ rlState.answer = 'u'
1577
+ const dbSchema = {
1578
+ fields: {
1579
+ title: { data_type: 'text', required: true },
1580
+ legacy: { data_type: 'number', required: false },
1581
+ },
1582
+ record_permissions: { admin: ['read'] },
1583
+ }
1584
+ const seedSchema = {
1585
+ fields: {
1586
+ title: { data_type: 'textarea', required: false },
1587
+ extra: { data_type: 'text', required: true },
1588
+ },
1589
+ record_permissions: { admin: ['read', 'update'] },
1590
+ }
1591
+ typeSelectResponder({ task: dbSchema })
1592
+ const appDir = typesApp(
1593
+ [{ kind: 'item', slug: 'task', design_schema: seedSchema }],
1594
+ { owned_types: ['task'] },
1595
+ )
1596
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })
1597
+ expect(queriesTo('types', 'upsert').length).toBe(0)
1598
+ const updates = queriesTo('types', 'update')
1599
+ expect(updates.length).toBe(1)
1600
+ const updatePayload = updates[0].calls.find(c => c.op === 'update')!.args[0]
1601
+ expect(updatePayload.design_schema.fields.title).toEqual({ data_type: 'textarea', required: false })
1602
+ expect(updatePayload.design_schema.fields.legacy).toEqual({ data_type: 'number', required: false })
1603
+ expect(updatePayload.design_schema.fields.extra).toEqual({ data_type: 'text', required: true })
1604
+ expect(updatePayload.design_schema.record_permissions).toEqual({ admin: ['read', 'update'] })
1605
+ expect(updatePayload.validation_schema).toEqual({
1606
+ fields: {
1607
+ title: { data_type: 'textarea', required: false },
1608
+ legacy: { data_type: 'number', required: false },
1609
+ extra: { data_type: 'text', required: true },
1610
+ },
1611
+ })
1612
+ })
1613
+
1614
+ it('keeps the DB row untouched on Keep', async () => {
1615
+ const { seedApp } = await freshModule()
1616
+ setSeedEnv()
1617
+ rlState.answer = 'k'
1618
+ typeSelectResponder({ task: { fields: { title: { data_type: 'textarea' } } } })
1619
+ const appDir = typesApp(
1620
+ [{ kind: 'item', slug: 'task', design_schema: { fields: { title: { data_type: 'text' } } } }],
1621
+ { owned_types: ['task'] },
1622
+ )
1623
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })
1624
+ expect(queriesTo('types', 'update').length).toBe(0)
1625
+ expect(queriesTo('types', 'upsert').length).toBe(0)
1626
+ })
1627
+
1628
+ it('propagates the flag through the seed-app command', async () => {
1629
+ setSeedEnv()
1630
+ makeApp(join(workdir, 'custom/apps/testapp'), baseManifest({
1631
+ seed: [{ file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }],
1632
+ }), { 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: { fields: {} } }]) })
1633
+ const program = await makeProgram()
1634
+ await expect(program.parseAsync(['node', 'cli', 'seed-app', 'testapp', '--reconcile-owned-types'])).rejects.toThrow('process.exit(1)')
1635
+ expect(exitSpy).toHaveBeenCalledWith(1)
1636
+ expect(supaState.createClientCalls.length).toBe(0)
1637
+ })
1638
+ })
1639
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:reconcile_owned_types_suite:block"]
1640
+
1641
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:pipeline_stage_validation_suite:block" type="block" identity="pipeline_stage_validation_suite" parent="none"]
1642
+ // CIL[DOC-MACRO purpose="Register install-time pipeline stage_type and send_notification recipient validation tests."]
1643
+ // CIL[DOC-RESPONSIBILITY responsibility="Register install-time pipeline stage_type and send_notification recipient validation tests."]
1644
+ // CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/functions/_shared/notify.ts"]
1645
+ // CIL[DOC-CONTRACT inputs="Mock responders and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
1646
+ // CIL[DOC-MICRO logic_summary="Register install-time pipeline stage_type and send_notification recipient validation tests." notable_decisions=""]
1647
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
1648
+ // CIL[DOC-STABILITY stability="experimental"]
1649
+ // CIL[DOC-AUDIENCE audience="framework contributors"]
1650
+ describe('seedApp pipeline stage validation', () => {
1651
+ function pipelineApp(pipeline: Record<string, any>) {
1652
+ return makeApp(join(workdir, 'app'), baseManifest({
1653
+ seed: [{ file: 'seed/pipelines.json', table: 'pipelines', conflict: 'app_id,slug' }],
1654
+ }), { 'seed/pipelines.json': JSON.stringify([pipeline]) })
1655
+ }
1656
+
1657
+ it('throws naming the pipeline slug, stage index, and bad stage_type', async () => {
1658
+ const { seedApp } = await freshModule()
1659
+ setSeedEnv()
1660
+ dbResponder(q => {
1661
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1662
+ if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'update_item' }], error: null }
1663
+ return { data: null, error: null }
1664
+ })
1665
+ const appDir = pipelineApp({
1666
+ slug: 'pipe',
1667
+ stages: [
1668
+ { stage_type: 'update_item', config: {} },
1669
+ { stage_type: 'bogus_stage', config: {} },
1670
+ ],
1671
+ })
1672
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/"pipe"[\s\S]*stage 1[\s\S]*bogus_stage/)
1673
+ expect(queriesTo('pipelines', 'upsert').length).toBe(0)
1674
+ expect(queriesTo('actions').length).toBe(1)
1675
+ })
1676
+
1677
+ it('throws when the actions table cannot be read (migrations not run)', async () => {
1678
+ const { seedApp } = await freshModule()
1679
+ setSeedEnv()
1680
+ dbResponder(q => {
1681
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1682
+ if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: null, error: { message: 'relation public.actions does not exist' } }
1683
+ return { data: null, error: null }
1684
+ })
1685
+ const appDir = pipelineApp({ slug: 'pipe', stages: [{ stage_type: 'update_item', config: {} }] })
1686
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/public\.actions/)
1687
+ expect(queriesTo('pipelines', 'upsert').length).toBe(0)
1688
+ })
1689
+
1690
+ it('rejects a send_notification stage whose recipients is not an array', async () => {
1691
+ const { seedApp } = await freshModule()
1692
+ setSeedEnv()
1693
+ dbResponder(q => {
1694
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1695
+ if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'send_notification' }], error: null }
1696
+ return { data: null, error: null }
1697
+ })
1698
+ const appDir = pipelineApp({
1699
+ slug: 'notify-pipe',
1700
+ stages: [{ stage_type: 'send_notification', config: { message: 'hi', recipients: 'role:member' } }],
1701
+ })
1702
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/recipients must be an array/)
1703
+ })
1704
+
1705
+ it('rejects a recipient that is not role:<slug>, self, or a UUID', async () => {
1706
+ const { seedApp } = await freshModule()
1707
+ setSeedEnv()
1708
+ dbResponder(q => {
1709
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1710
+ if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'send_notification' }], error: null }
1711
+ return { data: null, error: null }
1712
+ })
1713
+ const appDir = pipelineApp({
1714
+ slug: 'notify-pipe',
1715
+ stages: [{ stage_type: 'send_notification', config: { message: 'hi', recipients: ['bogus-value'] } }],
1716
+ })
1717
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/bogus-value/)
1718
+ expect(queriesTo('pipelines', 'upsert').length).toBe(0)
1719
+ })
1720
+
1721
+ it('rejects a role: recipient whose role does not exist', async () => {
1722
+ const { seedApp } = await freshModule()
1723
+ setSeedEnv()
1724
+ dbResponder(q => {
1725
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1726
+ if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'send_notification' }], error: null }
1727
+ if (q.table === 'roles' && q.calls[0]?.op === 'select') return { data: [{ slug: 'member' }], error: null }
1728
+ return { data: null, error: null }
1729
+ })
1730
+ const appDir = pipelineApp({
1731
+ slug: 'notify-pipe',
1732
+ stages: [{ stage_type: 'send_notification', config: { message: 'hi', recipients: ['role:ghost'] } }],
1733
+ })
1734
+ await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/role:ghost/)
1735
+ expect(queriesTo('pipelines', 'upsert').length).toBe(0)
1736
+ })
1737
+
1738
+ it('accepts role:, self, and UUID recipients and seeds the pipeline', async () => {
1739
+ const { seedApp } = await freshModule()
1740
+ setSeedEnv()
1741
+ dbResponder(q => {
1742
+ if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
1743
+ if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'send_notification' }], error: null }
1744
+ if (q.table === 'roles' && q.calls[0]?.op === 'select') return { data: [{ slug: 'member' }], error: null }
1745
+ return { data: null, error: null }
1746
+ })
1747
+ const appDir = pipelineApp({
1748
+ slug: 'notify-pipe',
1749
+ stages: [{
1750
+ stage_type: 'send_notification',
1751
+ config: { message: 'hi', recipients: ['role:member', 'self', '12345678-1234-4234-9234-123456789012'] },
1752
+ }],
1753
+ })
1754
+ await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
1755
+ expect(queriesTo('pipelines', 'upsert').length).toBe(1)
1756
+ expect(queriesTo('roles').length).toBe(1)
1757
+ })
1758
+ })
1759
+ // CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:pipeline_stage_validation_suite:block"]