primitive-admin 1.0.61 → 1.0.63

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 (320) hide show
  1. package/README.md +125 -69
  2. package/assets/skill/skills/primitive-platform/SKILL.md +336 -37
  3. package/dist/bin/primitive.js +25 -14
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/analytics.js +36 -16
  6. package/dist/src/commands/analytics.js.map +1 -1
  7. package/dist/src/commands/apps.js +52 -188
  8. package/dist/src/commands/apps.js.map +1 -1
  9. package/dist/src/commands/blob-buckets.js +13 -153
  10. package/dist/src/commands/blob-buckets.js.map +1 -1
  11. package/dist/src/commands/collection-type-configs.js +14 -100
  12. package/dist/src/commands/collection-type-configs.js.map +1 -1
  13. package/dist/src/commands/collections.js +47 -8
  14. package/dist/src/commands/collections.js.map +1 -1
  15. package/dist/src/commands/config.d.ts +46 -0
  16. package/dist/src/commands/config.js +479 -0
  17. package/dist/src/commands/config.js.map +1 -0
  18. package/dist/src/commands/connections.js +12 -7
  19. package/dist/src/commands/connections.js.map +1 -1
  20. package/dist/src/commands/cron-triggers.js +84 -176
  21. package/dist/src/commands/cron-triggers.js.map +1 -1
  22. package/dist/src/commands/database-type-configs.d.ts +2 -0
  23. package/dist/src/commands/database-type-configs.js +171 -0
  24. package/dist/src/commands/database-type-configs.js.map +1 -0
  25. package/dist/src/commands/database-types.js +4 -4
  26. package/dist/src/commands/database-types.js.map +1 -1
  27. package/dist/src/commands/databases.d.ts +63 -0
  28. package/dist/src/commands/databases.js +649 -87
  29. package/dist/src/commands/databases.js.map +1 -1
  30. package/dist/src/commands/documents.js +869 -12
  31. package/dist/src/commands/documents.js.map +1 -1
  32. package/dist/src/commands/email-templates.js +21 -128
  33. package/dist/src/commands/email-templates.js.map +1 -1
  34. package/dist/src/commands/env.d.ts +12 -1
  35. package/dist/src/commands/env.js +115 -26
  36. package/dist/src/commands/env.js.map +1 -1
  37. package/dist/src/commands/feature-flags.d.ts +14 -0
  38. package/dist/src/commands/feature-flags.js +116 -0
  39. package/dist/src/commands/feature-flags.js.map +1 -0
  40. package/dist/src/commands/group-type-configs.js +14 -109
  41. package/dist/src/commands/group-type-configs.js.map +1 -1
  42. package/dist/src/commands/init.d.ts +24 -1
  43. package/dist/src/commands/init.js +1194 -279
  44. package/dist/src/commands/init.js.map +1 -1
  45. package/dist/src/commands/integrations.js +166 -435
  46. package/dist/src/commands/integrations.js.map +1 -1
  47. package/dist/src/commands/metadata-category-configs.d.ts +12 -0
  48. package/dist/src/commands/metadata-category-configs.js +112 -0
  49. package/dist/src/commands/metadata-category-configs.js.map +1 -0
  50. package/dist/src/commands/metadata.js +33 -141
  51. package/dist/src/commands/metadata.js.map +1 -1
  52. package/dist/src/commands/prompts.js +206 -563
  53. package/dist/src/commands/prompts.js.map +1 -1
  54. package/dist/src/commands/rule-sets.d.ts +1 -1
  55. package/dist/src/commands/rule-sets.js +16 -132
  56. package/dist/src/commands/rule-sets.js.map +1 -1
  57. package/dist/src/commands/scripts.d.ts +10 -0
  58. package/dist/src/commands/scripts.js +203 -241
  59. package/dist/src/commands/scripts.js.map +1 -1
  60. package/dist/src/commands/sessions.js +17 -5
  61. package/dist/src/commands/sessions.js.map +1 -1
  62. package/dist/src/commands/sync-app-settings.d.ts +113 -70
  63. package/dist/src/commands/sync-app-settings.js +193 -242
  64. package/dist/src/commands/sync-app-settings.js.map +1 -1
  65. package/dist/src/commands/sync.d.ts +1904 -51
  66. package/dist/src/commands/sync.js +9468 -1790
  67. package/dist/src/commands/sync.js.map +1 -1
  68. package/dist/src/commands/tokens.js +8 -2
  69. package/dist/src/commands/tokens.js.map +1 -1
  70. package/dist/src/commands/users.js +92 -1
  71. package/dist/src/commands/users.js.map +1 -1
  72. package/dist/src/commands/vars.js +44 -58
  73. package/dist/src/commands/vars.js.map +1 -1
  74. package/dist/src/commands/webhooks.js +318 -156
  75. package/dist/src/commands/webhooks.js.map +1 -1
  76. package/dist/src/commands/workflows.d.ts +49 -12
  77. package/dist/src/commands/workflows.js +652 -1086
  78. package/dist/src/commands/workflows.js.map +1 -1
  79. package/dist/src/lib/access-rule-display.d.ts +21 -0
  80. package/dist/src/lib/access-rule-display.js +34 -0
  81. package/dist/src/lib/access-rule-display.js.map +1 -0
  82. package/dist/src/lib/api-client.d.ts +541 -61
  83. package/dist/src/lib/api-client.js +559 -102
  84. package/dist/src/lib/api-client.js.map +1 -1
  85. package/dist/src/lib/app-settings-descriptor.d.ts +180 -25
  86. package/dist/src/lib/app-settings-descriptor.js +403 -82
  87. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  88. package/dist/src/lib/canonical-json.d.ts +12 -0
  89. package/dist/src/lib/canonical-json.js +35 -0
  90. package/dist/src/lib/canonical-json.js.map +1 -0
  91. package/dist/src/lib/cli-manifest.d.ts +8 -0
  92. package/dist/src/lib/cli-manifest.js +1 -0
  93. package/dist/src/lib/cli-manifest.js.map +1 -1
  94. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -2
  95. package/dist/src/lib/codegen-shared/generatedFiles.js +9 -2
  96. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  97. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +6 -6
  98. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +8 -8
  99. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  100. package/dist/src/lib/config-object-descriptor.d.ts +127 -0
  101. package/dist/src/lib/config-object-descriptor.js +658 -0
  102. package/dist/src/lib/config-object-descriptor.js.map +1 -0
  103. package/dist/src/lib/config-payload.d.ts +85 -0
  104. package/dist/src/lib/config-payload.js +116 -0
  105. package/dist/src/lib/config-payload.js.map +1 -0
  106. package/dist/src/lib/config-surface.d.ts +130 -0
  107. package/dist/src/lib/config-surface.js +300 -0
  108. package/dist/src/lib/config-surface.js.map +1 -0
  109. package/dist/src/lib/credentials-store.d.ts +10 -0
  110. package/dist/src/lib/credentials-store.js +23 -0
  111. package/dist/src/lib/credentials-store.js.map +1 -1
  112. package/dist/src/lib/data-input.d.ts +23 -0
  113. package/dist/src/lib/data-input.js +50 -0
  114. package/dist/src/lib/data-input.js.map +1 -0
  115. package/dist/src/lib/db-codegen/dbTemplates.js +1 -1
  116. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  117. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +1 -1
  118. package/dist/src/lib/env-resolver-core.d.ts +187 -0
  119. package/dist/src/lib/env-resolver-core.js +325 -0
  120. package/dist/src/lib/env-resolver-core.js.map +1 -0
  121. package/dist/src/lib/env-resolver.d.ts +28 -6
  122. package/dist/src/lib/env-resolver.js +44 -32
  123. package/dist/src/lib/env-resolver.js.map +1 -1
  124. package/dist/src/lib/generated-allowlist.js +12 -0
  125. package/dist/src/lib/generated-allowlist.js.map +1 -1
  126. package/dist/src/lib/generated-config-surfaces.d.ts +682 -0
  127. package/dist/src/lib/generated-config-surfaces.js +4058 -0
  128. package/dist/src/lib/generated-config-surfaces.js.map +1 -0
  129. package/dist/src/lib/generated-template-lint.d.ts +212 -0
  130. package/dist/src/lib/generated-template-lint.js +624 -0
  131. package/dist/src/lib/generated-template-lint.js.map +1 -0
  132. package/dist/src/lib/init-adopt.d.ts +16 -0
  133. package/dist/src/lib/init-adopt.js +34 -0
  134. package/dist/src/lib/init-adopt.js.map +1 -0
  135. package/dist/src/lib/init-assets.d.ts +39 -0
  136. package/dist/src/lib/init-assets.js +97 -0
  137. package/dist/src/lib/init-assets.js.map +1 -0
  138. package/dist/src/lib/init-client-platforms.d.ts +14 -0
  139. package/dist/src/lib/init-client-platforms.js +71 -0
  140. package/dist/src/lib/init-client-platforms.js.map +1 -0
  141. package/dist/src/lib/init-config.d.ts +44 -5
  142. package/dist/src/lib/init-config.js +80 -7
  143. package/dist/src/lib/init-config.js.map +1 -1
  144. package/dist/src/lib/init-email-redirect-uris.d.ts +37 -0
  145. package/dist/src/lib/init-email-redirect-uris.js +46 -0
  146. package/dist/src/lib/init-email-redirect-uris.js.map +1 -0
  147. package/dist/src/lib/init-ios-links.d.ts +90 -0
  148. package/dist/src/lib/init-ios-links.js +218 -0
  149. package/dist/src/lib/init-ios-links.js.map +1 -0
  150. package/dist/src/lib/init-plan.d.ts +80 -0
  151. package/dist/src/lib/init-plan.js +95 -0
  152. package/dist/src/lib/init-plan.js.map +1 -0
  153. package/dist/src/lib/init-production-env.d.ts +48 -0
  154. package/dist/src/lib/init-production-env.js +59 -0
  155. package/dist/src/lib/init-production-env.js.map +1 -0
  156. package/dist/src/lib/init-schema.d.ts +74 -0
  157. package/dist/src/lib/init-schema.js +358 -0
  158. package/dist/src/lib/init-schema.js.map +1 -0
  159. package/dist/src/lib/init-xcode.d.ts +33 -0
  160. package/dist/src/lib/init-xcode.js +114 -0
  161. package/dist/src/lib/init-xcode.js.map +1 -0
  162. package/dist/src/lib/integration-request-config.d.ts +30 -0
  163. package/dist/src/lib/integration-request-config.js +145 -0
  164. package/dist/src/lib/integration-request-config.js.map +1 -0
  165. package/dist/src/lib/integration-selector.d.ts +42 -0
  166. package/dist/src/lib/integration-selector.js +46 -0
  167. package/dist/src/lib/integration-selector.js.map +1 -0
  168. package/dist/src/lib/local-state.d.ts +55 -0
  169. package/dist/src/lib/local-state.js +167 -0
  170. package/dist/src/lib/local-state.js.map +1 -0
  171. package/dist/src/lib/local-test-cases.d.ts +63 -0
  172. package/dist/src/lib/local-test-cases.js +135 -0
  173. package/dist/src/lib/local-test-cases.js.map +1 -0
  174. package/dist/src/lib/log-inspection.d.ts +568 -0
  175. package/dist/src/lib/log-inspection.js +639 -0
  176. package/dist/src/lib/log-inspection.js.map +1 -0
  177. package/dist/src/lib/migration-nag.d.ts +2 -2
  178. package/dist/src/lib/migration-nag.js +3 -3
  179. package/dist/src/lib/object-status-filter.d.ts +22 -0
  180. package/dist/src/lib/object-status-filter.js +45 -0
  181. package/dist/src/lib/object-status-filter.js.map +1 -0
  182. package/dist/src/lib/output.d.ts +24 -1
  183. package/dist/src/lib/output.js +42 -1
  184. package/dist/src/lib/output.js.map +1 -1
  185. package/dist/src/lib/package-manager.d.ts +140 -0
  186. package/dist/src/lib/package-manager.js +305 -0
  187. package/dist/src/lib/package-manager.js.map +1 -0
  188. package/dist/src/lib/paginate.d.ts +50 -0
  189. package/dist/src/lib/paginate.js +53 -0
  190. package/dist/src/lib/paginate.js.map +1 -1
  191. package/dist/src/lib/platform-owned.d.ts +29 -3
  192. package/dist/src/lib/platform-owned.js +42 -3
  193. package/dist/src/lib/platform-owned.js.map +1 -1
  194. package/dist/src/lib/project-config.d.ts +8 -0
  195. package/dist/src/lib/project-config.js +30 -5
  196. package/dist/src/lib/project-config.js.map +1 -1
  197. package/dist/src/lib/query-operators.d.ts +1 -1
  198. package/dist/src/lib/query-operators.js +1 -1
  199. package/dist/src/lib/record-filter.d.ts +18 -0
  200. package/dist/src/lib/record-filter.js +55 -0
  201. package/dist/src/lib/record-filter.js.map +1 -0
  202. package/dist/src/lib/resolve-init-dev-port.d.ts +56 -0
  203. package/dist/src/lib/resolve-init-dev-port.js +55 -0
  204. package/dist/src/lib/resolve-init-dev-port.js.map +1 -0
  205. package/dist/src/lib/resolve-init-server.d.ts +64 -0
  206. package/dist/src/lib/resolve-init-server.js +77 -0
  207. package/dist/src/lib/resolve-init-server.js.map +1 -0
  208. package/dist/src/lib/resolve-platform.d.ts +43 -14
  209. package/dist/src/lib/resolve-platform.js +74 -12
  210. package/dist/src/lib/resolve-platform.js.map +1 -1
  211. package/dist/src/lib/run-status.d.ts +19 -0
  212. package/dist/src/lib/run-status.generated.d.ts +39 -0
  213. package/dist/src/lib/run-status.generated.js +66 -0
  214. package/dist/src/lib/run-status.generated.js.map +1 -0
  215. package/dist/src/lib/run-status.js +19 -0
  216. package/dist/src/lib/run-status.js.map +1 -0
  217. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  218. package/dist/src/lib/server-text-normalization.js +90 -0
  219. package/dist/src/lib/server-text-normalization.js.map +1 -0
  220. package/dist/src/lib/server-url.d.ts +22 -0
  221. package/dist/src/lib/server-url.js +33 -0
  222. package/dist/src/lib/server-url.js.map +1 -0
  223. package/dist/src/lib/signing-secret-status.d.ts +81 -0
  224. package/dist/src/lib/signing-secret-status.js +116 -0
  225. package/dist/src/lib/signing-secret-status.js.map +1 -0
  226. package/dist/src/lib/skill-installer.js +6 -3
  227. package/dist/src/lib/skill-installer.js.map +1 -1
  228. package/dist/src/lib/snapshots.d.ts +2 -2
  229. package/dist/src/lib/snapshots.js +2 -2
  230. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +15 -2
  231. package/dist/src/lib/swift-codegen/dbGenerator.js +250 -18
  232. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  233. package/dist/src/lib/swift-codegen/generator.d.ts +11 -2
  234. package/dist/src/lib/swift-codegen/generator.js +181 -7
  235. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  236. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +7 -3
  237. package/dist/src/lib/swift-codegen/siblingSymbols.js +4 -1
  238. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -1
  239. package/dist/src/lib/sync-dir-selector.d.ts +21 -0
  240. package/dist/src/lib/sync-dir-selector.js +30 -0
  241. package/dist/src/lib/sync-dir-selector.js.map +1 -0
  242. package/dist/src/lib/sync-paths.d.ts +39 -0
  243. package/dist/src/lib/sync-paths.js +70 -2
  244. package/dist/src/lib/sync-paths.js.map +1 -1
  245. package/dist/src/lib/sync-resource-types.d.ts +138 -31
  246. package/dist/src/lib/sync-resource-types.js +241 -47
  247. package/dist/src/lib/sync-resource-types.js.map +1 -1
  248. package/dist/src/lib/sync-selectors.d.ts +95 -0
  249. package/dist/src/lib/sync-selectors.js +228 -0
  250. package/dist/src/lib/sync-selectors.js.map +1 -0
  251. package/dist/src/lib/template.d.ts +62 -4
  252. package/dist/src/lib/template.js +147 -39
  253. package/dist/src/lib/template.js.map +1 -1
  254. package/dist/src/lib/test-case-file-names.d.ts +40 -0
  255. package/dist/src/lib/test-case-file-names.js +91 -0
  256. package/dist/src/lib/test-case-file-names.js.map +1 -0
  257. package/dist/src/lib/test-case-keys.d.ts +29 -0
  258. package/dist/src/lib/test-case-keys.js +55 -0
  259. package/dist/src/lib/test-case-keys.js.map +1 -0
  260. package/dist/src/lib/test-case-variables.d.ts +15 -0
  261. package/dist/src/lib/test-case-variables.js +29 -0
  262. package/dist/src/lib/test-case-variables.js.map +1 -0
  263. package/dist/src/lib/toml-database-config.d.ts +4 -4
  264. package/dist/src/lib/toml-database-config.js +27 -34
  265. package/dist/src/lib/toml-database-config.js.map +1 -1
  266. package/dist/src/lib/toml-metadata-config.d.ts +9 -5
  267. package/dist/src/lib/toml-metadata-config.js +40 -45
  268. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  269. package/dist/src/lib/toml-native-form.d.ts +1 -1
  270. package/dist/src/lib/toml-native-form.js +1 -1
  271. package/dist/src/lib/toml-params-validator.d.ts +1 -1
  272. package/dist/src/lib/toml-params-validator.js +1 -1
  273. package/dist/src/lib/toml-scalar-edit.d.ts +43 -0
  274. package/dist/src/lib/toml-scalar-edit.js +283 -0
  275. package/dist/src/lib/toml-scalar-edit.js.map +1 -0
  276. package/dist/src/lib/user-selector.d.ts +24 -0
  277. package/dist/src/lib/user-selector.js +33 -0
  278. package/dist/src/lib/user-selector.js.map +1 -0
  279. package/dist/src/lib/version-check.d.ts +25 -0
  280. package/dist/src/lib/version-check.js +77 -8
  281. package/dist/src/lib/version-check.js.map +1 -1
  282. package/dist/src/lib/watch.d.ts +121 -0
  283. package/dist/src/lib/watch.js +169 -0
  284. package/dist/src/lib/watch.js.map +1 -0
  285. package/dist/src/lib/web-url.d.ts +40 -0
  286. package/dist/src/lib/web-url.js +76 -0
  287. package/dist/src/lib/web-url.js.map +1 -0
  288. package/dist/src/lib/workflow-apply.d.ts +69 -45
  289. package/dist/src/lib/workflow-apply.js +99 -72
  290. package/dist/src/lib/workflow-apply.js.map +1 -1
  291. package/dist/src/lib/workflow-codegen/generator.js +4 -4
  292. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  293. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +11 -0
  294. package/dist/src/lib/workflow-codegen/invokerIR.js +5 -0
  295. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  296. package/dist/src/lib/workflow-config-apply.d.ts +70 -0
  297. package/dist/src/lib/workflow-config-apply.js +137 -0
  298. package/dist/src/lib/workflow-config-apply.js.map +1 -0
  299. package/dist/src/lib/workflow-config-sidecar.d.ts +63 -0
  300. package/dist/src/lib/workflow-config-sidecar.js +96 -0
  301. package/dist/src/lib/workflow-config-sidecar.js.map +1 -0
  302. package/dist/src/lib/workflow-defaults.d.ts +29 -0
  303. package/dist/src/lib/workflow-defaults.js +41 -0
  304. package/dist/src/lib/workflow-defaults.js.map +1 -0
  305. package/dist/src/lib/workflow-include-preserve.d.ts +76 -0
  306. package/dist/src/lib/workflow-include-preserve.js +286 -0
  307. package/dist/src/lib/workflow-include-preserve.js.map +1 -0
  308. package/dist/src/lib/workflow-payload.d.ts +31 -20
  309. package/dist/src/lib/workflow-payload.js +100 -110
  310. package/dist/src/lib/workflow-payload.js.map +1 -1
  311. package/dist/src/lib/workflow-toml-validator.d.ts +37 -16
  312. package/dist/src/lib/workflow-toml-validator.js +141 -16
  313. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  314. package/dist/src/types/index.d.ts +78 -22
  315. package/dist/src/validators.d.ts +7 -7
  316. package/dist/src/validators.js +6 -6
  317. package/package.json +11 -6
  318. package/dist/src/commands/settings.d.ts +0 -15
  319. package/dist/src/commands/settings.js +0 -102
  320. package/dist/src/commands/settings.js.map +0 -1
@@ -1,15 +1,32 @@
1
- import { confirm, input, select } from "@inquirer/prompts";
2
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from "fs";
3
- import { basename, join, resolve } from "path";
4
- import { CURRENT_CONFIG_VERSION, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, PROJECT_CONFIG_DISPLAY_NAME, saveProjectConfig, loadProjectConfigFromPath, } from "../lib/project-config.js";
1
+ import { checkbox, confirm, input, select } from "@inquirer/prompts";
2
+ import { appendFileSync, existsSync, readFileSync, readdirSync, writeFileSync, } from "fs";
3
+ import { basename, join, relative, resolve } from "path";
4
+ import { CURRENT_CONFIG_VERSION, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, PROJECT_CONFIG_DISPLAY_NAME, findProjectRoot, saveProjectConfig, loadProjectConfigFromPath, } from "../lib/project-config.js";
5
+ import { resolveAdoptedEnvironment } from "../lib/init-adopt.js";
6
+ import { CANONICAL_SCHEMA_HEADER, FALLBACK_STARTER_SCHEMA, platformLabel, rootAgentsMarkdown, } from "../lib/init-assets.js";
7
+ import { checkInitGuards, planInit } from "../lib/init-plan.js";
8
+ import { resolveProductionRuntime } from "../lib/init-production-env.js";
9
+ import { checkClientWiring, composeSchemaContent, declaredModelNames, findClientSchema, wireClientToSchema, writeCanonicalSchema, } from "../lib/init-schema.js";
10
+ import { declareSharedProjectConfig } from "../lib/init-xcode.js";
11
+ import { deriveCallbackScheme, linkOptInGuidance, missingUrlTypeGuidance, stampCallbackScheme, universalLinkGuidance, } from "../lib/init-ios-links.js";
12
+ import { appHasBothPlatforms, detectClientPlatform, discoverClientPlatforms, } from "../lib/init-client-platforms.js";
13
+ import { planEmailRedirectUriMerge } from "../lib/init-email-redirect-uris.js";
14
+ import { getExplicitEnvName } from "../lib/env-resolver.js";
5
15
  import { ApiClient } from "../lib/api-client.js";
6
16
  import { browserLogin } from "../lib/auth-flow.js";
7
17
  import { loadCredentials, setCurrentApp } from "../lib/config.js";
8
- import { INIT_CONFIG_FILENAME, loadInitConfig, parseInitConfigToml, resolveUseExistingAppId, validateInitConfig, } from "../lib/init-config.js";
9
- import { resolvePlatform, } from "../lib/resolve-platform.js";
18
+ import { isProjectMode, seedCredentialsForEnvInProject, } from "../lib/credentials-store.js";
19
+ import { getCurrentEnvironment, getProjectConfig } from "../lib/env-resolver.js";
20
+ import { normalizeServerUrl } from "../lib/channel.js";
21
+ import { INIT_CONFIG_FILENAME, loadInitConfig, parseInitConfigToml, resolveSkipInstall, resolveUseExistingAppId, validateInitConfig, } from "../lib/init-config.js";
22
+ import { parsePlatformList, resolvePlatforms, } from "../lib/resolve-platform.js";
23
+ import { resolveInitServer } from "../lib/resolve-init-server.js";
24
+ import { validateServerUrl } from "../lib/server-url.js";
25
+ import { DEFAULT_DEV_PORT, resolveDevPort, validateDevPort, } from "../lib/resolve-init-dev-port.js";
26
+ import { DEFAULT_SERVER_URL, PRODUCTION_SERVER_URL } from "../lib/constants.js";
10
27
  import { divider, error, info, keyValue, success, warn, } from "../lib/output.js";
11
- import { gitInitialCommit, gitInitRepo, installPnpmGlobally, installPrimitiveCli, isDirectoryEmpty, isPnpmInstalled, isPortFree, isPrimitiveCliInstalled, isXcodeInstalled, iosTemplateUrl, vueTemplateUrl, resolveTemplateBranch, prepareTemplate, runPnpmInstall, runSwiftPackageResolve, updateEnvFile, updateEnvPort, updatePrimitiveJson, updateViteConfig, } from "../lib/template.js";
12
- const DEFAULT_SERVER_URL = process.env.PRIMITIVE_SERVER_URL || "https://primitiveapi.com";
28
+ import { installCommand, PRIMITIVE_CLI_PACKAGE } from "../lib/package-manager.js";
29
+ import { gitInitialCommit, gitInitRepo, installPnpmGlobally, installPrimitiveCli, isDirectoryEmpty, isPnpmInstalled, isPortFree, isPrimitiveCliInstalled, isXcodeInstalled, iosTemplateUrl, vueTemplateUrl, resolveTemplateBranch, prepareTemplate, runPnpmInstall, runSwiftPackageResolve, updateEnvFile, updateEnvPort, configurePrimitiveJson, updateViteConfig, } from "../lib/template.js";
13
30
  /**
14
31
  * The .gitignore lines `init` writes for Primitive CLI local state.
15
32
  *
@@ -24,56 +41,94 @@ const DEFAULT_SERVER_URL = process.env.PRIMITIVE_SERVER_URL || "https://primitiv
24
41
  * the restored `.primitive-sync.json` baseline — can never surface as untracked
25
42
  * files in a tracked custom config dir.
26
43
  */
44
+ /**
45
+ * The origin a local dev server is served from. ONE spelling: the app's CORS
46
+ * origins, its `emailRedirectUris` seed and the environment's `webUrl` all
47
+ * name this, and a second spelling would be a 400 on the scaffolded app's
48
+ * first sign-in (#2982).
49
+ */
50
+ export function localDevOrigin(port) {
51
+ return `http://localhost:${port}`;
52
+ }
53
+ /**
54
+ * Point an existing environment at the app's web counterpart.
55
+ *
56
+ * Returns false — and writes nothing — when there is no config to write, the
57
+ * environment is not in it, or the file cannot be read: an adopting run does
58
+ * not own the project config, and a seed is never worth failing an init over.
59
+ */
60
+ export function seedEnvironmentWebUrl(projectRoot, environment, webUrl) {
61
+ const configPath = join(projectRoot, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
62
+ if (!existsSync(configPath))
63
+ return false;
64
+ let config;
65
+ try {
66
+ config = loadProjectConfigFromPath(configPath);
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ const entry = config.environments[environment];
72
+ if (!entry)
73
+ return false;
74
+ if (entry.webUrl === webUrl)
75
+ return true;
76
+ entry.webUrl = webUrl;
77
+ saveProjectConfig(configPath, config);
78
+ return true;
79
+ }
80
+ /**
81
+ * Make sure this app allows the dev sign-in callback, so pointing the
82
+ * environment at the web client cannot break email sign-in (#2982).
83
+ *
84
+ * Returns true when the callback is on the app's `emailRedirectUris` — already
85
+ * there, or merged in here. Returns false when it is not and this run must not
86
+ * put it there: no credentials to ask the server with, an app whose allow-list
87
+ * would be NARROWED by writing one (see `planEmailRedirectUriMerge`), or an API
88
+ * call that failed. A false answer costs nothing but the automatic seed: the
89
+ * caller prints the manual step instead, and the app keeps working code-only.
90
+ */
91
+ async function devCallbackIsAllowListed(appId, callbackUri, credentials) {
92
+ if (!credentials)
93
+ return false;
94
+ try {
95
+ const apiClient = new ApiClient();
96
+ const settings = await apiClient.getAppSettings(appId);
97
+ const plan = planEmailRedirectUriMerge(settings?.emailRedirectUris, callbackUri);
98
+ if (plan.action === "already-listed")
99
+ return true;
100
+ if (plan.action === "leave-alone") {
101
+ warn(`Not touching this app's sign-in link allow-list: ${plan.reason}.`);
102
+ return false;
103
+ }
104
+ await apiClient.updateAppSettings(appId, { emailRedirectUris: plan.uris });
105
+ success(`Allow-listed the dev sign-in link: ${callbackUri}`);
106
+ return true;
107
+ }
108
+ catch (err) {
109
+ warn(`Could not check this app's sign-in link allow-list: ${err.message}`);
110
+ return false;
111
+ }
112
+ }
27
113
  export const GITIGNORE_LOCAL_STATE_ENTRIES = [
28
114
  ".primitive/credentials.json",
115
+ // This machine's Primitive environment selection (`primitive env use`).
116
+ // The committed `defaultEnvironment` is the team default; the selection is
117
+ // per-machine and must never be committable (#2873).
118
+ ".primitive/local.json",
29
119
  ".primitive/sync/**/.primitive-sync.json",
30
120
  ".primitive/sync-backups/",
31
121
  ".snapshots/",
32
122
  "config-snapshots/",
33
123
  ];
34
124
  /**
35
- * Writes .primitive/config.json into the project directory and ensures the
36
- * .gitignore entry for local state exists. Safe to call in a directory that
37
- * already has a config in that case we add/update the "dev" environment
38
- * instead of overwriting the file.
125
+ * Ensures the project root's .gitignore lists the Primitive CLI's local state.
126
+ *
127
+ * Split out of `scaffoldProjectConfig` because a run that ADOPTS an existing
128
+ * project (#2890) must not rewrite its config, but still has to make sure the
129
+ * per-machine state a new client's environment produces cannot be committed.
39
130
  */
40
- async function scaffoldProjectConfig(opts) {
41
- const configPath = join(opts.targetDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
42
- let config;
43
- if (existsSync(configPath)) {
44
- // Respect an existing config: merge in the new dev env if missing.
45
- try {
46
- config = loadProjectConfigFromPath(configPath);
47
- }
48
- catch {
49
- // Existing file is broken — don't clobber it; just warn and skip.
50
- warn(`${PROJECT_CONFIG_DISPLAY_NAME} exists but is invalid. Skipping project config scaffold.`);
51
- return;
52
- }
53
- if (!config.environments.dev) {
54
- config.environments.dev = {
55
- apiUrl: opts.serverUrl,
56
- appId: opts.appId,
57
- appName: opts.appName,
58
- };
59
- if (!config.defaultEnvironment)
60
- config.defaultEnvironment = "dev";
61
- }
62
- }
63
- else {
64
- config = {
65
- version: CURRENT_CONFIG_VERSION,
66
- defaultEnvironment: "dev",
67
- environments: {
68
- dev: {
69
- apiUrl: opts.serverUrl,
70
- appId: opts.appId,
71
- appName: opts.appName,
72
- },
73
- },
74
- };
75
- }
76
- saveProjectConfig(configPath, config);
131
+ export function ensureGitignoreLocalState(targetDir) {
77
132
  // Ensure .primitive/ local state (credentials, per-machine sync state) is
78
133
  // gitignored. The config.json itself lives in .primitive/ too, but we
79
134
  // gitignore only the sensitive / per-machine artifacts — config.json and
@@ -86,7 +141,7 @@ async function scaffoldProjectConfig(opts) {
86
141
  // which incorrectly hid the TOML export tree from `git status`. If we see
87
142
  // that legacy entry, replace it with the targeted pattern in-place so
88
143
  // users who re-run init pick up the fix.
89
- const gitignorePath = join(opts.targetDir, ".gitignore");
144
+ const gitignorePath = join(targetDir, ".gitignore");
90
145
  const LEGACY_SYNC_IGNORE = ".primitive/sync/";
91
146
  const gitignoreEntries = [...GITIGNORE_LOCAL_STATE_ENTRIES];
92
147
  let linesToAdd = [];
@@ -131,18 +186,52 @@ async function scaffoldProjectConfig(opts) {
131
186
  // Non-fatal
132
187
  }
133
188
  }
134
- success(`Wrote ${PROJECT_CONFIG_DISPLAY_NAME} with dev environment`);
135
189
  }
136
- function validateServerUrl(url) {
137
- if (!url.trim())
138
- return "Server URL cannot be empty";
139
- try {
140
- new URL(url);
141
- return true;
190
+ /**
191
+ * Writes .primitive/config.json into the project directory and ensures the
192
+ * .gitignore entry for local state exists. Safe to call in a directory that
193
+ * already has a config — in that case we add/update the "dev" environment
194
+ * instead of overwriting the file.
195
+ */
196
+ async function scaffoldProjectConfig(opts) {
197
+ const configPath = join(opts.targetDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
198
+ let config;
199
+ if (existsSync(configPath)) {
200
+ // Respect an existing config: merge in the new dev env if missing.
201
+ try {
202
+ config = loadProjectConfigFromPath(configPath);
203
+ }
204
+ catch {
205
+ // Existing file is broken — don't clobber it; just warn and skip.
206
+ warn(`${PROJECT_CONFIG_DISPLAY_NAME} exists but is invalid. Skipping project config scaffold.`);
207
+ return;
208
+ }
209
+ if (!config.environments.dev) {
210
+ config.environments.dev = {
211
+ apiUrl: opts.serverUrl,
212
+ appId: opts.appId,
213
+ appName: opts.appName,
214
+ };
215
+ if (!config.defaultEnvironment)
216
+ config.defaultEnvironment = "dev";
217
+ }
142
218
  }
143
- catch {
144
- return "Please enter a valid URL (e.g., https://example.com)";
219
+ else {
220
+ config = {
221
+ version: CURRENT_CONFIG_VERSION,
222
+ defaultEnvironment: "dev",
223
+ environments: {
224
+ dev: {
225
+ apiUrl: opts.serverUrl,
226
+ appId: opts.appId,
227
+ appName: opts.appName,
228
+ },
229
+ },
230
+ };
145
231
  }
232
+ saveProjectConfig(configPath, config);
233
+ ensureGitignoreLocalState(opts.targetDir);
234
+ success(`Wrote ${PROJECT_CONFIG_DISPLAY_NAME} with dev environment`);
146
235
  }
147
236
  /**
148
237
  * Prompts for login when credentials are missing or the server has changed.
@@ -195,6 +284,23 @@ async function ensureAuthenticated(credentials, serverUrl, serverChanged, contex
195
284
  success(`Logged in as ${result.credentials.email}`);
196
285
  return result.credentials;
197
286
  }
287
+ /**
288
+ * Finds the name of the project-config environment (if any) whose `apiUrl`
289
+ * matches `serverUrl`, so the project-mode "not logged in" remedy can name a
290
+ * real, working environment instead of a placeholder. Comparison uses the
291
+ * same canonicalization as `resolveInitServer` (trailing-slash-insensitive)
292
+ * so `https://host/` in `.primitive/config.json` still matches.
293
+ */
294
+ function findEnvironmentForServer(serverUrl) {
295
+ const project = getProjectConfig();
296
+ if (!project)
297
+ return undefined;
298
+ const canonicalTarget = normalizeServerUrl(serverUrl) ?? serverUrl;
299
+ return Object.keys(project.environments).find((name) => {
300
+ const apiUrl = project.environments[name].apiUrl;
301
+ return (normalizeServerUrl(apiUrl) ?? apiUrl) === canonicalTarget;
302
+ });
303
+ }
198
304
  /**
199
305
  * Loads the init config from --config flag or auto-discovers it in CWD.
200
306
  * Returns null if no config file is found/specified.
@@ -245,55 +351,97 @@ export function registerInitCommand(program) {
245
351
  .option("-s, --server <url>", "Server URL (default: current CLI server or https://primitiveapi.com)")
246
352
  .option("--skip-install", "Skip dependency installation")
247
353
  .option("--config <path>", `Path to an init config file for non-interactive setup (default: auto-discover ${INIT_CONFIG_FILENAME} in the current directory)`)
248
- .option("-p, --platform <platform>", "Target platform: web or ios (SwiftUI). Omit to be prompted interactively.")
354
+ .option("-p, --platform <platforms>", "Client platform(s) to scaffold: web, ios (SwiftUI), or both as a comma-separated list (web,ios). Omit to be prompted interactively.")
249
355
  .addHelpText("after", `
250
356
  Examples:
251
357
  $ primitive init my-app # Create web app in ./my-app
252
358
  $ primitive init # Interactive: prompt for directory and app name
253
359
  $ primitive init my-app --platform ios # Create an iOS (SwiftUI) app
360
+ $ primitive init my-app --platform web,ios # One app, a web client and an iOS client
361
+ $ primitive init ios --platform ios # Add an iOS client to the app of the surrounding project
254
362
  $ primitive init . --use-existing-app-id 01HXY... # Use existing app in current dir
255
363
  $ primitive init my-app --skip-install # Skip dependency install
256
364
  $ primitive init --config .primitive-init.toml # Non-interactive from config file
257
365
 
366
+ One app, several clients:
367
+ Selecting more than one platform scaffolds them side by side under one project:
368
+ each client in its own directory (web/, ios/), with .primitive/config.json, the
369
+ git repository and the shared models/models.toml at the root. Run inside a
370
+ directory that already has an ancestor .primitive/config.json and init ADDS the
371
+ client to that app instead: it reuses the selected environment's app, writes no
372
+ nested .primitive/ or .git/, makes no commit, and leaves the files for you to
373
+ review and commit in the outer repository.
374
+
258
375
  Non-interactive mode:
259
376
  Create a ${INIT_CONFIG_FILENAME} file (or pass --config <path>) to skip all prompts.
260
377
  The config file uses TOML format:
261
378
 
262
- action = "create" # "create" or "use-existing"
379
+ action = "create" # "create", "use-existing", or "add-client"
263
380
  app_name = "My App" # Required for action = "create"
264
381
  app_id = "01HXY..." # Required for action = "use-existing"
265
382
  dir = "my-app" # Project directory (optional)
266
- platform = "web" # "web" or "ios" (optional, defaults to web)
383
+ platform = "web" # "web", "ios", "web,ios", or ["web", "ios"]
384
+ # (optional, defaults to web)
385
+ promote_schema = false # Only with action = "add-client". Consent to move
386
+ # an existing single-client repo's models.toml to
387
+ # <project root>/models/models.toml so every client
388
+ # reads one copy.
267
389
  access_mode = "invite-only" # "public", "domain", or "invite-only" (optional)
268
- dev_port = 5173 # Dev server port (optional)
269
- skip_install = false # Skip pnpm install (optional)
270
- server = "https://..." # Custom server URL (optional)
390
+ dev_port = 5173 # Dev server port (optional). Set it to skip the
391
+ # port prompt: init pins the port in
392
+ # vite.config.ts and .env and adds
393
+ # http://localhost:<port> to the app's CORS
394
+ # allowed origins and redirect URIs.
395
+ skip_install = false # Skip pnpm install (optional). Same effect as
396
+ # --skip-install; the flag wins if both are set.
397
+ server = "https://..." # Server to create the app on (optional).
398
+ # Same effect as -s/--server; the flag wins if
399
+ # both are set. If the CLI already points at a
400
+ # different server, init stops and tells you how
401
+ # to point it here first. Outside a Primitive
402
+ # project, if you are not logged in at all, init
403
+ # logs in to this one; inside a project the
404
+ # selected environment's apiUrl is the target
405
+ # even when logged out, so a different value
406
+ # here stops the run.
271
407
  invite_emails = ["a@b.com"] # Emails to invite (optional, for invite-only)
272
408
  allowed_domains = ["b.com"] # Allowed domains (optional, for domain mode)
273
409
  overwrite = false # Overwrite non-empty target directory (optional)
274
410
 
275
411
  This command will:
276
- 1. Download the Primitive app template (web or Apple SwiftUI)
277
- 2. Prompt for an app name and create it on the Primitive server (or use existing)
278
- 3. Configure the project with your app ID
412
+ 1. Download a Primitive app template for each selected platform (web, Apple SwiftUI)
413
+ 2. Prompt for an app name and create it on the Primitive server (or use existing,
414
+ or adopt the app of the project it is run inside)
415
+ 3. Configure the project with your app ID and point every client at one model schema
279
416
  4. Install dependencies (pnpm for web, swift package resolve for Apple)
280
417
  `)
281
418
  .action(handleInit);
282
419
  }
283
420
  async function handleInit(projectDirArg, options) {
284
421
  // Validate an explicitly-passed `--platform` flag up front. Only `web` and
285
- // `ios` are supported (#1009 removed `macos`). An omitted flag is
286
- // `undefined` (the commander default was dropped) and is resolved below.
287
- if (options.platform !== undefined &&
288
- options.platform !== "web" &&
289
- options.platform !== "ios") {
290
- error(`Unknown platform "${options.platform}". Supported platforms: web, ios`);
291
- process.exit(1);
422
+ // `ios` are supported (#1009 removed `macos`), and the flag names a LIST —
423
+ // `--platform web,ios` scaffolds both clients of one app (#2890). An omitted
424
+ // flag is `undefined` (the commander default was dropped) and is resolved
425
+ // below.
426
+ let flagPlatforms;
427
+ if (options.platform !== undefined) {
428
+ const parsed = parsePlatformList(options.platform);
429
+ if (!parsed.ok) {
430
+ error(parsed.error);
431
+ process.exit(1);
432
+ }
433
+ flagPlatforms = parsed.platforms;
292
434
  }
293
435
  // `initConfig` is the parsed-and-validated `primitive-init.toml` for
294
436
  // non-interactive runs (CI, scripted scaffolding). When non-null, the
295
437
  // rest of this function uses it to skip prompts.
296
438
  const initConfig = loadInitConfigFromOptions(options);
439
+ // Whether to skip the dependency install (steps 7-8). Both the
440
+ // `--skip-install` flag and the config's `skip_install` key request it; the
441
+ // flag wins. Reading only the flag is what made the documented config key a
442
+ // no-op (#2720).
443
+ const { skipInstall, source: skipInstallSource } = resolveSkipInstall(options.skipInstall, initConfig);
444
+ const skipInstallReason = skipInstallSource === "config" ? "skip_install = true in config" : "--skip-install";
297
445
  // Resolve the target platform per the 4-case interactivity contract:
298
446
  // (a) explicit --platform flag -> use it (never prompt)
299
447
  // (b) init-config TOML present -> use its platform or fall back to web
@@ -301,23 +449,30 @@ async function handleInit(projectDirArg, options) {
301
449
  // (d) non-TTY, no flag/cfg -> fall back to web (no hang/crash)
302
450
  // The prompt is TTY-guarded inside `resolvePlatform` so a piped/CI stdin
303
451
  // never triggers the `@inquirer/prompts` `ERR_USE_AFTER_CLOSE` crash.
304
- const platform = await resolvePlatform({
305
- flag: options.platform,
306
- configPlatform: initConfig?.platform,
452
+ // The init config's `platform` was validated above, so it parses.
453
+ const configPlatforms = initConfig?.platform !== undefined
454
+ ? parsePlatformList(initConfig.platform).platforms
455
+ : undefined;
456
+ const platforms = await resolvePlatforms({
457
+ flag: flagPlatforms,
458
+ configPlatform: configPlatforms,
307
459
  hasConfig: initConfig != null,
308
460
  isTTY: Boolean(process.stdin.isTTY),
309
- promptFn: () => select({
310
- message: "What platform are you building for?",
461
+ promptFn: () => checkbox({
462
+ message: "Which clients are you building? (space to select)",
311
463
  choices: [
312
- { value: "web", name: "web (Vue 3 + TypeScript + Tailwind)" },
464
+ { value: "web", name: "web (Vue 3 + TypeScript + Tailwind)", checked: true },
313
465
  { value: "ios", name: "ios (Swift + SwiftUI)" },
314
466
  ],
315
- default: "web",
467
+ // A run with nothing selected has nothing to scaffold; re-ask rather
468
+ // than proceeding with an empty list.
469
+ validate: (choices) => choices.length > 0 ? true : "Select at least one client.",
316
470
  }),
317
471
  });
318
- // iOS shares the SwiftUI template, Xcode toolchain, and primitive.json
319
- // config path. (macOS was removed in #1009.)
320
- const isApple = platform === "ios";
472
+ // iOS brings the SwiftUI template, the Xcode toolchain and the
473
+ // primitive.json config path whether it is the only client or one of
474
+ // several. (macOS was removed in #1009.)
475
+ const isApple = platforms.includes("ios");
321
476
  // Apple-family platforms require macOS with Xcode.
322
477
  if (isApple && process.platform !== "darwin") {
323
478
  error("iOS apps require macOS with Xcode installed.");
@@ -331,14 +486,27 @@ async function handleInit(projectDirArg, options) {
331
486
  }
332
487
  }
333
488
  // Step 1: Determine project directory
489
+ //
490
+ // Run from inside an existing Primitive app, one platform goes in a
491
+ // directory named after itself (`ios/`, `web/`) rather than in a new
492
+ // project's. That default is also what makes an `add-client` init config
493
+ // non-interactive without a `dir` key (#2890). A run selecting several
494
+ // platforms names the directory their subdirectories go under, which has no
495
+ // obvious default, so it is asked for as before.
334
496
  let projectDir = projectDirArg;
335
497
  if (!projectDir && initConfig?.dir) {
336
498
  projectDir = initConfig.dir;
337
499
  }
500
+ const clientDirDefault = platforms.length === 1 && findProjectRoot(process.cwd()) ? platforms[0] : null;
501
+ if (!projectDir && clientDirDefault && initConfig) {
502
+ projectDir = clientDirDefault;
503
+ }
338
504
  if (!projectDir) {
339
505
  projectDir = await input({
340
- message: "Project name (a new directory will be created):",
341
- default: "my-primitive-app",
506
+ message: clientDirDefault
507
+ ? "Directory for the new client:"
508
+ : "Project name (a new directory will be created):",
509
+ default: clientDirDefault ?? "my-primitive-app",
342
510
  validate: (value) => {
343
511
  if (!value.trim())
344
512
  return "Project name cannot be empty";
@@ -349,19 +517,53 @@ async function handleInit(projectDirArg, options) {
349
517
  const targetDir = resolve(process.cwd(), projectDir);
350
518
  // Used as the default app name suggestion when prompting
351
519
  const defaultAppName = projectDir === "." ? basename(targetDir) : basename(projectDir);
352
- // Step 2: Check if directory exists and is non-empty
353
- if (existsSync(targetDir) && !isDirectoryEmpty(targetDir)) {
520
+ // ── Which layout is this run producing? (#2890) ────────────────────────
521
+ //
522
+ // A `.primitive/config.json` in an ANCESTOR of the target means this app
523
+ // already exists and this run adds a client to it. Passing the ancestor only
524
+ // when it differs from the target keeps `primitive init .` at a project root
525
+ // on its existing merge behavior.
526
+ const ancestorRoot = findProjectRoot(targetDir);
527
+ const adoptedRoot = ancestorRoot && resolve(ancestorRoot) !== resolve(targetDir) ? ancestorRoot : null;
528
+ const plan = planInit({
529
+ platforms,
530
+ targetDir,
531
+ ancestorProjectRoot: adoptedRoot,
532
+ });
533
+ // The adopted app's identity comes from the project config's selected
534
+ // environment — never from a flag or a prompt.
535
+ let adopted = null;
536
+ if (adoptedRoot) {
537
+ try {
538
+ adopted = resolveAdoptedEnvironment(adoptedRoot, {
539
+ explicitEnvName: getExplicitEnvName(),
540
+ envVarName: process.env.PRIMITIVE_ENV || null,
541
+ });
542
+ }
543
+ catch (err) {
544
+ error(`Could not read the Primitive project at ${adoptedRoot}: ${err.message}`);
545
+ warn("No project files were written.");
546
+ process.exit(1);
547
+ }
548
+ }
549
+ // Step 2: Check if the target — and each client directory under it — exists
550
+ // and is non-empty. Each directory follows the same overwrite contract.
551
+ const directoriesToGuard = [targetDir, ...plan.clients.map((c) => c.dir)].filter((dir, index, all) => all.indexOf(dir) === index);
552
+ for (const dir of directoriesToGuard) {
553
+ if (!existsSync(dir) || isDirectoryEmpty(dir))
554
+ continue;
555
+ const label = dir === targetDir ? projectDir : relative(process.cwd(), dir);
354
556
  if (initConfig) {
355
557
  // Non-interactive: require explicit overwrite opt-in
356
558
  if (!initConfig.overwrite) {
357
- error(`Directory "${projectDir}" already exists and is not empty.`);
559
+ error(`Directory "${label}" already exists and is not empty.`);
358
560
  error('Set overwrite = true in your config file to allow overwriting.');
359
561
  process.exit(1);
360
562
  }
361
- warn(`Directory "${projectDir}" already exists and is not empty. Overwriting (overwrite = true in config).`);
563
+ warn(`Directory "${label}" already exists and is not empty. Overwriting (overwrite = true in config).`);
362
564
  }
363
565
  else {
364
- warn(`Directory "${projectDir}" already exists and is not empty.`);
566
+ warn(`Directory "${label}" already exists and is not empty.`);
365
567
  const overwrite = await confirm({
366
568
  message: "Do you want to overwrite the existing contents?",
367
569
  default: false,
@@ -372,34 +574,53 @@ async function handleInit(projectDirArg, options) {
372
574
  }
373
575
  }
374
576
  }
375
- // Step 2.5: Determine target server
376
- let serverUrl;
377
- let serverChanged = false;
378
- if (options.server) {
577
+ // Step 2.5: Determine target server. Precedence: `--server` flag, then the
578
+ // init-config `server` key, then the current login, then the default. The
579
+ // config key used to be dropped on the floor here (#2376).
580
+ if (options.server !== undefined) {
379
581
  const validation = validateServerUrl(options.server);
380
582
  if (validation !== true) {
381
583
  error(validation);
382
584
  process.exit(1);
383
585
  }
384
- serverUrl = options.server;
385
- // Check if explicit --server differs from current credentials
386
- const existingCredentials = loadCredentials();
387
- if (existingCredentials?.serverUrl &&
388
- existingCredentials.serverUrl !== options.server) {
389
- serverChanged = true;
390
- }
391
586
  }
392
- else {
393
- const existingCredentials = loadCredentials();
394
- const currentServer = existingCredentials?.serverUrl;
395
- if (currentServer && currentServer !== DEFAULT_SERVER_URL && !initConfig) {
587
+ const existingCredentials = loadCredentials();
588
+ // In project mode the CLI is pinned to the selected environment's `apiUrl`
589
+ // even before that environment has any credentials: `saveCredentialsStore()`
590
+ // writes tokens into the environment's slot and discards the server they were
591
+ // minted for, and `loadCredentialsStore()` blends them back with
592
+ // `env.config.apiUrl`. So "logged out" does not mean "no current server"
593
+ // there — the environment still fixes the target, and the mismatch check
594
+ // below has to compare against it. Reading only the credentials' `serverUrl`
595
+ // let a logged-out environment slip past the guard: init would log in to the
596
+ // config's server and then send every later call to the environment's own
597
+ // `apiUrl`.
598
+ const currentEnvironment = getCurrentEnvironment();
599
+ // When adopting, the app already lives somewhere: the environment being
600
+ // adopted fixes the server, and a `--server` that disagrees is a mistake
601
+ // rather than a choice.
602
+ const currentServer = adopted
603
+ ? adopted.config.apiUrl
604
+ : existingCredentials?.serverUrl ?? currentEnvironment?.config.apiUrl;
605
+ const resolvedServer = await resolveInitServer({
606
+ flagServer: options.server,
607
+ configServer: initConfig?.server,
608
+ hasConfig: initConfig != null,
609
+ currentServer,
610
+ defaultServer: DEFAULT_SERVER_URL,
611
+ // An adopt run has nowhere else to put the client: the environment it is
612
+ // adopting names the server, so there is nothing to ask about.
613
+ isTTY: Boolean(process.stdin.isTTY) && plan.mode !== "add-client",
614
+ promptFn: async () => {
615
+ // Only reached when a current server exists (see resolveInitServer).
616
+ const current = currentServer ?? DEFAULT_SERVER_URL;
396
617
  console.log();
397
618
  const serverChoice = await select({
398
- message: `The Primitive CLI is currently using ${currentServer}.\nWhere do you want to create this app?`,
619
+ message: `The Primitive CLI is currently using ${current}.\nWhere do you want to create this app?`,
399
620
  choices: [
400
621
  {
401
- value: currentServer,
402
- name: `Current server (${currentServer})`,
622
+ value: current,
623
+ name: `Current server (${current})`,
403
624
  },
404
625
  {
405
626
  value: DEFAULT_SERVER_URL,
@@ -412,60 +633,195 @@ async function handleInit(projectDirArg, options) {
412
633
  ],
413
634
  default: currentServer,
414
635
  });
415
- if (serverChoice === "__custom__") {
416
- serverUrl = await input({
417
- message: "Enter the server URL:",
418
- validate: validateServerUrl,
419
- });
636
+ if (serverChoice !== "__custom__")
637
+ return serverChoice;
638
+ return input({
639
+ message: "Enter the server URL:",
640
+ validate: validateServerUrl,
641
+ });
642
+ },
643
+ });
644
+ const serverUrl = resolvedServer.serverUrl;
645
+ const serverChanged = resolvedServer.serverChanged;
646
+ if (resolvedServer.source === "config") {
647
+ info(`Server: ${serverUrl} (from config)`);
648
+ }
649
+ // A run that can't get usable credentials for the resolved server must fail
650
+ // loudly here rather than hang on the OAuth callback — or, as in #2376,
651
+ // quietly build the app on the current server. Two cases qualify:
652
+ //
653
+ // * a non-interactive init-config run, which has no way to drive a browser;
654
+ // * any project-mode run, because a login always lands in the selected
655
+ // environment's slot and is read back with that environment's `apiUrl`.
656
+ // Logging in to a different server there would leave the project holding
657
+ // credentials the rest of the CLI sends somewhere else.
658
+ //
659
+ // In legacy mode an interactive run can still log in to the requested server
660
+ // and have it stored correctly, so it goes through the login flow below.
661
+ const inProjectMode = isProjectMode() || plan.mode === "add-client";
662
+ if (serverChanged && (initConfig || inProjectMode)) {
663
+ const requestedBy = resolvedServer.source === "flag"
664
+ ? "--server"
665
+ : resolvedServer.source === "config"
666
+ ? "the init config's `server`"
667
+ : "the server you selected";
668
+ // `currentServer` is the stored login's server when there is one, and
669
+ // otherwise the selected environment's `apiUrl` — say which, so the user
670
+ // isn't told they're "logged in to" a server they never logged in to.
671
+ const currentDescription = existingCredentials
672
+ ? `the CLI is logged in to ${currentServer}`
673
+ : `this project's "${adopted?.name ?? currentEnvironment?.name}" environment targets ${currentServer}`;
674
+ error(`Not logged in to ${serverUrl}.`);
675
+ error(`${requestedBy} requests ${serverUrl}, but ${currentDescription}.`);
676
+ // The remedy differs by mode: `primitive login` resolves its target
677
+ // through `getConfiguredServerUrl()`, which returns the active
678
+ // environment's `apiUrl` whenever a project config is in scope and ignores
679
+ // `PRIMITIVE_SERVER_URL` there. Printing the legacy-mode hint inside a
680
+ // project would send the user in circles.
681
+ if (inProjectMode) {
682
+ error(`Inside a Primitive project, \`primitive login\` targets the selected environment's server, so PRIMITIVE_SERVER_URL has no effect here.`);
683
+ // `primitive -e <env> login` only resolves the mismatch if `<env>` is
684
+ // both logged into AND passed to the init re-run — `currentServer` is
685
+ // always the *selected* environment's apiUrl (that's what made this
686
+ // serverChanged in the first place), so re-running `primitive login`
687
+ // or `primitive init` without `-e` reproduces the identical error.
688
+ // Look up which configured environment (if any) actually targets
689
+ // `serverUrl` so the hint names a real, working command instead of a
690
+ // placeholder.
691
+ const targetEnvName = findEnvironmentForServer(serverUrl);
692
+ // The re-run has to resolve to the same server, and it re-reads every
693
+ // input from scratch. Only a config-selected server survives on its own:
694
+ // when the flag (or the interactive prompt) is what won here, an
695
+ // otherwise-identical re-run would fall back to the config's `server` —
696
+ // or prompt again — and reproduce this exact error. Pin it with
697
+ // `--server`, which outranks both.
698
+ const serverFlag = resolvedServer.source === "config" ? "" : ` --server ${serverUrl}`;
699
+ if (targetEnvName) {
700
+ const configFlag = options.config ? ` --config ${options.config}` : "";
701
+ error(`Log in to the "${targetEnvName}" environment and run init against it: ` +
702
+ `\`primitive -e ${targetEnvName} login\` then ` +
703
+ `\`primitive -e ${targetEnvName} init${configFlag}${serverFlag}\`.`);
420
704
  }
421
705
  else {
422
- serverUrl = serverChoice;
706
+ error(`This project has no environment configured for ${serverUrl}. ` +
707
+ `Add one (\`primitive env add <name> --api-url ${serverUrl}\`) and ` +
708
+ `re-run with \`-e <name>${serverFlag}\`, or re-run init outside the project directory.`);
423
709
  }
424
- serverChanged = currentServer !== serverUrl;
425
710
  }
426
711
  else {
427
- serverUrl = currentServer || DEFAULT_SERVER_URL;
712
+ error(`Log in to the target server first: PRIMITIVE_SERVER_URL=${serverUrl} primitive login`);
428
713
  }
714
+ warn("No project files were written.");
715
+ process.exit(1);
429
716
  }
430
- // Step 4: Check authentication
431
- let credentials = loadCredentials();
717
+ // Everything this run must be refused for, before a download or a write.
718
+ const guardError = checkInitGuards({
719
+ action: initConfig?.action,
720
+ hasConfig: initConfig != null,
721
+ ancestorProjectRoot: adoptedRoot,
722
+ configAppId: initConfig?.app_id,
723
+ flagAppId: options.useExistingAppId,
724
+ environmentAppId: adopted?.config.appId,
725
+ environmentName: adopted?.name,
726
+ });
727
+ if (guardError) {
728
+ error(guardError);
729
+ warn("No project files were written.");
730
+ process.exit(1);
731
+ }
732
+ if (plan.mode === "add-client") {
733
+ info(`Found an existing Primitive project at ${adoptedRoot} (environment "${adopted.name}").`);
734
+ // A run driven by a config already said what it wants; anything else asks,
735
+ // and a piped stdin gets the key that says it non-interactively rather than
736
+ // `confirmPrompt`'s `--yes` remedy, which init has no flag for.
737
+ if (!initConfig) {
738
+ if (!process.stdin.isTTY) {
739
+ error(`Adding a client to the project at ${adoptedRoot} needs a confirmation, and stdin is not interactive.`);
740
+ error(`Re-run with an init config containing \`action = "add-client"\` to confirm non-interactively.`);
741
+ warn("No project files were written.");
742
+ process.exit(1);
743
+ }
744
+ const proceed = await confirm({
745
+ message: `Add this client to app ${adopted.config.appName ?? adopted.config.appId} in ${adoptedRoot}?`,
746
+ default: true,
747
+ });
748
+ if (!proceed) {
749
+ error("Aborted. No project files were written.");
750
+ process.exit(1);
751
+ }
752
+ }
753
+ }
754
+ // ── The one schema (#2890) ─────────────────────────────────────────────
755
+ //
756
+ // Decided here, before anything is downloaded or written, because the answer
757
+ // can be "stop": an adopted repository whose client keeps its schema
758
+ // somewhere this run does not recognize, or one whose owner declines the
759
+ // move, must end with nothing changed.
760
+ const schemaPlan = plan.canonicalSchemaPath
761
+ ? await resolveSchemaComposition({
762
+ plan,
763
+ promoteConsent: initConfig?.promote_schema === true,
764
+ interactive: initConfig == null && Boolean(process.stdin.isTTY),
765
+ nonInteractive: initConfig != null,
766
+ })
767
+ : null;
768
+ // Step 4: Check authentication (credentials were loaded for the server
769
+ // resolution above — reuse them rather than re-reading the file).
770
+ let credentials = existingCredentials;
432
771
  let appId;
433
772
  let appName;
434
- let preparedTemplate = null;
773
+ /**
774
+ * Whether THIS run created the app. A created app is seeded with
775
+ * `http://localhost:5173/oauth/callback` in `emailRedirectUris`; an adopted
776
+ * one carries whatever allow-list it already had, which is why the combined
777
+ * app's `webUrl` seed asks the server before pointing the sign-in link at a
778
+ * dev callback (#2982).
779
+ */
780
+ let appWasCreatedByThisRun = false;
781
+ const preparedTemplates = new Map();
435
782
  const cleanupPreparedTemplate = () => {
436
- try {
437
- preparedTemplate?.cleanup();
438
- }
439
- catch {
440
- // Non-fatal cleanup best effort
783
+ for (const prepared of preparedTemplates.values()) {
784
+ try {
785
+ prepared.cleanup();
786
+ }
787
+ catch {
788
+ // Non-fatal cleanup best effort
789
+ }
441
790
  }
442
- preparedTemplate = null;
791
+ preparedTemplates.clear();
443
792
  };
444
793
  const prepareTemplateOrExit = async () => {
445
- if (preparedTemplate)
446
- return;
447
- // Download and validate the archive before app creation, but keep it staged
448
- // in a temp directory. That avoids both failure modes: app-creation failure
449
- // leaves no scaffold, and template-download failure leaves no orphaned app.
450
- info(`Preparing your ${platform === "ios" ? "iOS" : "web"} project template...`);
451
- // The branch is the channel (issue #1934): scaffolding against the alpha
452
- // server pulls the alpha template branch, whose Primitive deps pin the
453
- // alpha prerelease libraries. Falls back to main (with a warning) if the
454
- // channel branch isn't available.
455
- const templateBranch = resolveTemplateBranch(serverUrl);
456
- const templateUrlFor = isApple ? iosTemplateUrl : vueTemplateUrl;
457
- try {
458
- preparedTemplate = await prepareTemplate({
459
- templateUrl: templateUrlFor(templateBranch),
460
- fallbackTemplateUrl: templateBranch !== "main" ? templateUrlFor("main") : undefined,
461
- onFallback: (reason) => warn(`Template branch "${templateBranch}" unavailable (${reason}) — falling back to main.`),
462
- onProgress: info,
463
- });
464
- }
465
- catch (err) {
466
- error(`Failed to download template: ${err.message}`);
467
- error("Check your internet connection and try again.");
468
- process.exit(1);
794
+ // EVERY selected template downloads and validates before the app is
795
+ // created, and none of them touches the target directory yet. That keeps
796
+ // both failure modes covered with several clients in play: app-creation
797
+ // failure leaves no scaffold, and a download failure of either template —
798
+ // leaves no orphaned app and nothing half-written (#1218, #2890).
799
+ for (const client of plan.clients) {
800
+ if (preparedTemplates.has(client.platform))
801
+ continue;
802
+ info(`Preparing your ${platformLabel(client.platform)} project template...`);
803
+ // The branch is the channel (issue #1934): scaffolding against the alpha
804
+ // server pulls the alpha template branch, whose Primitive deps pin the
805
+ // alpha prerelease libraries. Falls back to main (with a warning) if the
806
+ // channel branch isn't available.
807
+ const templateBranch = resolveTemplateBranch(serverUrl);
808
+ const templateUrlFor = client.platform === "ios" ? iosTemplateUrl : vueTemplateUrl;
809
+ try {
810
+ preparedTemplates.set(client.platform, await prepareTemplate({
811
+ templateUrl: templateUrlFor(templateBranch),
812
+ fallbackTemplateUrl: templateBranch !== "main" ? templateUrlFor("main") : undefined,
813
+ onFallback: (reason) => warn(`Template branch "${templateBranch}" unavailable (${reason}) — falling back to main.`),
814
+ onProgress: info,
815
+ }));
816
+ }
817
+ catch (err) {
818
+ error(`Failed to download template: ${err.message}`);
819
+ error("Check your internet connection and try again.");
820
+ // A template that already downloaded must not survive as a temp
821
+ // directory when the run stops here.
822
+ cleanupPreparedTemplate();
823
+ process.exit(1);
824
+ }
469
825
  }
470
826
  };
471
827
  // An existing app can be selected two ways: the `--use-existing-app-id` flag,
@@ -474,7 +830,49 @@ async function handleInit(projectDirArg, options) {
474
830
  // the create branch and prompt for an app name — hanging non-interactive
475
831
  // runs. (#1217)
476
832
  const useExistingAppId = resolveUseExistingAppId(options.useExistingAppId, initConfig);
477
- if (useExistingAppId) {
833
+ if (plan.mode === "add-client") {
834
+ // ── Adopt: the app is the one this project already targets ──────────
835
+ //
836
+ // `appName` is OPTIONAL in a valid environment, so it is resolved rather
837
+ // than assumed: the environment's name, else the app's name from the API
838
+ // when we are authenticated, else the client directory's own name. No
839
+ // downstream writer ever receives an undefined display name, and no
840
+ // scaffold ships an unresolved placeholder.
841
+ appId = adopted.config.appId;
842
+ appName = adopted.config.appName ?? "";
843
+ // Credentials are resolved from the working directory, while the app comes
844
+ // from the project the TARGET sits in — a target inside a different project
845
+ // (or a run from outside any) can leave the CLI holding another server's
846
+ // token, and every API call here would go there with this app's id. The
847
+ // run continues unauthenticated instead: nothing it writes needs the API.
848
+ const credentialServer = credentials ? normalizeServerUrl(credentials.serverUrl) : null;
849
+ if (credentialServer && credentialServer !== normalizeServerUrl(adopted.config.apiUrl)) {
850
+ warn(`The CLI is logged in to ${credentials.serverUrl}, but the "${adopted.name}" environment in ${adoptedRoot} targets ${adopted.config.apiUrl}.`);
851
+ warn(`Continuing without contacting either server. Run init from inside that project, or log in to ${adopted.config.apiUrl}, to have the app verified.`);
852
+ credentials = null;
853
+ }
854
+ else {
855
+ credentials = await ensureAuthenticated(credentials, serverUrl, serverChanged, {
856
+ reason: "verify the app",
857
+ allowSkip: true,
858
+ nonInteractive: !!initConfig,
859
+ });
860
+ }
861
+ if (!appName && credentials) {
862
+ try {
863
+ const client = new ApiClient();
864
+ appName = (await client.getApp(appId)).name;
865
+ }
866
+ catch (err) {
867
+ warn(`Could not look up the app's name: ${err.message}`);
868
+ }
869
+ }
870
+ if (!appName)
871
+ appName = basename(plan.clients[0].dir);
872
+ info(`Adding a ${plan.clients.map((c) => platformLabel(c.platform)).join(" and ")} client to ${appName} (${appId}).`);
873
+ await prepareTemplateOrExit();
874
+ }
875
+ else if (useExistingAppId) {
478
876
  // Using existing app ID - verify it exists
479
877
  appId = useExistingAppId;
480
878
  appName = "(existing app)"; // Default if we can't verify
@@ -528,6 +926,7 @@ async function handleInit(projectDirArg, options) {
528
926
  throw new Error("App created but no appId returned");
529
927
  }
530
928
  appId = id;
929
+ appWasCreatedByThisRun = true;
531
930
  success(`App created: ${appName}`);
532
931
  keyValue("App ID", appId);
533
932
  }
@@ -549,7 +948,7 @@ async function handleInit(projectDirArg, options) {
549
948
  else {
550
949
  console.log();
551
950
  info("Let's configure who can access your app.");
552
- info("(You can change this later with 'primitive apps update')");
951
+ info("(You can change this later in app.toml, then 'primitive config push --only app')");
553
952
  accessMode = await select({
554
953
  message: "Who should be able to sign up for your app?",
555
954
  choices: [
@@ -675,13 +1074,16 @@ async function handleInit(projectDirArg, options) {
675
1074
  }
676
1075
  }
677
1076
  }
678
- // Step 3 (deferred): install the already-prepared template + init git, now
679
- // that the app exists. The network download and archive validation already
680
- // happened before createApp, without touching targetDir.
681
- info(`Setting up your ${platform === "ios" ? "iOS" : "web"} project...`);
1077
+ // ── Install every selected template ─────────────────────────────────
1078
+ //
1079
+ // The network download and archive validation already happened above,
1080
+ // without touching any target directory.
682
1081
  try {
683
- preparedTemplate.install(targetDir);
684
- success("Template extracted successfully.");
1082
+ for (const client of plan.clients) {
1083
+ info(`Setting up your ${platformLabel(client.platform)} project...`);
1084
+ preparedTemplates.get(client.platform).install(client.dir);
1085
+ success(`${platformLabel(client.platform)} template extracted into ${client.dir}.`);
1086
+ }
685
1087
  }
686
1088
  catch (err) {
687
1089
  error(`Failed to write template files: ${err.message}`);
@@ -691,128 +1093,322 @@ async function handleInit(projectDirArg, options) {
691
1093
  finally {
692
1094
  cleanupPreparedTemplate();
693
1095
  }
694
- // Initialize git repository (commit happens later, after env + install)
695
- try {
696
- await gitInitRepo(targetDir);
697
- }
698
- catch {
699
- // Non-fatal: git may not be installed, or already a git repo
700
- }
701
- // Step 4.5: Update CLI to point at this app
702
- try {
703
- setCurrentApp(appId, appName);
704
- info(`CLI now using app: ${appName} (${appId})`);
1096
+ // Initialize git repository (commit happens later, after env + install).
1097
+ // An adopted repository already has one, and a nested repository inside it is
1098
+ // exactly what this change stops producing.
1099
+ if (plan.initsGit) {
1100
+ try {
1101
+ await gitInitRepo(plan.projectRoot);
1102
+ }
1103
+ catch {
1104
+ // Non-fatal: git may not be installed, or already a git repo
1105
+ }
705
1106
  }
706
- catch (err) {
707
- // Not critical - user may not be logged in (e.g. used --use-existing-app-id without login)
708
- warn(`Could not set current app in CLI config: ${err.message}`);
1107
+ // Step 4.5: Update CLI to point at this app. Adopting does not: the app is
1108
+ // already the project's, and the CLI's global selection is not this run's to
1109
+ // move.
1110
+ if (plan.ownsApp) {
1111
+ try {
1112
+ setCurrentApp(appId, appName);
1113
+ info(`CLI now using app: ${appName} (${appId})`);
1114
+ }
1115
+ catch (err) {
1116
+ // Not critical - user may not be logged in (e.g. used --use-existing-app-id without login)
1117
+ warn(`Could not set current app in CLI config: ${err.message}`);
1118
+ }
709
1119
  }
710
- // Steps 5-8: Platform-specific configuration and dependency installation
711
- if (isApple) {
712
- // ── Apple (SwiftUI) path ──────────────────────────────────────────
713
- // Step 5: Update primitive.json
714
- info("Configuring primitive.json...");
1120
+ // Step 4.6: Scaffold .primitive/config.json with a "dev" environment bound
1121
+ // to this app, and seed its credential slot with the session we just
1122
+ // authenticated so `primitive` commands run from inside the project pick
1123
+ // up project mode without a second login.
1124
+ if (plan.writesProjectConfig) {
715
1125
  try {
716
- const updated = updatePrimitiveJson(targetDir, appId, serverUrl, appName);
717
- if (!updated) {
718
- warn("No primitive.json file found in template.");
719
- warn(`Please manually set appId to "${appId}" in your primitive.json.`);
720
- }
721
- else {
722
- success("primitive.json configured.");
1126
+ await scaffoldProjectConfig({
1127
+ targetDir: plan.projectRoot,
1128
+ appId,
1129
+ appName,
1130
+ serverUrl,
1131
+ });
1132
+ if (credentials) {
1133
+ seedCredentialsForEnvInProject(plan.projectRoot, "dev", credentials);
723
1134
  }
1135
+ success(`Project environment "dev" configured (.primitive/config.json).`);
1136
+ }
1137
+ catch (err) {
1138
+ warn(`Could not scaffold .primitive/config.json: ${err.message}`);
1139
+ }
1140
+ }
1141
+ else {
1142
+ // Adopting: the config is the project's own. Only make sure the local state
1143
+ // a new client's environment produces stays out of the repository.
1144
+ try {
1145
+ ensureGitignoreLocalState(plan.projectRoot);
724
1146
  }
725
1147
  catch (err) {
726
- warn(`Could not update primitive.json: ${err.message}`);
727
- warn(`Please manually set appId to "${appId}" in your primitive.json.`);
1148
+ warn(`Could not update .gitignore: ${err.message}`);
728
1149
  }
729
- // Step 6-7: Skip port selection and pnpm (not applicable to Apple)
730
- // Step 8: Resolve Swift packages
731
- if (!options.skipInstall) {
732
- info("Resolving Swift packages (this may take a few minutes)...");
1150
+ }
1151
+ // ── The one model schema (#2890) ────────────────────────────────────
1152
+ if (plan.canonicalSchemaPath && schemaPlan) {
1153
+ composeSchema({ plan, schemaPlan });
1154
+ }
1155
+ // ── The app-wide AGENTS.md ──────────────────────────────────────────
1156
+ if (plan.writesRootAgents) {
1157
+ const agentsPath = join(plan.projectRoot, "AGENTS.md");
1158
+ if (!existsSync(agentsPath)) {
733
1159
  try {
734
- await runSwiftPackageResolve(targetDir);
735
- success("Swift packages resolved.");
1160
+ // The clients this run wrote, plus any the repository already had —
1161
+ // adding iOS to a web app describes a repository with two clients.
1162
+ const written = plan.clients.map((client) => ({
1163
+ platform: client.platform,
1164
+ dir: relative(plan.projectRoot, client.dir) || ".",
1165
+ }));
1166
+ const clients = [
1167
+ ...written,
1168
+ ...discoverExistingClients(plan.projectRoot).filter((existing) => !written.some((client) => client.dir === existing.dir)),
1169
+ ];
1170
+ writeFileSync(agentsPath, rootAgentsMarkdown({ appName, clients }));
1171
+ success("Wrote AGENTS.md describing the app layout.");
736
1172
  }
737
1173
  catch (err) {
738
- warn(`Swift package resolution failed: ${err.message}`);
739
- warn("You can try running 'swift package resolve' manually.");
1174
+ warn(`Could not write AGENTS.md: ${err.message}`);
740
1175
  }
741
1176
  }
742
- else {
743
- info("Skipping dependency installation (--skip-install).");
744
- }
745
1177
  }
746
- else {
1178
+ // ── Per-client configuration and dependency installation ────────────
1179
+ //
1180
+ // The production runtime target is derived once from the project's own
1181
+ // environments, so a client built for production talks to a backend the app
1182
+ // id actually exists on (#2890).
1183
+ const production = plan.mode === "standalone"
1184
+ ? undefined
1185
+ : resolveProductionRuntime({
1186
+ environments: (adopted?.projectConfig.environments ??
1187
+ readProjectEnvironments(plan.projectRoot)),
1188
+ selectedServerUrl: serverUrl,
1189
+ selectedAppId: appId,
1190
+ selectedAppName: appName,
1191
+ productionServerUrl: PRODUCTION_SERVER_URL,
1192
+ });
1193
+ /**
1194
+ * The URL scheme derived for each Apple client, for the final summary.
1195
+ * `registered` is false when the client's plist had no `PrimitiveAuth` URL
1196
+ * type to stamp it into, so the summary and the opt-in guidance describe a
1197
+ * candidate scheme rather than a registered one.
1198
+ */
1199
+ const iosCallbackSchemes = [];
1200
+ /**
1201
+ * An app with BOTH clients emails ONE https sign-in link — the web app's
1202
+ * callback, which opens the iOS app where it is installed (#2982). "Both"
1203
+ * counts the clients already in the repository, including one that IS the
1204
+ * project root (the flat single-client layout gaining its second platform).
1205
+ */
1206
+ const combinedApp = appHasBothPlatforms(plan.clients.map((client) => client.platform), discoverClientPlatforms(plan.projectRoot));
1207
+ /** The web dev origin this run settled on, for the environment's `webUrl`. */
1208
+ let webDevOrigin = null;
1209
+ /**
1210
+ * Whether that origin's `/oauth/callback` is known to be on the app's
1211
+ * magic-link allow-list already — because the app was created here with the
1212
+ * default-port seed, or because the dev-port step merged it (#2982). Anything
1213
+ * else has to be asked about before the environment points a sign-in link at
1214
+ * it.
1215
+ */
1216
+ let devCallbackAllowListed = false;
1217
+ for (const client of plan.clients) {
1218
+ if (client.platform === "ios") {
1219
+ // ── Apple (SwiftUI) path ────────────────────────────────────────
1220
+ // The Xcode build phases name the project config as a declared input,
1221
+ // and inside Xcode's script sandbox a phase may read only what it
1222
+ // declared. When the config lives above this client, both the generated
1223
+ // spec and the checked-in .xcodeproj have to say so — otherwise a fresh
1224
+ // clone (primitive.json is gitignored) cannot build in Xcode at all, and
1225
+ // a working copy that has one keeps whichever environment wrote it.
1226
+ if (resolve(client.dir) !== resolve(plan.projectRoot)) {
1227
+ const declared = declareSharedProjectConfig(client.dir, plan.projectRoot);
1228
+ if (declared.length > 0) {
1229
+ success(`Xcode build phases read ${relative(client.dir, join(plan.projectRoot, ".primitive", "config.json"))}.`);
1230
+ }
1231
+ }
1232
+ // Step 5: Generate primitive.json from the environment.
1233
+ //
1234
+ // In a current template the file is a build product, written by
1235
+ // `scripts/resolve-primitive-config.sh` from `.primitive/config.json`
1236
+ // (#2873) — it walks up to the project root, so a client inside a
1237
+ // composed repository resolves the same one every other command does. A
1238
+ // template that predates the change has no such script and a tracked
1239
+ // primitive.json instead; fill that in the old way.
1240
+ info("Configuring primitive.json...");
1241
+ try {
1242
+ const result = configurePrimitiveJson(client.dir, appId, serverUrl, appName, {
1243
+ // Adopting resolved an environment of the project's own — the one
1244
+ // whose app this client is being added to. The script has to be told
1245
+ // which, or it re-resolves from this machine and can configure the
1246
+ // client for a different app on a different backend.
1247
+ environmentName: adopted?.name,
1248
+ });
1249
+ if (result.outcome === "generated") {
1250
+ success("primitive.json generated from .primitive/config.json.");
1251
+ }
1252
+ else if (result.outcome === "filled") {
1253
+ success("primitive.json configured.");
1254
+ }
1255
+ else if (result.outcome === "failed") {
1256
+ warn(`Could not generate primitive.json: ${result.detail}`);
1257
+ warn("Run 'bash scripts/resolve-primitive-config.sh' in the project to retry.");
1258
+ }
1259
+ else {
1260
+ // Neither the resolve script nor a template primitive.json — an
1261
+ // unexpectedly old or trimmed template. The remedy is still never
1262
+ // "type the app ID somewhere else": it is already in
1263
+ // .primitive/config.json, which is where every reader looks.
1264
+ warn("This template has neither scripts/resolve-primitive-config.sh nor a primitive.json.");
1265
+ warn(`The project config holds the app ID; update the template to generate primitive.json from it.`);
1266
+ }
1267
+ }
1268
+ catch (err) {
1269
+ warn(`Could not configure primitive.json: ${err.message}`);
1270
+ warn("Run 'bash scripts/resolve-primitive-config.sh' in the project to retry.");
1271
+ }
1272
+ // Step 5b: Give this client its own URL scheme (#2969).
1273
+ //
1274
+ // The template ships `primitiveapp` registered, which every app
1275
+ // scaffolded from it would otherwise share — iOS then picks arbitrarily
1276
+ // between two of them for an incoming link. The stamped scheme carries
1277
+ // the app id, so no two apps collide.
1278
+ //
1279
+ // Nothing is written to the app's `emailRedirectUris`, here or on the
1280
+ // server: the manager sends no redirect target by default, so this app's
1281
+ // email sign-in works immediately and code-only. The link is the
1282
+ // developer's opt-in, and this run only prints what it takes.
1283
+ const callbackScheme = deriveCallbackScheme(appName, appId, basename(client.dir));
1284
+ const stamped = stampCallbackScheme(client.dir, callbackScheme);
1285
+ iosCallbackSchemes.push({
1286
+ dir: client.dir,
1287
+ scheme: callbackScheme,
1288
+ registered: stamped.outcome !== "not-found",
1289
+ });
1290
+ if (stamped.outcome === "not-found") {
1291
+ for (const line of missingUrlTypeGuidance(callbackScheme))
1292
+ warn(line);
1293
+ }
1294
+ else {
1295
+ success(`Callback URL scheme registered: ${callbackScheme}`);
1296
+ }
1297
+ // Step 6-7: Skip port selection and pnpm (not applicable to Apple)
1298
+ // Step 8: Resolve Swift packages
1299
+ if (!skipInstall) {
1300
+ info("Resolving Swift packages (this may take a few minutes)...");
1301
+ try {
1302
+ await runSwiftPackageResolve(client.dir);
1303
+ success("Swift packages resolved.");
1304
+ }
1305
+ catch (err) {
1306
+ warn(`Swift package resolution failed: ${err.message}`);
1307
+ warn("You can try running 'swift package resolve' manually.");
1308
+ }
1309
+ }
1310
+ else {
1311
+ info(`Skipping dependency installation (${skipInstallReason}).`);
1312
+ }
1313
+ continue;
1314
+ }
747
1315
  // ── Web path ──────────────────────────────────────────────────────
748
- // Step 5: Update .env file
1316
+ // Step 5: Fill in an OLD template's .env identity keys.
1317
+ //
1318
+ // A current template does not author them at all — `primitiveEnv()` reads
1319
+ // `.primitive/config.json`, which it finds by walking up — so "no keys to
1320
+ // replace" is the expected outcome, not a problem (#2873). The fill-in
1321
+ // survives only so a new CLI still produces a working scaffold from a
1322
+ // template that predates the change.
749
1323
  info("Configuring environment...");
750
1324
  try {
751
- const envResult = updateEnvFile(targetDir, appId, serverUrl, appName);
1325
+ const envResult = updateEnvFile(client.dir, appId, serverUrl, appName, production);
752
1326
  if (!envResult.envUpdated) {
753
- warn("No .env or .env.example file found in template.");
754
- warn(`Please manually set VITE_APP_ID=${appId} in your .env file.`);
755
- }
756
- else if (!envResult.envProductionUpdated) {
757
- info(".env configured. No .env.production file found (this is optional).");
1327
+ info("No .env file in this template identity comes from .primitive/config.json.");
758
1328
  }
759
1329
  else {
760
- success("Environment configured (.env and .env.production).");
1330
+ success("Environment configured.");
761
1331
  }
762
1332
  }
763
1333
  catch (err) {
764
1334
  warn(`Could not update .env file: ${err.message}`);
765
- warn(`Please manually set VITE_APP_ID=${appId} in your .env file.`);
766
1335
  }
767
- // Step 6: Confirm the dev port to use. Default to 5173; warn about taken ports.
1336
+ // Step 6: Determine the dev port. Default to 5173; warn about taken ports
1337
+ // when prompting. The init config's `dev_port` decides it non-interactively
1338
+ // — this was the one branch in the command that prompted unconditionally,
1339
+ // so the documented key was ignored and a closed stdin threw
1340
+ // ERR_USE_AFTER_CLOSE into the catch below (#2720).
768
1341
  try {
769
- console.log();
770
- const defaultPort = 5173;
771
- const defaultOrigin = `http://localhost:${defaultPort}`;
772
- // Scan up to 10 ports starting from 5173 to surface what's already in use
773
- const takenPorts = [];
774
- for (let p = defaultPort; p < defaultPort + 10; p++) {
775
- if (!(await isPortFree(p)))
776
- takenPorts.push(p);
777
- else
778
- break;
779
- }
780
- if (takenPorts.length > 0) {
781
- warn(`Port${takenPorts.length > 1 ? "s" : ""} ${takenPorts.join(", ")} ${takenPorts.length > 1 ? "are" : "is"} already in use.`);
782
- }
783
- const portInput = await input({
784
- message: "Dev server port for this project:",
785
- default: String(defaultPort),
786
- validate: (val) => {
787
- const n = parseInt(val, 10);
788
- if (isNaN(n) || n < 1 || n > 65535)
789
- return "Enter a valid port number.";
790
- return true;
1342
+ const defaultPort = DEFAULT_DEV_PORT;
1343
+ const defaultOrigin = localDevOrigin(defaultPort);
1344
+ const resolvedPort = await resolveDevPort({
1345
+ configPort: initConfig?.dev_port,
1346
+ hasConfig: initConfig != null,
1347
+ isTTY: Boolean(process.stdin.isTTY),
1348
+ defaultPort,
1349
+ promptFn: async () => {
1350
+ console.log();
1351
+ // Scan up to 10 ports starting from 5173 to surface what's already in use
1352
+ const takenPorts = [];
1353
+ for (let p = defaultPort; p < defaultPort + 10; p++) {
1354
+ if (!(await isPortFree(p)))
1355
+ takenPorts.push(p);
1356
+ else
1357
+ break;
1358
+ }
1359
+ if (takenPorts.length > 0) {
1360
+ warn(`Port${takenPorts.length > 1 ? "s" : ""} ${takenPorts.join(", ")} ${takenPorts.length > 1 ? "are" : "is"} already in use.`);
1361
+ }
1362
+ const portInput = await input({
1363
+ message: "Dev server port for this project:",
1364
+ default: String(defaultPort),
1365
+ validate: validateDevPort,
1366
+ });
1367
+ return parseInt(portInput, 10);
791
1368
  },
792
1369
  });
793
- const chosenPort = parseInt(portInput, 10);
1370
+ const chosenPort = resolvedPort.port;
1371
+ // The same origin the app's CORS list and `emailRedirectUris` are seeded
1372
+ // with, so a combined app's first sign-in link is already allow-listed.
1373
+ webDevOrigin = localDevOrigin(chosenPort);
1374
+ // …but only for an app this run created: that seed happens at creation.
1375
+ // An adopted app on the default port has been through neither the seed
1376
+ // nor the port step below, so nothing here can vouch for its allow-list.
1377
+ devCallbackAllowListed = appWasCreatedByThisRun && chosenPort === defaultPort;
1378
+ if (resolvedPort.source === "config") {
1379
+ info(`Dev server port: ${chosenPort} (from config)`);
1380
+ }
794
1381
  if (chosenPort !== defaultPort) {
795
- const devOrigin = `http://localhost:${chosenPort}`;
796
- // Pin the port in vite.config.ts and update .env
797
- updateViteConfig(targetDir, chosenPort);
798
- updateEnvPort(targetDir, chosenPort);
1382
+ const devOrigin = localDevOrigin(chosenPort);
1383
+ // Pin the port in vite.config.ts and update .env. A vite config whose
1384
+ // shape we can't patch must be said out loud — otherwise the dev
1385
+ // server keeps 5173 while .env and the redirect URIs point elsewhere.
1386
+ const viteUpdated = updateViteConfig(client.dir, chosenPort);
1387
+ if (!viteUpdated) {
1388
+ warn(`Could not pin port ${chosenPort} in vite.config.ts — set \`server.port\` there manually.`);
1389
+ }
1390
+ updateEnvPort(client.dir, chosenPort);
799
1391
  // Update app settings: CORS, redirect URIs, baseUrl
800
1392
  if (!credentials) {
801
1393
  info(`Local files updated. Skipping server CORS/redirect config — not authenticated.`);
802
1394
  }
803
1395
  else {
804
1396
  try {
805
- const client = new ApiClient();
1397
+ const apiClient = new ApiClient();
806
1398
  let existingOrigins = [];
807
- let existingRedirectUris = [];
1399
+ let existingEmailRedirectUris = [];
1400
+ let existingGoogleClients = null;
808
1401
  try {
809
- const currentSettings = await client.getAppSettings(appId);
1402
+ const currentSettings = await apiClient.getAppSettings(appId);
810
1403
  if (currentSettings.corsMode === "custom" &&
811
1404
  Array.isArray(currentSettings.corsAllowedOrigins)) {
812
1405
  existingOrigins = currentSettings.corsAllowedOrigins;
813
1406
  }
814
- if (Array.isArray(currentSettings.redirectUris)) {
815
- existingRedirectUris = currentSettings.redirectUris;
1407
+ if (Array.isArray(currentSettings.emailRedirectUris)) {
1408
+ existingEmailRedirectUris = currentSettings.emailRedirectUris;
1409
+ }
1410
+ if (currentSettings.googleClients) {
1411
+ existingGoogleClients = currentSettings.googleClients;
816
1412
  }
817
1413
  }
818
1414
  catch {
@@ -820,19 +1416,49 @@ async function handleInit(projectDirArg, options) {
820
1416
  }
821
1417
  // Always include 5173 since it's the universal localhost default
822
1418
  const corsOrigins = Array.from(new Set([...existingOrigins, defaultOrigin, devOrigin]));
823
- const redirectUris = Array.from(new Set([
824
- ...existingRedirectUris,
1419
+ // MERGED into the creation-time seed, never replacing it (#2891):
1420
+ // `emailRedirectUris` matching is fail-closed, so blindly writing
1421
+ // only this port's callback would break every other environment the
1422
+ // app already serves.
1423
+ const emailRedirectUris = Array.from(new Set([
1424
+ ...existingEmailRedirectUris,
825
1425
  `${defaultOrigin}/oauth/callback`,
826
1426
  `${devOrigin}/oauth/callback`,
827
1427
  ]));
828
- await client.updateAppSettings(appId, {
1428
+ const settingsUpdate = {
829
1429
  corsMode: "custom",
830
1430
  corsAllowedOrigins: corsOrigins,
831
1431
  corsAllowCredentials: true,
832
- redirectUris,
1432
+ emailRedirectUris,
833
1433
  baseUrl: devOrigin,
834
- });
835
- success(`Dev port set to ${chosenPort}: vite.config.ts, .env, CORS, redirect URIs, and base URL updated.`);
1434
+ };
1435
+ // A Google web client redirects to this dev origin too, so its own
1436
+ // `redirectUris` needs the callback. A PUT replaces the WHOLE map,
1437
+ // so the complete map is sent back — an entry for a platform this
1438
+ // scaffold does not touch (ios, desktop, …) would otherwise be
1439
+ // deleted as a side effect of choosing a dev port.
1440
+ const webEntry = existingGoogleClients?.clients?.web;
1441
+ if (webEntry) {
1442
+ settingsUpdate.googleClients = {
1443
+ ...existingGoogleClients,
1444
+ clients: {
1445
+ ...existingGoogleClients.clients,
1446
+ web: {
1447
+ ...webEntry,
1448
+ redirectUris: Array.from(new Set([
1449
+ ...(webEntry.redirectUris ?? []),
1450
+ `${defaultOrigin}/oauth/callback`,
1451
+ `${devOrigin}/oauth/callback`,
1452
+ ])),
1453
+ },
1454
+ },
1455
+ };
1456
+ }
1457
+ await apiClient.updateAppSettings(appId, settingsUpdate);
1458
+ // This merge is what puts the chosen port's callback on the
1459
+ // allow-list, whoever created the app (#2982).
1460
+ devCallbackAllowListed = true;
1461
+ success(`Dev port set to ${chosenPort}: ${viteUpdated ? "vite.config.ts, " : ""}.env, CORS, redirect URIs, and base URL updated.`);
836
1462
  }
837
1463
  catch (err) {
838
1464
  warn(`Could not update app settings: ${err.message}`);
@@ -840,18 +1466,32 @@ async function handleInit(projectDirArg, options) {
840
1466
  }
841
1467
  }
842
1468
  }
843
- catch {
844
- // Non-fatal: port detection or settings update failed silently
1469
+ catch (err) {
1470
+ // Non-fatal but say so. A silent swallow here is what hid the closed
1471
+ // stdin crash in #2720: the run kept 5173 and still exited 0.
1472
+ warn(`Could not configure the dev server port: ${err.message}`);
1473
+ warn("The project keeps the default port 5173.");
845
1474
  }
846
- // Step 7: Check if pnpm is installed globally, offer to install if not
1475
+ // Step 7: Check if pnpm is installed globally, offer to install if not.
1476
+ //
1477
+ // An offer, so only where there is someone to ask: a config-driven run
1478
+ // answers no questions, and a piped stdin has no answer to give. Either way
1479
+ // the install below still runs pnpm through npx, so the client gets its
1480
+ // dependencies — this is about the machine, not about this project.
847
1481
  const pnpmInstalled = await isPnpmInstalled();
848
1482
  if (!pnpmInstalled) {
849
1483
  console.log();
850
1484
  info("pnpm is not installed globally on your system.");
851
- const installPnpm = await confirm({
852
- message: "Install pnpm globally? (recommended for future use)",
853
- default: true,
854
- });
1485
+ const canAsk = initConfig == null && Boolean(process.stdin.isTTY);
1486
+ const installPnpm = canAsk
1487
+ ? await confirm({
1488
+ message: "Install pnpm globally? (recommended for future use)",
1489
+ default: true,
1490
+ })
1491
+ : false;
1492
+ if (!canAsk) {
1493
+ info("Using npx to run pnpm. Install it globally later with: npm install -g pnpm");
1494
+ }
855
1495
  if (installPnpm) {
856
1496
  info("Installing pnpm...");
857
1497
  try {
@@ -865,10 +1505,10 @@ async function handleInit(projectDirArg, options) {
865
1505
  }
866
1506
  }
867
1507
  // Step 8: Install dependencies with pnpm (via npx to handle version management)
868
- if (!options.skipInstall) {
1508
+ if (!skipInstall) {
869
1509
  info("Installing dependencies...");
870
1510
  try {
871
- await runPnpmInstall(targetDir);
1511
+ await runPnpmInstall(client.dir);
872
1512
  success("Dependencies installed.");
873
1513
  }
874
1514
  catch (err) {
@@ -877,7 +1517,7 @@ async function handleInit(projectDirArg, options) {
877
1517
  }
878
1518
  }
879
1519
  else {
880
- info("Skipping dependency installation (--skip-install).");
1520
+ info(`Skipping dependency installation (${skipInstallReason}).`);
881
1521
  }
882
1522
  }
883
1523
  // Step 9: Install primitive CLI globally if not already installed
@@ -892,7 +1532,7 @@ async function handleInit(projectDirArg, options) {
892
1532
  }
893
1533
  catch (err) {
894
1534
  warn(`Could not install CLI: ${err.message}`);
895
- warn("You can install it manually with: npm install -g primitive-admin");
1535
+ warn(`You can install it manually with: ${installCommand(PRIMITIVE_CLI_PACKAGE)}`);
896
1536
  }
897
1537
  }
898
1538
  else {
@@ -910,11 +1550,37 @@ async function handleInit(projectDirArg, options) {
910
1550
  }
911
1551
  catch (err) {
912
1552
  warn(`Could not install CLI: ${err.message}`);
913
- warn("You can install it manually with: npm install -g primitive-admin");
1553
+ warn(`You can install it manually with: ${installCommand(PRIMITIVE_CLI_PACKAGE)}`);
914
1554
  }
915
1555
  }
916
1556
  }
917
1557
  }
1558
+ // The combined app's dev sign-in link (#2982): the environment learns the
1559
+ // web origin this run settled on, so the iOS client's emailed link opens the
1560
+ // local web app instead of a scheme that is dead in the Simulator. Only when
1561
+ // this run owns the project config — an adopting run gets the guidance and
1562
+ // decides for itself.
1563
+ //
1564
+ // And only once the app actually allows that callback. Setting `webUrl` is
1565
+ // what turns a code-only email into a link, and a link the server refuses is
1566
+ // a 400 on every sign-in request — strictly worse than the code-only default
1567
+ // this app has today. An app created here was seeded with the callback; an
1568
+ // adopted one is asked about, and when the answer is no or unavailable the
1569
+ // seed is skipped in favour of printing the step.
1570
+ let seededWebOrigin = null;
1571
+ let unverifiedDevCallback = null;
1572
+ if (combinedApp && plan.writesProjectConfig && webDevOrigin) {
1573
+ const devCallback = `${webDevOrigin}/oauth/callback`;
1574
+ const allowed = devCallbackAllowListed ||
1575
+ (await devCallbackIsAllowListed(appId, devCallback, credentials));
1576
+ if (allowed && seedEnvironmentWebUrl(plan.projectRoot, "dev", webDevOrigin)) {
1577
+ seededWebOrigin = webDevOrigin;
1578
+ success(`Environment "dev" points at the web client: webUrl ${webDevOrigin}`);
1579
+ }
1580
+ else if (!allowed) {
1581
+ unverifiedDevCallback = devCallback;
1582
+ }
1583
+ }
918
1584
  // Step 10: Offer to install Claude Code skill if not already installed
919
1585
  try {
920
1586
  const { isSkillInstalled, installSkillExplicit, checkSkillStatus } = await import("../lib/skill-installer.js");
@@ -941,39 +1607,288 @@ async function handleInit(projectDirArg, options) {
941
1607
  catch {
942
1608
  // Non-fatal: skill installation should never block init
943
1609
  }
944
- // Step 11: Create initial git commit (captures env config + installed dependencies)
945
- try {
946
- await gitInitialCommit(targetDir);
947
- }
948
- catch {
949
- // Non-fatal: git may not be installed, or already committed
1610
+ // Step 11: Create initial git commit (captures env config + installed
1611
+ // dependencies). Not when adopting: the surrounding repository owns its
1612
+ // history, and the new files are the user's to review and commit.
1613
+ if (plan.initsGit) {
1614
+ try {
1615
+ await gitInitialCommit(plan.projectRoot);
1616
+ }
1617
+ catch {
1618
+ // Non-fatal: git may not be installed, or already committed
1619
+ }
950
1620
  }
951
1621
  // Step 12: Show success message
952
1622
  divider();
953
- success("Project initialized successfully!");
1623
+ success(plan.mode === "add-client"
1624
+ ? "Client added successfully!"
1625
+ : "Project initialized successfully!");
954
1626
  divider();
955
- keyValue("Directory", targetDir);
1627
+ keyValue("Project", plan.projectRoot);
956
1628
  keyValue("App Name", appName);
957
1629
  keyValue("App ID", appId);
958
- keyValue("Platform", platform === "ios" ? "iOS (SwiftUI)" : "Web");
1630
+ for (const client of plan.clients) {
1631
+ keyValue(platformLabel(client.platform), client.dir);
1632
+ }
1633
+ if (plan.canonicalSchemaPath) {
1634
+ keyValue("Model schema", plan.canonicalSchemaPath);
1635
+ }
1636
+ for (const { scheme, registered } of iosCallbackSchemes) {
1637
+ keyValue("Callback URL scheme", registered ? scheme : `${scheme} (not registered — see the warning above)`);
1638
+ }
959
1639
  divider();
1640
+ // Email sign-in already works for the scaffolded app — code-only, no app
1641
+ // settings — so this is an opt-in, not a step (#2969). An app with both
1642
+ // clients gets the https link instead, which needs a different checklist
1643
+ // (#2982).
1644
+ if (combinedApp) {
1645
+ for (const line of universalLinkGuidance({
1646
+ webOrigin: seededWebOrigin ?? undefined,
1647
+ unverifiedDevCallback: unverifiedDevCallback ?? undefined,
1648
+ })) {
1649
+ info(line);
1650
+ }
1651
+ divider();
1652
+ }
1653
+ else {
1654
+ for (const { scheme, registered } of iosCallbackSchemes) {
1655
+ for (const line of linkOptInGuidance(scheme, { registered }))
1656
+ info(line);
1657
+ divider();
1658
+ }
1659
+ }
1660
+ if (plan.mode === "add-client") {
1661
+ info(`No .primitive/ and no .git/ were created inside the new client — this app has one of each, at ${plan.projectRoot}.`);
1662
+ info(`The new files are uncommitted: review them with \`git status\` in ${plan.projectRoot} and commit when you are happy.`);
1663
+ divider();
1664
+ }
960
1665
  info("Next steps:");
961
- if (projectDir !== ".") {
962
- console.log(` cd ${projectDir}`);
1666
+ for (const client of plan.clients) {
1667
+ const relativeDir = relative(process.cwd(), client.dir);
1668
+ // A target outside the working directory reads better as its own path than
1669
+ // as a chain of `../`.
1670
+ const where = relativeDir.startsWith("..") ? client.dir : relativeDir || ".";
1671
+ if (where !== ".")
1672
+ console.log(` cd ${where}`);
1673
+ if (client.platform === "ios") {
1674
+ if (skipInstall)
1675
+ console.log(" swift package resolve");
1676
+ // The Xcode project lists its sources explicitly, and the shared schema
1677
+ // brings models the checked-in project does not compile yet — so the
1678
+ // first build regenerates. `run-ios.sh` runs codegen and xcodegen in that
1679
+ // order; a build straight from Xcode before that stops and says so.
1680
+ if (resolve(client.dir) !== resolve(plan.projectRoot)) {
1681
+ console.log(" ./run-ios.sh # codegen + xcodegen, then the Simulator");
1682
+ console.log(" # then: open *.xcodeproj");
1683
+ }
1684
+ else {
1685
+ console.log(" open *.xcodeproj");
1686
+ console.log(" # or: ./run-ios.sh (iOS Simulator)");
1687
+ }
1688
+ }
1689
+ else {
1690
+ if (skipInstall)
1691
+ console.log(" pnpm install");
1692
+ console.log(" pnpm dev");
1693
+ }
963
1694
  }
964
- if (isApple) {
965
- if (options.skipInstall) {
966
- console.log(" swift package resolve");
1695
+ console.log();
1696
+ }
1697
+ /** Where a project root's environments live, for the production derivation. */
1698
+ function readProjectEnvironments(projectRoot) {
1699
+ const configPath = join(projectRoot, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
1700
+ if (!existsSync(configPath))
1701
+ return undefined;
1702
+ try {
1703
+ return loadProjectConfigFromPath(configPath).environments;
1704
+ }
1705
+ catch {
1706
+ return undefined;
1707
+ }
1708
+ }
1709
+ /**
1710
+ * The clients a repository already has, for the app-wide `AGENTS.md`.
1711
+ *
1712
+ * A run that adds a client to an existing app knows only about the one it is
1713
+ * writing, and a layout description that names one client of two describes the
1714
+ * repository wrongly to the next agent that reads it. The signals are the ones
1715
+ * the templates ship — a Swift package or Xcode project, a Vite project — so a
1716
+ * `functions/` directory with a `package.json` is not mistaken for a client.
1717
+ */
1718
+ function discoverExistingClients(projectRoot) {
1719
+ let entries;
1720
+ try {
1721
+ entries = readdirSync(projectRoot, { withFileTypes: true });
1722
+ }
1723
+ catch {
1724
+ return [];
1725
+ }
1726
+ const found = [];
1727
+ for (const entry of entries) {
1728
+ if (!entry.isDirectory() || entry.name.startsWith("."))
1729
+ continue;
1730
+ // The same template signals the both-platforms predicate reads, so the
1731
+ // layout description and the universal-link wiring cannot disagree about
1732
+ // what counts as a client.
1733
+ const platform = detectClientPlatform(join(projectRoot, entry.name));
1734
+ if (platform)
1735
+ found.push({ platform, dir: entry.name });
1736
+ }
1737
+ return found;
1738
+ }
1739
+ /**
1740
+ * Decides how this run gets to ONE schema — before anything is downloaded or
1741
+ * written, because the answer can be "stop".
1742
+ */
1743
+ async function resolveSchemaComposition(input) {
1744
+ const schemaPath = input.plan.canonicalSchemaPath;
1745
+ if (existsSync(schemaPath))
1746
+ return { alreadyPresent: true };
1747
+ // A fresh composition seeds the shared schema from the clients it is about
1748
+ // to scaffold; there is nothing to move and nobody to ask.
1749
+ if (input.plan.mode !== "add-client")
1750
+ return { alreadyPresent: false };
1751
+ const root = input.plan.projectRoot;
1752
+ const existing = ["web", "ios"]
1753
+ .map((platform) => ({ platform, schemaPath: findClientSchema(root, platform) }))
1754
+ .find((candidate) => candidate.schemaPath !== null);
1755
+ if (existing) {
1756
+ // The flat single-client layout `primitive init` still produces: the client
1757
+ // IS the repository root, and its schema has to become the app's.
1758
+ const from = relative(root, existing.schemaPath);
1759
+ // Moving the schema is only half of it — what reads the schema has to be
1760
+ // able to name the new location. A client that cannot be rewired would keep
1761
+ // its own copy beside the app's, which is the drift the one schema exists
1762
+ // to prevent, so the run stops here: before the download, before the move,
1763
+ // with the repository exactly as it was.
1764
+ const blocked = checkClientWiring(root, existing.platform, schemaPath);
1765
+ if (blocked) {
1766
+ error(`The ${platformLabel(existing.platform)} client at ${root} cannot be pointed at ${relative(root, schemaPath)}: ${blocked}.`);
1767
+ error(`Every client of an app reads one schema, so ${from} stays where it is. Restore that file to its template shape, or move the schema and rewire the client by hand, then re-run this command.`);
1768
+ warn("No project files were written.");
1769
+ process.exit(1);
1770
+ }
1771
+ info(`This repository's model schema is at ${from}. Adding a second client means moving it to models/models.toml so both clients read one copy.`);
1772
+ if (!input.promoteConsent) {
1773
+ if (!input.interactive) {
1774
+ error(`Adding a client needs the schema at models/models.toml, and moving ${from} there needs your consent.`);
1775
+ error(input.nonInteractive
1776
+ ? 'Set `promote_schema = true` alongside `action = "add-client"` in the init config to allow the move.'
1777
+ : "Re-run from an interactive terminal, or with an init config setting `promote_schema = true`, to allow the move.");
1778
+ warn("No project files were written.");
1779
+ process.exit(1);
1780
+ }
1781
+ const proceed = await confirm({
1782
+ message: `Move ${from} to models/models.toml and point both clients at it?`,
1783
+ default: true,
1784
+ });
1785
+ if (!proceed) {
1786
+ error("Aborted. No project files were written.");
1787
+ process.exit(1);
1788
+ }
1789
+ }
1790
+ return {
1791
+ alreadyPresent: false,
1792
+ promoteFrom: { dir: root, platform: existing.platform, schemaPath: existing.schemaPath },
1793
+ };
1794
+ }
1795
+ // A root that looks like a client but keeps its schema somewhere neither
1796
+ // template does: say exactly what to do, and write nothing.
1797
+ const looksLikeClient = existsSync(join(root, "package.json")) || existsSync(join(root, "Package.swift"));
1798
+ if (looksLikeClient) {
1799
+ error(`The project at ${root} has a client, but no model schema where either template keeps one (src/models/models.toml, or Sources/<App>/Models/models.toml).`);
1800
+ error(`Move that client's schema to ${root}/models/models.toml and point its codegen at it, then re-run this command.`);
1801
+ warn("No project files were written.");
1802
+ process.exit(1);
1803
+ }
1804
+ return { alreadyPresent: false };
1805
+ }
1806
+ /**
1807
+ * Writes the app's one schema and points every client at it.
1808
+ *
1809
+ * The promoted file and the new wiring are written BEFORE the old client-local
1810
+ * copy goes away, so an interrupted promotion leaves a client with a schema
1811
+ * rather than one with neither.
1812
+ */
1813
+ function composeSchema(input) {
1814
+ const { plan, schemaPlan } = input;
1815
+ const schemaPath = plan.canonicalSchemaPath;
1816
+ const reportAddedModels = (added) => {
1817
+ if (added.length === 0)
1818
+ return;
1819
+ info(`Added ${added.map((model) => `\`${model}\``).join(", ")} to ${relative(plan.projectRoot, schemaPath)} — the models the added client's own code reads. Models the app already declares are untouched.`);
1820
+ };
1821
+ const clientsToWire = [
1822
+ ...(schemaPlan.promoteFrom
1823
+ ? [{ platform: schemaPlan.promoteFrom.platform, dir: schemaPlan.promoteFrom.dir }]
1824
+ : []),
1825
+ ...plan.clients,
1826
+ ];
1827
+ // Asked of every client before the shared schema exists. A client that cannot
1828
+ // be pointed at it keeps its own copy — so writing the shared one anyway, and
1829
+ // wiring whichever clients did work, would leave the app with two schemas and
1830
+ // the run reporting success. One schema, or none: the existing client's
1831
+ // shape was already checked before the download, so this catches an installed
1832
+ // template whose wiring this CLI no longer recognizes.
1833
+ const unreachable = clientsToWire
1834
+ .map((client) => ({
1835
+ client,
1836
+ reason: checkClientWiring(client.dir, client.platform, schemaPath),
1837
+ }))
1838
+ .filter((checked) => checked.reason !== null);
1839
+ if (unreachable.length > 0) {
1840
+ for (const { client, reason } of unreachable) {
1841
+ error(`The ${platformLabel(client.platform)} client at ${client.dir} cannot be pointed at ${schemaPath}: ${reason}.`);
1842
+ }
1843
+ error("Every client of an app reads one model schema, so nothing was moved and no client was rewired.");
1844
+ warn(`The templates this run installed are still in place; remove them and re-run, or point each client at ${schemaPath} by hand.`);
1845
+ process.exit(1);
1846
+ }
1847
+ // Every client this run installs ships a starter schema AND code generated
1848
+ // from it — the web template's store reads `UserPref`, and its generator
1849
+ // refuses a schema with no models at all. So the app's schema has to declare
1850
+ // what each client reads, whether it is being written now or was already
1851
+ // there: a promoted comment-only Swift starter would otherwise hand a new web
1852
+ // client a schema it cannot generate from, and the run would report success.
1853
+ const starters = plan.clients
1854
+ .map((client) => findClientSchema(client.dir, client.platform))
1855
+ .filter((found) => found !== null)
1856
+ .map((found) => readFileSync(found, "utf-8"));
1857
+ if (!schemaPlan.alreadyPresent) {
1858
+ // The base is the promoted client's schema when one is being promoted —
1859
+ // its models are the app's — else the richest starter this run installs.
1860
+ const base = (schemaPlan.promoteFrom
1861
+ ? readFileSync(schemaPlan.promoteFrom.schemaPath, "utf-8")
1862
+ : starters.find((starter) => declaredModelNames(starter).length > 0)) ??
1863
+ starters[0] ??
1864
+ FALLBACK_STARTER_SCHEMA;
1865
+ const composed = composeSchemaContent({ base, starters });
1866
+ if (writeCanonicalSchema(schemaPath, `${CANONICAL_SCHEMA_HEADER}\n${composed.content}`)) {
1867
+ success(`Wrote ${relative(plan.projectRoot, schemaPath)} — the app's one model schema.`);
1868
+ reportAddedModels(schemaPlan.promoteFrom ? composed.added : []);
967
1869
  }
968
- console.log(" open *.xcodeproj");
969
- console.log(" # or: ./run-ios.sh (iOS Simulator)");
970
1870
  }
971
1871
  else {
972
- if (options.skipInstall) {
973
- console.log(" pnpm install");
1872
+ const composed = composeSchemaContent({
1873
+ base: readFileSync(schemaPath, "utf-8"),
1874
+ starters,
1875
+ });
1876
+ if (composed.added.length > 0) {
1877
+ writeFileSync(schemaPath, composed.content);
1878
+ reportAddedModels(composed.added);
974
1879
  }
975
- console.log(" pnpm dev");
976
1880
  }
977
- console.log();
1881
+ for (const client of clientsToWire) {
1882
+ const result = wireClientToSchema(client.dir, client.platform, schemaPath);
1883
+ if (result.error) {
1884
+ // The check above said this client could be wired, so an error here is
1885
+ // the filesystem changing under the run — still not something to finish
1886
+ // on top of, because it is a client reading a schema of its own.
1887
+ error(`Could not point the ${platformLabel(client.platform)} client at the shared schema: ${result.error}.`);
1888
+ error(`Point it at ${schemaPath} before using this repository.`);
1889
+ process.exit(1);
1890
+ }
1891
+ success(`${platformLabel(client.platform)} client reads ${relative(client.dir, schemaPath)}.`);
1892
+ }
978
1893
  }
979
1894
  //# sourceMappingURL=init.js.map