@xano/sdk 1.0.0

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 (336) hide show
  1. package/CHANGELOG.md +788 -0
  2. package/LICENSE +21 -0
  3. package/README.md +541 -0
  4. package/dist/.build-fingerprint +1 -0
  5. package/dist/agent-file-refresh-ZEMK33AX.js +38 -0
  6. package/dist/answer-shape-K67TAAPY.js +33 -0
  7. package/dist/backend-dir-2U53CIAL.js +39 -0
  8. package/dist/backend-slot-7QXW2QYC.js +28 -0
  9. package/dist/bin.d.ts +1 -0
  10. package/dist/bin.js +255 -0
  11. package/dist/branch-NCRNWQBQ.js +61 -0
  12. package/dist/branch-commands-ACIRCEQJ.js +79 -0
  13. package/dist/bundle.d.ts +223 -0
  14. package/dist/bundle.js +153 -0
  15. package/dist/capture-WAXPD7DR.js +41 -0
  16. package/dist/chunk-27GWNP2I.js +23 -0
  17. package/dist/chunk-2CMJZDCL.js +177 -0
  18. package/dist/chunk-2EZ3ZJZZ.js +302 -0
  19. package/dist/chunk-2GNFDZA2.js +390 -0
  20. package/dist/chunk-2MNLRIOX.js +11 -0
  21. package/dist/chunk-2TFRR6JV.js +104 -0
  22. package/dist/chunk-2ZUEE4EK.js +408 -0
  23. package/dist/chunk-36JUQZVQ.js +22 -0
  24. package/dist/chunk-3DRXWLJ5.js +83 -0
  25. package/dist/chunk-3JDU4VRL.js +137 -0
  26. package/dist/chunk-3KZCLN4Z.js +241 -0
  27. package/dist/chunk-3LDULPRE.js +168 -0
  28. package/dist/chunk-3M4LFYNQ.js +111 -0
  29. package/dist/chunk-3XI5CTUE.js +22 -0
  30. package/dist/chunk-3YW23UFH.js +50 -0
  31. package/dist/chunk-45UIT5T2.js +862 -0
  32. package/dist/chunk-4BW2SG2N.js +46 -0
  33. package/dist/chunk-4KHEMC32.js +79 -0
  34. package/dist/chunk-4POWHT2K.js +321 -0
  35. package/dist/chunk-4Q2GN7NW.js +192 -0
  36. package/dist/chunk-5A2PLKZB.js +70 -0
  37. package/dist/chunk-5FBYXK5P.js +159 -0
  38. package/dist/chunk-5HVXPTU4.js +323 -0
  39. package/dist/chunk-5QU2P5CA.js +304 -0
  40. package/dist/chunk-5V5XU32D.js +191 -0
  41. package/dist/chunk-5ZSO4T2G.js +10 -0
  42. package/dist/chunk-622HUCZT.js +10 -0
  43. package/dist/chunk-65HWOMGV.js +177 -0
  44. package/dist/chunk-6ELFRN3P.js +365 -0
  45. package/dist/chunk-6HO3NCSJ.js +1055 -0
  46. package/dist/chunk-6IUMKYWI.js +457 -0
  47. package/dist/chunk-6KEB3C3U.js +65 -0
  48. package/dist/chunk-6MR5TI5V.js +987 -0
  49. package/dist/chunk-6NNTNXN7.js +157 -0
  50. package/dist/chunk-6PBR37LN.js +97 -0
  51. package/dist/chunk-6QXLMMT3.js +155 -0
  52. package/dist/chunk-6ZGJCBZS.js +224 -0
  53. package/dist/chunk-73IVBKMA.js +250 -0
  54. package/dist/chunk-7G3CTU7I.js +286 -0
  55. package/dist/chunk-A3UDOZB6.js +222 -0
  56. package/dist/chunk-A6E77GDW.js +85 -0
  57. package/dist/chunk-AHJWCYQO.js +660 -0
  58. package/dist/chunk-AKNCFVKZ.js +36 -0
  59. package/dist/chunk-AM4FKL4J.js +237 -0
  60. package/dist/chunk-AN6BL47T.js +26 -0
  61. package/dist/chunk-AOSYVMUS.js +147 -0
  62. package/dist/chunk-APWXY5SL.js +182 -0
  63. package/dist/chunk-AWEIIR2U.js +218 -0
  64. package/dist/chunk-AWQ7BQTQ.js +73 -0
  65. package/dist/chunk-BAUZOWGX.js +393 -0
  66. package/dist/chunk-BHACRS6R.js +940 -0
  67. package/dist/chunk-BWPNT55T.js +680 -0
  68. package/dist/chunk-C6RIMRU4.js +187 -0
  69. package/dist/chunk-CGVQZXYG.js +263 -0
  70. package/dist/chunk-CIMQZRRE.js +162 -0
  71. package/dist/chunk-CJHQKJU4.js +113 -0
  72. package/dist/chunk-CTMO46PK.js +1897 -0
  73. package/dist/chunk-D53J55C5.js +106 -0
  74. package/dist/chunk-D7VGG3WS.js +90 -0
  75. package/dist/chunk-DAD3ONIS.js +245 -0
  76. package/dist/chunk-DGWHAVRG.js +162 -0
  77. package/dist/chunk-DX4QR4B2.js +483 -0
  78. package/dist/chunk-EHP3WPEG.js +21 -0
  79. package/dist/chunk-ELP2HMXC.js +432 -0
  80. package/dist/chunk-ERVIASGL.js +21 -0
  81. package/dist/chunk-ET4Y4XCB.js +865 -0
  82. package/dist/chunk-ETO6LGLD.js +4750 -0
  83. package/dist/chunk-EUUUMY7J.js +24 -0
  84. package/dist/chunk-FQJ2TTHP.js +218 -0
  85. package/dist/chunk-FQOF7E2E.js +133 -0
  86. package/dist/chunk-FWD7AL6N.js +21 -0
  87. package/dist/chunk-FXFV3KU6.js +138 -0
  88. package/dist/chunk-FXRQOWTE.js +171 -0
  89. package/dist/chunk-G554NZNI.js +210 -0
  90. package/dist/chunk-GSGDWJB5.js +13 -0
  91. package/dist/chunk-H2V3N23D.js +66 -0
  92. package/dist/chunk-H5C7MBCR.js +159 -0
  93. package/dist/chunk-HHN734BU.js +371 -0
  94. package/dist/chunk-HRVBLCVJ.js +243 -0
  95. package/dist/chunk-HVRUQXGD.js +1264 -0
  96. package/dist/chunk-I4F5TCJH.js +130 -0
  97. package/dist/chunk-IDOTCMDV.js +57 -0
  98. package/dist/chunk-IGOLHKWF.js +427 -0
  99. package/dist/chunk-JGE7CAWB.js +148 -0
  100. package/dist/chunk-JTUKLCXS.js +92 -0
  101. package/dist/chunk-JZGVVJSI.js +66 -0
  102. package/dist/chunk-K4WX6UUU.js +923 -0
  103. package/dist/chunk-K5BBGTYZ.js +217 -0
  104. package/dist/chunk-K6RXBGUB.js +427 -0
  105. package/dist/chunk-KTSDSD36.js +165 -0
  106. package/dist/chunk-L5JYN3NM.js +1038 -0
  107. package/dist/chunk-LIEOKXKD.js +354 -0
  108. package/dist/chunk-LLNZGEFK.js +51 -0
  109. package/dist/chunk-LPECSJ2J.js +55 -0
  110. package/dist/chunk-LQCMKAJ7.js +413 -0
  111. package/dist/chunk-LRN4ZJCQ.js +1145 -0
  112. package/dist/chunk-MJ4ZVHKN.js +17 -0
  113. package/dist/chunk-MXHSQOC4.js +3061 -0
  114. package/dist/chunk-N5GKCWE5.js +367 -0
  115. package/dist/chunk-NA3PXMEF.js +10 -0
  116. package/dist/chunk-NAQDMEHV.js +76 -0
  117. package/dist/chunk-NWKXM5RD.js +109 -0
  118. package/dist/chunk-NZOWWHU5.js +196 -0
  119. package/dist/chunk-OHX6MIUZ.js +184 -0
  120. package/dist/chunk-OOXWYAA4.js +15 -0
  121. package/dist/chunk-OPHB5ORL.js +1990 -0
  122. package/dist/chunk-OXA6PSZT.js +1725 -0
  123. package/dist/chunk-P2HAGF6H.js +73 -0
  124. package/dist/chunk-PTHVMXZI.js +3036 -0
  125. package/dist/chunk-PZ5AY32C.js +10 -0
  126. package/dist/chunk-Q325JKYB.js +189 -0
  127. package/dist/chunk-Q7DGTQ5G.js +255 -0
  128. package/dist/chunk-QCNYHBL7.js +601 -0
  129. package/dist/chunk-QO6BVGS5.js +145 -0
  130. package/dist/chunk-QXTKSOFI.js +76 -0
  131. package/dist/chunk-ROY4YAEQ.js +66 -0
  132. package/dist/chunk-RTJRZFFY.js +447 -0
  133. package/dist/chunk-SAVJUS2X.js +128 -0
  134. package/dist/chunk-SFLHVCKX.js +173 -0
  135. package/dist/chunk-STSFNCY4.js +127 -0
  136. package/dist/chunk-SWBF2LTK.js +170 -0
  137. package/dist/chunk-T5KELPAR.js +109 -0
  138. package/dist/chunk-TFFGOFVZ.js +1481 -0
  139. package/dist/chunk-TLHIFK4M.js +256 -0
  140. package/dist/chunk-TLQE7ZN3.js +156 -0
  141. package/dist/chunk-TPHWJ7UG.js +1981 -0
  142. package/dist/chunk-TRJCALW3.js +291 -0
  143. package/dist/chunk-TTEIYD5P.js +133 -0
  144. package/dist/chunk-U64O2MXY.js +407 -0
  145. package/dist/chunk-UCQ2VUTG.js +630 -0
  146. package/dist/chunk-UTI5G6OJ.js +44 -0
  147. package/dist/chunk-UVHDMMIP.js +188 -0
  148. package/dist/chunk-UZ6QQWJT.js +120 -0
  149. package/dist/chunk-VAX3UZOY.js +686 -0
  150. package/dist/chunk-W3HBUKFO.js +57 -0
  151. package/dist/chunk-WKQLGKCL.js +2823 -0
  152. package/dist/chunk-WQFNASK4.js +560 -0
  153. package/dist/chunk-WRBKLYSJ.js +224 -0
  154. package/dist/chunk-WTPZRR4I.js +17 -0
  155. package/dist/chunk-WZDJ2LLX.js +2385 -0
  156. package/dist/chunk-X2NL4KUO.js +14 -0
  157. package/dist/chunk-X4DVXBFY.js +38 -0
  158. package/dist/chunk-X677DMZM.js +7050 -0
  159. package/dist/chunk-XBKMWY7F.js +612 -0
  160. package/dist/chunk-XNCP4KYO.js +78 -0
  161. package/dist/chunk-XVBGLISP.js +35 -0
  162. package/dist/chunk-XW6KISBB.js +1192 -0
  163. package/dist/chunk-YGZHHAAB.js +8156 -0
  164. package/dist/chunk-YHEFQUQB.js +186 -0
  165. package/dist/chunk-YLDALTWZ.js +653 -0
  166. package/dist/chunk-YU4HSQW7.js +229 -0
  167. package/dist/chunk-ZDPP6RKB.js +27 -0
  168. package/dist/chunk-ZFDZKNGG.js +194 -0
  169. package/dist/chunk-ZIY7RNUT.js +49 -0
  170. package/dist/chunk-ZYJP6BOO.js +30 -0
  171. package/dist/cli.d.ts +1111 -0
  172. package/dist/cli.js +165 -0
  173. package/dist/codegen-command-VR2GWR6C.js +173 -0
  174. package/dist/codegen.d.ts +115 -0
  175. package/dist/codegen.js +19 -0
  176. package/dist/commands-GJMJ4XYZ.js +76 -0
  177. package/dist/completion-ZQLO6PB6.js +738 -0
  178. package/dist/config-MBYGCAFK.js +136 -0
  179. package/dist/deploy-command-JVZPWHMK.js +180 -0
  180. package/dist/deploy-source-3W45CXMP.js +81 -0
  181. package/dist/dev-env-sync-HN44K4D5.js +188 -0
  182. package/dist/diagnostics-UEAAHWVF.js +30 -0
  183. package/dist/display-name-hint-ABBPVQIR.js +46 -0
  184. package/dist/emit-CyhBDxli.d.ts +7007 -0
  185. package/dist/env-example-refresh-ET5HMMDW.js +126 -0
  186. package/dist/env-pull-command-TNMNB3CV.js +323 -0
  187. package/dist/env-target-O4HRKTGZ.js +40 -0
  188. package/dist/env-var-command-ZIRDCAZA.js +427 -0
  189. package/dist/ephemeral-P7F3PMAD.js +54 -0
  190. package/dist/ephemeral-command-CLJJKOFT.js +592 -0
  191. package/dist/ephemeral-state-PYCBSOGX.js +61 -0
  192. package/dist/file-lock-PT74GWYW.js +10 -0
  193. package/dist/generate-command-AYEOLA26.js +510 -0
  194. package/dist/gitignore-3SJ3WNRG.js +51 -0
  195. package/dist/guards-4EBNCZDC.js +162 -0
  196. package/dist/hosted-file-PSHJB3HX.js +28 -0
  197. package/dist/impersonate-command-5JAS3WSV.js +152 -0
  198. package/dist/import-EQSFU6JC.js +19 -0
  199. package/dist/index.d.ts +5470 -0
  200. package/dist/index.js +371 -0
  201. package/dist/init-command-X2FR4UAK.js +125 -0
  202. package/dist/init-modules-RY7UHAOL.js +50 -0
  203. package/dist/init-web-M4YZSZK6.js +42 -0
  204. package/dist/internal.d.ts +179 -0
  205. package/dist/internal.js +2634 -0
  206. package/dist/io-RC56QSN5.js +26 -0
  207. package/dist/keep-data-merge-7MPLVAVI.js +1119 -0
  208. package/dist/lambda-config-reconcile-OUZ4OYAB.js +116 -0
  209. package/dist/landed-753YLJ5H.js +40 -0
  210. package/dist/landing-record-SU52SKXU.js +61 -0
  211. package/dist/live-diff-YONW2VI3.js +65 -0
  212. package/dist/local-engine-choice-CH5X3W7E.js +79 -0
  213. package/dist/local-engine-command-Z3OL7VOD.js +766 -0
  214. package/dist/local-engine-config-5U3SC434.js +39 -0
  215. package/dist/local-engine-deploy-PCQ4SNPB.js +39 -0
  216. package/dist/local-engine-process-EHTE5LLU.js +63 -0
  217. package/dist/local-engine-state-A4FTRFAF.js +32 -0
  218. package/dist/local-engine-update-check-6L645VBL.js +162 -0
  219. package/dist/lock-MRRHW5GS.js +87 -0
  220. package/dist/lock-commands-2QG25HL7.js +963 -0
  221. package/dist/login-command-QPYRWOXZ.js +538 -0
  222. package/dist/logout-command-WOIQLDP6.js +56 -0
  223. package/dist/loop-ALIZLOSX.js +191 -0
  224. package/dist/marketplace-command-K5YL42NO.js +977 -0
  225. package/dist/marketplace-resolve-LY32JES5.js +40 -0
  226. package/dist/meta-client-YXGPW53C.js +244 -0
  227. package/dist/module-manifest-JFGHU2TC.js +38 -0
  228. package/dist/near-key.d.ts +13 -0
  229. package/dist/near-key.js +8 -0
  230. package/dist/node.d.ts +95 -0
  231. package/dist/node.js +969 -0
  232. package/dist/operation-outcome-TZSESNC7.js +51 -0
  233. package/dist/output-target-AL3I44GG.js +47 -0
  234. package/dist/package-manager-PRAHUSCN.js +34 -0
  235. package/dist/plan-disclosure-6G4MLZKD.js +114 -0
  236. package/dist/plugin-contributions-WRNY5ABG.js +28 -0
  237. package/dist/plugin-prompt-4VA3B4FF.js +20 -0
  238. package/dist/plugin-questions-JTSYA74Q.js +24 -0
  239. package/dist/plugin.d.ts +396 -0
  240. package/dist/plugin.js +1 -0
  241. package/dist/preflight-command-2T5Z36MI.js +321 -0
  242. package/dist/profile-command-AH3PIM4X.js +800 -0
  243. package/dist/profile-pointer-GRWJU3YD.js +31 -0
  244. package/dist/profile-select-74TRDEPD.js +23 -0
  245. package/dist/project-dependency-KSH63ODT.js +35 -0
  246. package/dist/project-sdk-REPPOIBD.js +24 -0
  247. package/dist/promote-command-PCWT5N6S.js +140 -0
  248. package/dist/prompt-II3F3LND.js +27 -0
  249. package/dist/publish-command-TTDUUJI5.js +414 -0
  250. package/dist/pull-command-VBO7C4S3.js +161 -0
  251. package/dist/pull-superseded-GJWLG7XO.js +20 -0
  252. package/dist/release-command-N4ZPKMHR.js +146 -0
  253. package/dist/release-ns-command-3KFDMZ6G.js +1745 -0
  254. package/dist/response-HlXahv4K.d.ts +925 -0
  255. package/dist/retry-command-AS5EVREX.js +34 -0
  256. package/dist/routes-manifest-SNWUVBT6.js +43 -0
  257. package/dist/runtime-XETHVF33.js +14 -0
  258. package/dist/scaffold.d.ts +1911 -0
  259. package/dist/scaffold.js +107 -0
  260. package/dist/secrets-file-LVZD6IIM.js +56 -0
  261. package/dist/secrets-fill-command-TTCPSVXN.js +218 -0
  262. package/dist/seed-VU7C3QYS.js +46 -0
  263. package/dist/sent-writes-N7X4OVXT.js +22 -0
  264. package/dist/source-resolve-S2XA3XZF.js +83 -0
  265. package/dist/static-host-B2Y6AG3W.js +352 -0
  266. package/dist/status-command-RUDXTF7I.js +554 -0
  267. package/dist/store-6VNCD4D7.js +20 -0
  268. package/dist/store-ARM6VMO5.js +98 -0
  269. package/dist/store-DuYfzKlW.d.ts +3144 -0
  270. package/dist/suggest-6FOOYKHG.js +12 -0
  271. package/dist/sveltekit.d.ts +154 -0
  272. package/dist/sveltekit.js +109 -0
  273. package/dist/sync-contributions-UCEROO26.js +415 -0
  274. package/dist/table-ENAVMITR.js +42 -0
  275. package/dist/tables-command-GUPXXSB5.js +156 -0
  276. package/dist/tenant-IIHPOHGP.js +42 -0
  277. package/dist/tenant-command-D5HGI2X4.js +965 -0
  278. package/dist/test-command-TIKZMDPZ.js +476 -0
  279. package/dist/tests-BGGIOK3L.js +26 -0
  280. package/dist/token-NICJUTMT.js +72 -0
  281. package/dist/toolchain-hooks-GHKLUJOH.js +96 -0
  282. package/dist/toolchain-modules-5EAZUONB.js +24 -0
  283. package/dist/tracked-backend-EIBUMDZA.js +80 -0
  284. package/dist/update-check-2W2NLDTC.js +136 -0
  285. package/dist/upgrade-command-QHNFWM7O.js +531 -0
  286. package/dist/verify-rollout-DRCK5TDD.js +61 -0
  287. package/dist/whoami-command-STGINTXH.js +51 -0
  288. package/dist/workspace-2CLHU2LK.js +93 -0
  289. package/dist/workspace-binding-56PIIPCP.js +54 -0
  290. package/dist/workspace-command-JEXQETJD.js +535 -0
  291. package/dist/workspace-env-F2RFMSC5.js +70 -0
  292. package/dist/workspace-export-OJSH2HIW.js +21 -0
  293. package/dist/writable-VA7OISSK.js +25 -0
  294. package/dist/xano-ICFUSPFR.js +42 -0
  295. package/dist/xanosdk-import-GN42TO2B.js +56 -0
  296. package/dist/xanosdk-project-S6DXJONQ.js +41 -0
  297. package/dist/xdo-CxDr2Q1C.d.ts +1371 -0
  298. package/guides/README.md +33 -0
  299. package/guides/authoring.md +921 -0
  300. package/guides/cli.md +401 -0
  301. package/guides/codegen.md +177 -0
  302. package/guides/codes.md +404 -0
  303. package/guides/coverage.md +74 -0
  304. package/guides/deploying.md +1525 -0
  305. package/guides/environment.md +400 -0
  306. package/guides/marketplace.md +415 -0
  307. package/guides/module-contract.md +558 -0
  308. package/guides/object-kinds.md +433 -0
  309. package/guides/project-structure.md +43 -0
  310. package/guides/scaffold.md +351 -0
  311. package/guides/typed-frontend.md +242 -0
  312. package/llms/client.md +63 -0
  313. package/llms/errors.md +56 -0
  314. package/llms/fields.md +176 -0
  315. package/llms/filters.md +183 -0
  316. package/llms/kinds-agent-mcp.md +30 -0
  317. package/llms/kinds-core.md +100 -0
  318. package/llms/kinds-knowledge.md +20 -0
  319. package/llms/kinds-realtime.md +76 -0
  320. package/llms/lambda.md +94 -0
  321. package/llms/legacy.md +44 -0
  322. package/llms/lock.md +40 -0
  323. package/llms/object-kinds.md +34 -0
  324. package/llms/statements-calls.md +62 -0
  325. package/llms/statements-catalog.md +320 -0
  326. package/llms/statements-data.md +66 -0
  327. package/llms/statements-runtime.md +68 -0
  328. package/llms/tests.md +59 -0
  329. package/llms/toolchain.md +16 -0
  330. package/llms/triggers.md +36 -0
  331. package/llms/values.md +29 -0
  332. package/llms-full.txt +2085 -0
  333. package/llms.txt +493 -0
  334. package/manifest.json +11224 -0
  335. package/package.json +162 -0
  336. package/types/lambda-globals.d.ts +129 -0
@@ -0,0 +1,1525 @@
1
+ # Signing in & deploying
2
+
3
+ Authentication, ephemeral environments, static hosting, releasing to production, and validating against a live instance.
4
+
5
+ ## Signing in & deploying (in depth)
6
+
7
+ **Sign in once** with `xanosdk login`. It runs the standard authorization-code + PKCE
8
+ browser flow (powered by the OpenID-certified [`openid-client`](https://github.com/panva/openid-client)):
9
+ opens your browser, you approve, and the CLI captures the redirect on a `127.0.0.1`
10
+ callback. On first use it dynamically registers its own OAuth client (RFC 7591) so the
11
+ authorize step never depends on the server tolerating an arbitrary loopback port; the
12
+ registration is cached in `~/.xano/xanosdk-clients.json`. The instance you're bound to is
13
+ read from the token's own `aud` claim.
14
+
15
+ `login` also **pins the numeric workspace** you consented to into the credential, so every
16
+ later command acts on exactly that workspace without looking it up again. There is **no
17
+ `--workspace` flag** — a credential addresses exactly one instance and one workspace. Run
18
+ `xanosdk workspace details` to see which.
19
+
20
+ The credential caches by default in a **shared** `~/.xanosdk/auth.json`, reusable
21
+ from **any** project directory — so a single `xanosdk login` covers all your projects.
22
+ Override the OAuth host with `--origin`/`$XANO_ORIGIN` and the loopback port with `--port`.
23
+
24
+ **Named profiles.** That file holds any number of credentials, each under a name.
25
+ `xanosdk login --profile staging` adds one without touching the others; `--profile <name>`
26
+ (or `-p`) selects one for a single command. See [Credential profiles](#credential-profiles)
27
+ below.
28
+
29
+ **When the browser can't reach this machine** — a remote shell, a container, a
30
+ Codespace — the `127.0.0.1` redirect lands nowhere and `login` waits five minutes for a
31
+ callback that will never arrive. Run `xanosdk login --paste` instead. It binds nothing:
32
+ it prints the authorize URL, you finish signing in in whatever browser you have, and
33
+ your browser then fails to load the `127.0.0.1` callback — which is expected. Paste that
34
+ URL back from the address bar (or just the `code=` value out of it) and the sign-in
35
+ completes. Everything else is identical, including the instance and workspace you pick
36
+ at consent.
37
+
38
+ Two things follow from how the code is bound. The paste has to happen in the same run
39
+ that printed the URL, so `--paste` needs a terminal — for automation use the environment
40
+ credential instead (see [Environment variables](environment.md)). And `--port` still
41
+ matters under `--paste`, not because anything binds it, but because it is part of the
42
+ redirect URL's identity.
43
+
44
+ Note this is **not** what `XANO_NO_BROWSER` does: that only suppresses the browser
45
+ launch, and still needs the redirect to arrive here.
46
+
47
+ Running `login` again when the **selected profile** already holds a usable credential
48
+ prints what you're signed in to and stops, rather than spending another browser round
49
+ trip — pass `--force` to sign in anyway. `login --profile staging` on a machine that
50
+ already has `prod` proceeds to consent: the guard is per profile, not per file.
51
+
52
+ ## Credential profiles
53
+
54
+ `auth.json` holds a map of **named profiles** under a versioned envelope. Each profile is
55
+ one credential — one instance, one workspace — in one of two shapes:
56
+
57
+ - **`oauth`** — written by `xanosdk login`. Refreshes and rotates. Do not hand-edit.
58
+ - **`token`** — a meta API bearer token for the same meta APIs, for automation. No login
59
+ flow, no refresh, no rotation. Store one with `xanosdk profile add <name> --instance <url>
60
+ --workspace-id <n>`, which prompts for the token (or reads it piped on stdin) rather than
61
+ taking it as a flag (a secret on a command line lands in the process list and the shell
62
+ history), and checks it against the instance before storing it. The token
63
+ itself stays valid until you revoke it wherever you minted it — `xanosdk logout` and
64
+ `xanosdk profile delete` only remove the CLI's copy.
65
+
66
+ Both shapes work at **either** location (project-local `./.xano/auth.json` or global
67
+ `~/.xanosdk/auth.json`) on the same file precedence ladder below.
68
+
69
+ **Which profile a command acts as**, highest first:
70
+
71
+ 1. `--profile <name>` / `-p <name>` on the command line.
72
+ 2. `xano.profile.json` at the project root — a committed, secret-free pointer naming one
73
+ profile, found by walking up from the working directory and stopping at the first
74
+ `package.json`. Write it with `xanosdk profile use <name>`. Nothing ambient outranks it:
75
+ a `$XANO_PROFILE` left in a shell rc cannot retarget a pinned project.
76
+ 3. `$XANO_PROFILE`.
77
+ 4. The credential file's own `default` key. Set it with `xanosdk profile set-default <name>`.
78
+ 5. The literal name `default`.
79
+
80
+ `xanosdk profile use` pins **this project**; `xanosdk profile set-default` changes **this
81
+ machine**. `xanosdk profile list` shows every profile with the default and the active one
82
+ marked, and `xanosdk whoami` and `xanosdk status` both report which profile is in play and
83
+ which rung chose it.
84
+
85
+ Writes are serialised on a lock and merge one profile at a time, so signing in, signing
86
+ out, or refreshing a token in one profile never touches another — including from two
87
+ processes at once.
88
+
89
+ > **Upgrading:** a file written before profiles is read as the `default` profile with no
90
+ > action needed. It is rewritten in the new shape the next time something writes to it.
91
+
92
+ **Project-local credentials** — pass `--local` to `login` to cache tokens in a
93
+ **project-local** `./.xano/auth.json` instead (which `login` **auto-adds to `.gitignore`**),
94
+ scoping the sign-in to that directory. Every command that **reads** credentials
95
+ (`deploy`/`details`, `whoami`, token refresh) resolves them **project-local first, global
96
+ as a fallback**: it uses `./.xano/auth.json` when present, otherwise `~/.xanosdk/auth.json` —
97
+ so a `--local` project keeps working without repeating the flag. `login` and `logout` do
98
+ **not** fall back: they target the shared global cache unless you pass `--local`. A `logout`
99
+ naming a profile that only `./.xano/auth.json` holds exits 8 (not found in the shared file)
100
+ and names `xanosdk logout --local -p <name>` — it does not report the session as gone. A
101
+ logout clears stored profiles only: with the `XANO_*` credential variables still set, it
102
+ says so, and commands keep authenticating with them. An explicit
103
+ `--config`/`$XANO_CONFIG` wins over both default file locations; only the three-variable
104
+ environment credential (below) outranks it, since it reads no file at all.
105
+
106
+ `deploy <file>` runs the exact same pipeline as `export` (including writing and
107
+ seeding `xano.lock` — the identities come from your code, so a throwaway
108
+ environment cannot leak into them), then create-or-refreshes the target environment and imports the compiled
109
+ workspace into it as a full replace — or, with `--keep-data`, as a merge (see
110
+ [Keeping your data between deploys](#keeping-your-data-between-deploys)). `deploy --bundle <path>` skips the compile and uploads a
111
+ bundle a previous `export` wrote (handy in CI). A **projected, secret-free summary** prints to stdout
112
+ as JSON, while the human-readable progress (and the live URLs) echoes to stderr:
113
+
114
+ ```jsonc
115
+ {
116
+ "kind": "ephemeral", // or "local-engine"
117
+ "destination": { // the PARENT it was created under, then the environment itself
118
+ "instance": "https://x.xano.io", "workspaceId": 1, "kind": "ephemeral",
119
+ "label": "ezm0-xkdq-6564", "url": "https://x.xano.io/tenant/ezm0-xkdq-6564",
120
+ "display": "my-app" // its display name, when it has one other than the handle
121
+ },
122
+ "profile": { "name": "default", "source": "implicit" }, // null for an environment credential
123
+ "url": "https://x.xano.io/tenant/ezm0-xkdq-6564", // the environment's own base URL
124
+ "ephemeral": { "name": "ezm0-xkdq-6564", "display": "my-app", "expiresAt": "…" },
125
+ "created": true, // false on a refresh
126
+ "data": "replaced" // or "kept" — see Keeping your data between deploys
127
+ }
128
+ ```
129
+
130
+ Present only when they apply: `localEngine` (in place of `ephemeral`), `keepDataSkipped` and the
131
+ merge lists (see [Keeping your data between deploys](#keeping-your-data-between-deploys)),
132
+ `static` (`url`; `canonical`, the build's identity, and `verified` when the frontend was
133
+ confirmed live serving it; `staticEnv` — `{ injected, keys, documents, skipped?, reason? }` — when config was
134
+ injected; or `error` with `retry`, the `xanosdk publish` that retries only the static step),
135
+ `microservices` (or `microservicesError`, `{ message, remedy }`, when their status could not be
136
+ read after the deploy), `testRun` (`total` — the whole suite, `passed + failed + notRun` —
137
+ `passed`, `failed`, `notRun`, and `tests[]` each with `kind`,
138
+ `name`, `qualified` — the `type:object/name` form `xanosdk test run` takes (a query's object is its
139
+ lock key, `query:<group>|<verb>|<name>/<test>`), `workflow:<name>` for a workflow test — `object` for a
140
+ unit test (a query's carries `group` and `verb`), `status`, `message` (the first failure),
141
+ `expectations` for a unit test — every expectation in order, `{ index, status, message? }`, `index`
142
+ from 0 — and `timing` for a workflow test — the engine reports none for a unit test; plus `error` and `retry`, the `xanosdk test run-all` to run
143
+ again, when the suite could not be run — exit 6 — and `unreachable`, the tests it never reached
144
+ (as `test run` takes them), when it stopped answering part-way; the same `N passed, M failed,
145
+ K not run` line `test run-all` prints closes the run), `landingRecord` (`{ destination, file,
146
+ recorded, changed, cleared }`: what this deploy recorded as landed, the scope of a later
147
+ `--prune`; `null` for a local engine), `staticRemoved` (`{ urls, verified }`: the frontends a replace took down, below),
148
+ `notApplied` (flags accepted and not acted on — `--expires-hours` on an ephemeral that already
149
+ exists), `unconfigured`, and `passwordHashesUnverifiable` (below). The raw
150
+ workspace blob is deliberately never dumped: it carries per-tenant secrets that must not land
151
+ in shell history or CI logs.
152
+
153
+ `xanosdk test run-all --json` (and `test run`, `test list`) writes the same per-test entries at
154
+ the top level, beside the backend they ran on: `kind`, `env` (the ephemeral's or tenant's handle;
155
+ `null` for a local engine or a workspace), `name` (the backend's own name whatever its kind — the
156
+ same handle on an ephemeral or tenant, the engine's name on a local engine) and `display` (its display
157
+ name), each always present, `null` where the backend has none. When the suite stops answering
158
+ part-way, the rest are not started and the run exits 6 with a failure document whose
159
+ `details.results` is that document — `total` the whole suite, `notRun` and `unreachable` the tests
160
+ that could not be run, `tests[]` the ones that did — and `details.unreachable` names them again
161
+ (as `test run` takes them). Every run document carries `notRun` (`0` on a run that finished), the
162
+ same counts `deploy --test`'s `testRun` carries.
163
+
164
+ **A retry the error prints** — when a deploy stops short (a new ephemeral not ready yet) or its
165
+ import's answer is lost, the error names the command to run next: this run's own, with its
166
+ credential flags, without create-only flags like `--expires-hours` (the ephemeral already
167
+ exists), and with no secret in it. `--env-var`, `--doc-token` and `--origin` values are left
168
+ out and named (`--env-var GREETING`) — pass them again, or keep them in `xano/.env` and
169
+ `xano/.secrets.json`, which a deploy reads. The same text is the `--json` error's `message`.
170
+
171
+ **Where it goes** — the instance your **token is bound to** (the token's `aud`), never a
172
+ flag. `xanosdk deploy` create-or-refreshes an **ephemeral**, which is the only environment it
173
+ writes to. Reaching your real workspace is the separate release flow: cut a release from
174
+ what ran, then `xanosdk promote <release>` lands it there as a new branch, leaving live
175
+ untouched until you say otherwise.
176
+
177
+ **Static host** — `deploy --static <dir>` archives a directory and deploys it to a
178
+ static host after the backend import. The frontend lands **on the ephemeral itself**, so the
179
+ backend and the frontend live in one environment. The CLI uploads the archive to
180
+ `/api:meta/workspace/{id}/static_host/default/build` with your ordinary bearer. That route
181
+ auto-creates the `default` host (and only that one: a `--static-host` naming a host the target
182
+ does not have is not found, exit `8` from `publish`, naming the hosts it does have) and
183
+ **auto-deploys to `dev`**, returning the live URL — so
184
+ the static step is independent of the backend deploy (the backend still runs first because
185
+ it's the primary action). The directory is checked **before anything is uploaded**: a missing
186
+ path, a file, an empty directory, or a build whose compressed archive is over the 100 MB upload
187
+ cap is refused and nothing is deployed (`publish` checks the cap before it asks, too). An upload that fails
188
+ after the backend landed leaves the backend in place and still writes the deploy's own `--json`
189
+ summary (not a failure document), with `static` holding `error` (the reason, as printed),
190
+ `retry` (the `xanosdk publish` that retries only the static step) and `completed`. The exit
191
+ code says whether the frontend is up: `3` with `completed: "no"` when nothing was sent or the
192
+ host refused it, `9` with `completed: "unknown"` when the upload was sent and its answer lost
193
+ (a 5xx, a timeout, a dropped connection) — run `retry`, which settles it either way.
194
+
195
+ A **replacing** deploy clears the target's static hosting — whatever the flags, the replace
196
+ removes it — so a later plain `xanosdk deploy`, or `deploy --to … --replace` (a workspace, a
197
+ tenant, or an ephemeral named as one), takes a published frontend down. Before the import (and
198
+ in a `--dry-run` plan) it reads which frontends the target is serving from the platform — one
199
+ another project or the dashboard published included — warns naming each URL, and reports them
200
+ as `staticRemoved: { urls, verified: true }`. Only when that read fails does it fall back to
201
+ this project's record of its last publish, said as unverified (`verified: false`). With
202
+ `--static` the new frontend is published again (the URL may change), and a URL it no longer serves
203
+ at is still named under `staticRemoved`. Re-run with `--static <dir>` to publish again, or merge (`--keep-data`; under
204
+ `--to`, drop `--replace`), which leaves the frontend serving.
205
+
206
+ **Liveness verification** — the build endpoint returning `200` only means the archive was
207
+ *accepted*; the edge may still be starting a cold host (which `503`s for tens of seconds) or
208
+ briefly routing the previous build. So after the upload the CLI polls the deployed URL until
209
+ the static server reports it is serving **this** build — its `X-Xano-Canonical` response header
210
+ matches the canonical returned for the build just pushed — then prints `Frontend is live`. It
211
+ polls every second for the first 30s, then every two seconds out to 120s. An unconfirmed poll
212
+ is a **warning, not a failure** (the build uploaded fine and usually comes online moments
213
+ later; the exit code stays `0` and the summary records `"verified": false`). Verification is
214
+ skipped when the response carries no canonical to compare against. Pass **`--skip-liveness`** to
215
+ skip the wait entirely — useful for fast iterative deploys or when the deployed URL isn't
216
+ reachable from the machine running the CLI.
217
+
218
+ **Config injection** — before archiving, the deploy rewrites EVERY `.html` document in the
219
+ build, inserting an inline `<script>` at the top of `<head>` that assigns each config value to a
220
+ `window.<KEY>` global (so it runs before the app bundle). The backend URL is seeded
221
+ automatically as `window.XANO_HOST` (from the backend deploy's own response), and
222
+ `--static-env KEY=VALUE` (repeatable) merges in extra keys, overriding the seed on a name
223
+ clash. A static host has no server runtime — it serves these files verbatim — so injected
224
+ values are **public**: base URLs and *publishable* keys only, never secrets (those go in
225
+ backend env, read via `env(name)`). Rewriting every document, not just the root, is what a
226
+ prerendered build needs: it serves a different document per route, so a root-only injection
227
+ leaves every deep link and refresh running with the global unset — and the page still renders.
228
+ Injection is skipped (reported as a warning, not a failure) when the archive has no document
229
+ with a `<head>` to anchor to, and any individual document that lacks one is named; values are
230
+ `<`-escaped so one containing `</script>` can't break out of the element. This is why a
231
+ prebuilt `frontend/dist` can retarget any environment with no rebuild.
232
+
233
+ > **Caching — verify with a cache buster.** The static host serves `index.html` with
234
+ > `Cache-Control: public, max-age=3600`, so a browser (or CDN) that loaded the page before
235
+ > your latest deploy can hold the old HTML — including a *pre-injection* `<script>`-less
236
+ > version — for up to an hour. If `window.XANO_HOST` looks missing, it's almost always this:
237
+ > hard-reload (Cmd/Ctrl+Shift+R) or open DevTools with "Disable cache" checked. When
238
+ > verifying from a script or agent, append a throwaway query param so you never read a cached
239
+ > copy — `curl -s "$URL/?nocache=$(date +%s)"` — and check the fetched HTML for the injected
240
+ > `window.XANO_HOST` line rather than retrying the same cached URL.
241
+
242
+ `xanosdk status` prints the same **environment base URL** out of band, for cases where you'd
243
+ rather bake it in at build time — along with the environment's name and expiry, without your
244
+ having to know either. (`xanosdk whoami` prints the *instance* base URL, i.e. the account's
245
+ origin rather than the environment.) A static failure after a committed backend deploy **does not roll
246
+ back**: it exits `3` (`9` when the upload was sent and its answer lost) and prints the `xanosdk publish` command that retries just that
247
+ step, without re-importing the backend.
248
+
249
+ **Publishing a frontend on its own** — `xanosdk publish <dir>` uploads an already-built
250
+ directory and nothing else: no compile, no backend import. `--to` picks the destination —
251
+ `ephemeral[:<name>]`, `workspace`, or `tenant:<name>`; left off, the backend this project last
252
+ deployed to — and a workspace or tenant asks before replacing what its users are served
253
+ (`--yes` in CI). `publish` does not reach a local engine, so after a local-engine deploy it
254
+ refuses the tracked engine and names `--to ephemeral`; the engine takes a frontend through
255
+ `xanosdk deploy --local-engine --static <dir>` instead (below). Scaffolded projects carry it as `npm run xano:deploy:frontend`,
256
+ which rebuilds first.
257
+
258
+ ```bash
259
+ xanosdk promote v2 --set-live # the backend, landed and live
260
+ xanosdk publish ./frontend/dist --to workspace --release v2 --branch <label> --yes
261
+ ```
262
+
263
+ The destination's own URL is injected as `window.XANO_HOST` at publish time (under any
264
+ `--static-env`), so a byte-identical build published to an ephemeral and to your workspace
265
+ serves two different HTML documents. That is the build-once, configure-per-destination
266
+ model, not drift.
267
+
268
+ A release carries no frontend and nothing records which release a static host is serving,
269
+ so the pairing is checked only as far as it can be, and `--json` says how far in `association`:
270
+
271
+ | `association` | What was checked |
272
+ |---|---|
273
+ | `none` | No `--release`. |
274
+ | `release-exists` | `--release` names a release in this workspace — nothing more. |
275
+ | `live-branch` | …and `--branch` is the label your workspace is serving. A label match, not a comparison of the branch's contents with the release. |
276
+
277
+ `--branch` refuses to publish when the label is not live, since the frontend would otherwise
278
+ sit in front of a backend it was not built for. It applies to `--to workspace` only.
279
+ Publication and rollout are reported apart — `published: true` with `verified: false` and
280
+ `verification: "unconfirmed"` means the upload succeeded but the host was not seen serving it
281
+ inside the wait window. Unlike `deploy --static`, `publish` holds no seed rows, so it cannot scan
282
+ the build for non-public seed values. An `index.html` that loads `.ts`/`.tsx`/`.jsx` source (the
283
+ unbuilt source directory, not its build output) is still published, with a warning on stderr and
284
+ in `--json` `warnings` naming the build step.
285
+
286
+ `deploy` reuses cached tokens and **refreshes them automatically** when the access token
287
+ expires (Xano rotates the refresh token on every use; the new one is persisted). A rejected
288
+ refresh (`invalid_grant`) clears the stale cache and tells you to `xanosdk login` again.
289
+
290
+ **CI & agents** run non-interactively. The credential to reach for is the **meta credential
291
+ as three environment variables** — the `type: "token"` record above, with no file:
292
+
293
+ ```bash
294
+ XANO_INSTANCE_URL=https://your-instance.xano.io \
295
+ XANO_WORKSPACE_ID=3 \
296
+ XANO_META_TOKEN=your-meta-api-token \
297
+ npx xanosdk deploy ./xano/index.ts
298
+ ```
299
+
300
+ Nothing is read from or written to disk, and nothing rotates, so the same three secrets keep
301
+ working run after run — which is what makes this the right shape for a CI job. It **outranks
302
+ every other credential**, including an explicit `--config` path, `$XANO_PROFILE` and
303
+ `$XANO_REFRESH_TOKEN`; whichever it displaces is named on stderr, so it never wins silently.
304
+
305
+ An explicit `--profile` is the one exception: it is a **hard error** rather than a
306
+ displacement, because it selects a stored credential that the environment has already
307
+ outranked, and acting on a different tenant with a contradicting flag on the command line
308
+ is the failure this ladder exists to prevent.
309
+
310
+ All three are required **together**. Setting some but not others is a hard error naming the
311
+ rest, rather than a quiet fallback to another credential — a workflow with one misspelled
312
+ secret must not deploy against whatever happens to be on the runner.
313
+
314
+ > **Automated agents:** do **not** invoke `xanosdk login` — it blocks on interactive browser
315
+ > consent. Use the three variables above.
316
+ >
317
+ > The older `$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works (both copied once
318
+ > from the profile in `auth.json` after a local `xanosdk login`; the target instance is read from the refresh
319
+ > token's `aud` and the workspace resolved per run), but Xano **rotates refresh tokens on
320
+ > use** — a stored one is spent by its first exchange, so a job that runs twice fails the
321
+ > second time. Prefer the meta credential.
322
+
323
+ ## Checking against a live instance (xanosdk preflight)
324
+
325
+ `xanosdk preflight` proves your compiled output against a **real, running Xano
326
+ instance** — not a static snapshot. It compiles your workspace, imports it into a
327
+ **fresh ephemeral environment created for that run**, exports it back, and diffs it
328
+ against what you compiled, so you catch three classes of problem a local build can't:
329
+
330
+ 1. **Import accepts** — the engine actually accepts the bundle (malformed-but-shaped output is rejected here).
331
+ 2. **Round-trip parity** — the workspace the engine stores, re-exported in the same bundle format, matches your compiled JSON after normalization (full object logic included). Every authored kind is diffed — tables, functions, queries, triggers, tasks, and more — each object matched by identity and reported per kind.
332
+ 3. **Runtime** (`--runtime`) — each deployed function actually runs on the engine, with no input, with logs surfaced on failure. A function that throws (a failed precondition, say) fails the gate with the engine's message. A function that declares inputs and is refused only for a missing or invalid one is reported as not run (`needs inputs`, and `needsInputs: true` in `--json`) and does not fail the gate — the run said nothing about its logic, so cover it with a unit test that supplies the inputs.
333
+
334
+ It talks only to public meta API routes — the **same** archive import `xanosdk
335
+ deploy` uses, plus the workspace export. There is one way into an instance, so a
336
+ transport bug is one `preflight` reproduces rather than routes around. (It also
337
+ fetches the engine's own XanoScript rendering of the imported environment, but only
338
+ when an installed toolchain module asks for it; the SDK fetches and hands it over,
339
+ and the comparison belongs to the module.)
340
+
341
+ It is non-destructive: nothing you own is written to. Each run creates its own
342
+ ephemeral environment, imports into that, and deletes it afterwards — including when
343
+ the import is rejected or a transport error is thrown. The environment carries a
344
+ short expiry, so even a killed process leaves nothing permanent behind. A fresh
345
+ environment per run is also what makes the diff trustworthy: the objects read back
346
+ can only have come from this bundle, never from what a previous run left.
347
+
348
+ **Setup** — nothing, if you have run `xanosdk login`. With no `XANO_VALIDATE_*`
349
+ variable set, `preflight` uses the credential the rest of the CLI already acts on
350
+ and creates its ephemeral under the workspace that credential pins.
351
+
352
+ To point a run somewhere else — a different instance, or a local Docker one —
353
+ copy `.env.example` to `.env` (gitignored) and fill in a base URL + token. That
354
+ pair takes over whenever it is set, and switching between a cloud dev instance
355
+ and a local Docker one is just a different `XANO_VALIDATE_INSTANCE`:
356
+
357
+ ```bash
358
+ # .env
359
+ XANO_VALIDATE_INSTANCE=https://your-instance.xano.io # or http://localhost:8080 for local Docker
360
+ XANO_VALIDATE_TOKEN=your-meta-bearer-token
361
+ # XANO_VALIDATE_WORKSPACE_ID=… # optional; PARENT workspace the run's env is created under (default 1)
362
+ ```
363
+
364
+ ```bash
365
+ xanosdk preflight ./xano/index.ts # import + round-trip diff, reports per object (every authored kind)
366
+ xanosdk preflight ./xano/index.ts --runtime # + run each deployed function
367
+ xanosdk preflight ./xano/index.ts --capture # + write fetched JSON to ./validate-out (fixture candidates)
368
+ xanosdk preflight ./xano/index.ts --instance http://localhost:8080 # override the target for one run
369
+ xanosdk preflight --bundle ws.json # check an already-exported bundle
370
+ ```
371
+
372
+ Two sources, and the environment wins. Set any `XANO_VALIDATE_*` variable (or
373
+ pass `--instance`) and the run targets what it names, reading the token from
374
+ `XANO_VALIDATE_TOKEN` — env-only, never a flag, so it stays out of your shell
375
+ history and the process list. A `.env` in the current directory is autoloaded for
376
+ those three variables and nothing else, and a real environment variable beats the
377
+ file. Set none of them and the run falls back to your stored login, where
378
+ `--profile` selects which credential to act as. A half-set environment is an
379
+ error naming the variable it is missing, never a silent fall back to your own
380
+ workspace. A non-zero exit means a check failed; `--verbose` prints full diffs and
381
+ raw engine detail instead of a projected summary.
382
+
383
+
384
+ ## Wiring the frontend to the backend
385
+
386
+ **Wiring the frontend to the backend.** The deploy bakes the environment's backend URL into
387
+ every HTML document in your build automatically, as a `window.XANO_HOST` global evaluated
388
+ *before* your app bundle — every document, so a prerendered build's deep links and refreshes
389
+ boot with the same backend the root does. So read it at runtime with a build-time fallback and you never have to
390
+ know the URL ahead of time:
391
+
392
+ ```ts
393
+ const HOST = (typeof window !== "undefined" && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;
394
+ ```
395
+
396
+ `window.XANO_HOST` is the **environment** URL your deployed APIs answer at (the same value
397
+ `xanosdk status` prints as the environment's URL); it is *not* `xanosdk whoami`, which prints
398
+ your account's instance origin. Because injection happens at deploy time, a prebuilt
399
+ `frontend/dist` retargets any environment with **no rebuild** — ideal for headless agents.
400
+ Add your own public config (base URLs, *publishable* keys) with `--static-env KEY=VALUE`
401
+ (repeatable), exposed the same way as `window.<KEY>`. A static host serves these files
402
+ verbatim to the browser, so everything injected is **public** — never put secrets here;
403
+ those belong in backend env, read server-side via `env(name)`.
404
+
405
+ **Showing a stored file.** A file column comes back as `{ path, name, type, size, meta,
406
+ access, url }`. Don't use its `url`: on a tenant-scoped environment that field addresses the
407
+ instance host *without* the `/tenant/<name>` segment and 404s — as a broken `<img>`, while
408
+ every assertion about the response still passes. Build the URL from `path` and the host you
409
+ already have:
410
+
411
+ ```ts
412
+ import { fileUrl } from "@xano/sdk";
413
+
414
+ <img src={fileUrl(row.avatar, HOST) ?? ""} /> // null for an absent file
415
+ ```
416
+
417
+ **Verifying the injection:** the served `index.html` writes the global in **bracket
418
+ notation** — `window["XANO_HOST"]="…";` — so grep for the bare token `XANO_HOST`, not the
419
+ exact string `window.XANO_HOST` (the dot form is valid to *read* the global in your app,
420
+ but it's not what the file contains, so an exact-string grep for it wrongly reads as "not
421
+ injected"). It can also be served from cache for up to an hour after a deploy — fetch once
422
+ with a cache-buster (`curl -s "$URL/?nocache=$(date +%s)" | grep XANO_HOST`) rather than
423
+ retrying the bare URL.
424
+
425
+
426
+ ## Deploy targets, and what a release changes
427
+
428
+ **Two targets**, so the dev loop and the production step stay distinct:
429
+
430
+ | Command | Where it goes |
431
+ |---|---|
432
+ | `xanosdk deploy` | A disposable **ephemeral** environment — create-or-refreshed each run, auto-expiring, with its own URL. This is the default and needs no flag. |
433
+ | `xanosdk promote <release>` | Your **main Xano instance** workspace — the production target. Lands a release on a branch named for it (`--set-live` serves it). Its table changes reach live as it lands — see [what a landing does to tables](#what-a-landing-does-to-tables). |
434
+ | `xanosdk tenant deploy <tenant> <release>` | A **customer tenant** — the same release, landed on someone else's deployment. It **replaces** what the tenant serves: anything the release does not carry is removed. Static hosting is kept. |
435
+ | `xanosdk deploy --to <dest>` | The **escape hatch**: merges a local build straight into `workspace` or `tenant:<name>`, skipping the release record. |
436
+ | `xanosdk deploy --local-engine` | A **local engine** on this machine — no Xano account needed ([below](#deploying-to-a-local-engine)). |
437
+ | `xanosdk test` | Nothing — it only reads. Runs the tests an already-deployed backend carries; `--on` picks which one, `local-engine` and `workspace` included. |
438
+
439
+ Not every instance has ephemeral environments enabled. Where they are off, `deploy` says so
440
+ and names who can turn them on — there is no second destination to fall back to. `xanosdk
441
+ export` and `xanosdk preflight` still work meanwhile.
442
+
443
+ Every `deploy` is a **full replace** of the disposable environment — always fresh — unless
444
+ you pass `--keep-data`. Landing somewhere real is the opposite by design: it changes what
445
+ your code defines and leaves the rest of the workspace — including every row in every
446
+ table — alone.
447
+
448
+ A replace clears everything the ephemeral holds, so before it writes, a refresh reads the
449
+ ephemeral and checks for objects **another source landed there** (another project's
450
+ `deploy --to tenant:<name>`, say) — anything this project neither sends nor landed before. As
451
+ with `--keep-data` below, those are deleted only with `--yes` or a yes on a terminal; without
452
+ one the deploy is refused before anything is written (`SDK_PRUNE_OUT_OF_SCOPE`, exit 2, each
453
+ object in `details.outOfScope`, and the rerun with `--yes` printed).
454
+
455
+ ### Keeping your data between deploys
456
+
457
+ `xanosdk deploy --keep-data` redeploys to your ephemeral or local engine by **merging** into
458
+ what an earlier deploy put there, instead of replacing it. Code and schema changes land,
459
+ objects removed from the project are deleted, and the rows already in the tables stay. Seed
460
+ rows are not written again.
461
+
462
+ The merge mirrors this project's source: the environment is the project's own sandbox, so
463
+ anything on it the project no longer declares is deleted. The preview tells that apart from
464
+ objects another source put there (a `deploy --to tenant:<name>` naming it from another
465
+ project, say) by the ephemeral's landing record: an object this project landed there is one
466
+ it "no longer declares"; anything else is "not in this project (landed by another source)".
467
+ Those are deleted only with an answer — `--yes`, or a yes on a terminal (a no writes nothing
468
+ and exits 0, as any declined confirmation does). Without one the
469
+ merge is refused before it writes, as `--prune` refuses the same deletion:
470
+ `SDK_PRUNE_OUT_OF_SCOPE`, exit 2, each object in `details.outOfScope` as `{ type, name, label }`
471
+ (the shape `deploy --to --prune` uses), and the rerun with `--yes` printed. A merge that goes ahead names them in `--json`'s `notLanded`. The scaffolded
472
+ `npm run xano:deploy:local-engine` passes it, so the rows you enter through your app survive
473
+ every code change.
474
+
475
+ It merges only into an environment an earlier deploy **finished** filling. Otherwise it
476
+ replaces and seeds as a plain deploy does, and says why:
477
+
478
+ | Reason (`keepDataSkipped` in `--json`) | When |
479
+ |---|---|
480
+ | `new` | Nothing was recorded for this project yet. |
481
+ | `recreated` | The recorded environment is gone — the ephemeral expired, or the engine restarted. |
482
+ | `never-filled` | The environment exists, but no deploy into it completed (or `.xano/ephemeral.json` lost the marker that one did — the message says which). |
483
+ | `reset` | `--reset` was passed; it always wins. |
484
+
485
+ A deploy that fails does not unmark the environment: a refused one wrote nothing, and one
486
+ whose outcome is unknown (its answer was lost, or the instance failed the merge with a 5xx —
487
+ exit 9) may have landed in whole or in part. Check what the environment holds before rerunning;
488
+ either way the next `--keep-data` still merges.
489
+
490
+ The `--json` summary carries `data: "kept"` or `data: "replaced"` on every deploy.
491
+
492
+ Before it writes, a merge prints what it will cost, without prompting:
493
+
494
+ - every table it drops (removed from the project) with its rows, and every column it drops;
495
+ - every column whose **type** changes — only the definition changes, and the stored values stay:
496
+ a value that reads as the new type does (int → text: `11` reads `"11"`), one that does not
497
+ reads `null` while the column has the new type, and reads again if it is retyped back. A
498
+ vector's size is part of its type (`vector(3)` → `vector(4)`, see the table below);
499
+ - every value removed from an **enum** column — rows holding it are not rewritten, and read
500
+ `null` until the value is restored;
501
+ - every **table reference** pointed at another table — the ids the rows hold are kept, and now
502
+ name rows of the new table;
503
+ - every public URL slug a changed `canonical` moves — every endpoint under the old slug stops
504
+ answering;
505
+ - every column that becomes **nullable** — a merge keeps the table's NOT NULL (an insert without
506
+ it still fails) though the export reads nullable, and only this preview can see it; `--reset`
507
+ (or `--replace` under `--to`) recreates the table, which relaxes it;
508
+ - every column that stops being nullable — rows holding `null` there are not rewritten, and read
509
+ back as the type's empty value (int `0`, text `""`; a date column's key is left out of every row);
510
+ - every index it adds or drops;
511
+ - every new table whose seed rows it does not write;
512
+ - every env var whose project value differs from the live one — a merge keeps the live
513
+ value (`xanosdk env set <NAME> <value>` changes it). Only names are printed, never values;
514
+ - every other object it deletes, named `<kind>:<name>` (`function:old_helper`,
515
+ `query:GET ping (apiGroup shop)`).
516
+
517
+ The `--json` summary carries the same lists, each only when it names something:
518
+ `droppedTables`, `droppedColumns`, `retypedColumns` (each `{ table, column, from, to }`, plus
519
+ `stored` — the type its values are held as, when known — `default` and `nullable` when declared,
520
+ `useXdo` on a JSON-storage table,
521
+ and `omittedInsertsFail` / `setInsertsFail` when inserts fail; see below),
522
+ `notNullKept`, `pairedColumns` (`{ table, dropped, added }`: a drop beside an add, not a rename),
523
+ `narrowedEnums` (`{ table, column, removed }`), `retargetedRefs` (`{ table, column, from, to }`),
524
+ `indexChanges` (`{ table, index, action, unique }`), `notNullTightened` (`{ table, column, type }`),
525
+ `storageChanges` (`{ table, from, to }`: the table's `useXdo` before and after),
526
+ `removed`, `unseededTables` (new tables whose seed rows the merge did not
527
+ write), `renamedTables` (each `{ from, to }`: renamed through `xanosdk lock rename`, rows kept),
528
+ `unchangedEnv` and `canonicalChanges` (each `{ kind, name, from, to }`: a public URL slug a
529
+ changed `canonical` moved — every endpoint under `from` stops answering). A realtime server's
530
+ moved slug, or a channel renamed in place, also leaves its `conversation` transcript and
531
+ `at_least_once` replay under the old name (warning `plan.realtime-history-detached`); moving
532
+ the name back restores them. Columns are named
533
+ `table.column` everywhere.
534
+
535
+ The limits, in one place: rows survive only in tables and columns that keep their identity
536
+ (a table renamed through `xanosdk lock rename` keeps its rows; an unrecorded rename is refused,
537
+ below); changed env values and a new table's seeds need `--reset`;
538
+ a local engine's rows last as long as its process (an engine update restarts it empty, and the
539
+ next run seeds); and a test whose `datasource` is `"live"`
540
+ sees the kept rows, not only the seed fixtures.
541
+
542
+ If the merge is refused, nothing is written — neither the environment nor `xano.lock` — and
543
+ the environment keeps its data. Each refusal is exit 2 and names its remedy:
544
+
545
+ - **A rename the lock was not told about** (`SDK_IDENTITY_CONFLICT`, `details.refused:
546
+ "renamePending"`): the merge would drop a table `xano.lock` still pins, with its rows, while
547
+ the project adds a new one. Each is in `details.renames[]` as `{ table, guid, candidates,
548
+ rename, prune }` — `rename` is the `xanosdk lock rename …` to run if it was renamed (the merge
549
+ then renames it and keeps its rows), `prune` the `xanosdk lock prune …` if it was deleted.
550
+ Run one, then deploy again. When the new table's def pins its own `guid:`, `setGuid: { table,
551
+ guid, pins }` replaces `rename`: set that def's `guid:` to `guid` — a def's guid replaces any
552
+ lock entry, so a `lock rename` would not keep the rows. The lock marks such an entry
553
+ `"guid_source": "code"`. An export that re-pins a table to its def's guid keeps the guid it
554
+ replaced as `"replaced"` (warning `lock.repinned-table`), so the merge refuses to drop a
555
+ table still holding it; `prune` is then `lock prune --identity-only table:<name>`.
556
+ The same refusal names a realtime channel (realtime server, api group) renamed in code whose
557
+ children — its messages — now collide by name: its entry in `details.renames[]` is
558
+ `{ kind, name, guid, candidates, rename, conflicts }`, and its `lock rename` moves the
559
+ children's entries with it.
560
+ - **A name the environment holds under another identity** (`SDK_IDENTITY_CONFLICT`,
561
+ `details.refused: "identityConflict"`): the same kind and name, a different guid, so the
562
+ merge would delete the live one and create the project's beside it. Each is in
563
+ `details.conflicts[]` as `{ kind, name, liveGuid, projectGuid }`. Pass `--reset` to rebuild,
564
+ or rename one of the two.
565
+ - **A pinned public URL the merge cannot serve** (`SDK_IDENTITY_CONFLICT`, `details.refused:
566
+ "identityConflict"`, each in `details.conflicts[]` as `{ kind, name, canonical, liveGuid }`):
567
+ a `canonical` written in code is served as declared or the merge is refused. The usual cause
568
+ is an api group (toolset, realtime server) renamed while keeping its `canonical`: a merge
569
+ creates the renamed object beside the old one, which still holds the slug. The entry carries
570
+ `heldBy` and `rename` — the `xanosdk lock rename …` that makes the merge rename it in place,
571
+ keeping its URL. Otherwise pick another `canonical`, or `--reset`.
572
+ - **A unique index the rows already there break** (`SDK_IMPORT_REFUSED`, `details.refused:
573
+ "uniqueViolation"`, the indexes the merge adds in `details.indexes[]` as `{ table, index }`,
574
+ exit 2, nothing written — `deploy --to` too): remove the duplicate rows or the index, then deploy again.
575
+ Under `--to … --seed`, `details.seededIndexes[]` names the unique indexes on seeded tables: a
576
+ seed row colliding with a live row there is fixed by changing the seed value or the live row.
577
+ - **Another source's objects** (`SDK_PRUNE_OUT_OF_SCOPE`, `details.refused:
578
+ "pruneOutOfScope"`, above): rerun with `--yes`, or declare them in this project.
579
+ - **A table trigger in an ephemeral or a tenant** (`SDK_IMPORT_REFUSED`, `details.refused:
580
+ "tableTrigger"`, each in `details.triggers[]` as `{ name, table }`): a merge into one fails
581
+ while the environment holds a table trigger — the instance answers 500, after part of the merge
582
+ may have landed — and removing the trigger from the project does not help while it is live.
583
+ A replace works: rerun with `--reset` (`--replace` under `deploy --to`), which replaces the
584
+ rows with the seed rows. A merge into a workspace is not affected.
585
+ - **A table holding rows switched to the other storage mode** (`SDK_IMPORT_REFUSED`,
586
+ `details.refused: "storageModeChange"`, each in `details.tables[]` as `{ table, from, to, rows }`):
587
+ `useXdo` (the table's own, or the workspace `use_xdo` it inherits) changed. A merge from JSON to
588
+ columns keeps the rows and erases every value for good; columns to JSON fails partway on NOT NULL.
589
+ `deploy --to` and `promote` refuse it too. Keep the mode, or move the rows to a new table
590
+ yourself; an empty table switches, with a note. `tenant deploy` is a replace, and differs:
591
+ JSON to columns converts the table and keeps every value (a note), while columns to JSON is
592
+ refused for every table, empty or seeded — the instance cannot convert a table back to JSON
593
+ storage, and nothing is sent. A `deploy --replace` rebuilds the tables, so it switches either way.
594
+
595
+ A keep-data refusal's `details` carries `refused` (the reason, a string), `conflicts`, `renames`
596
+ and `outOfScope` (each a list, empty when it does not apply), `serverCode` when the environment
597
+ itself refused, and `warnings` when the run had some. A `--keep-data` deploy never falls back to a replace.
598
+
599
+ Another import already running on the instance is not a refusal of the project: any deploy
600
+ (`--keep-data`, a replace, `--to`) exits 8 with `SDK_IMPORT_REFUSED`, `details.refused:
601
+ "importInProgress"`, writes nothing, and prints the same command to run again in a moment.
602
+ `--to` already merges, so `--keep-data --to` is refused; the flags that only mean something
603
+ with `--to` (`--seed`, `--prune`, `--dry-run`, …) stay refused alongside it.
604
+
605
+ ### Deploying to a local engine
606
+
607
+ `xanosdk deploy --local-engine` deploys to an engine running on this machine instead of an
608
+ ephemeral — no network round-trip per deploy. It needs nothing configured:
609
+
610
+ - **The first run** resolves the latest published engine for this machine's platform,
611
+ downloads it, checks it against the sha256 its release recorded, and **pins** that version in
612
+ your `package.json` under `"xanosdk": { "@xano/sdk": { "localEngine": "vX.Y.Z" } }` —
613
+ creating a `package.json` holding only that pin when the project has none. Commit that change —
614
+ every checkout of the project then runs the same engine.
615
+ - **Every later run** uses the pinned version, whatever is newest. Each version downloads once
616
+ per machine and is shared by every project on it (`~/.xanosdk/local-engine`, or
617
+ `XANOSDK_LOCAL_ENGINE_HOME`); a cached pin deploys with no network access.
618
+ - **A newer engine** is announced on each deploy (checked at most once an hour; opt out with
619
+ `XANOSDK_NO_UPDATE_CHECK=1`). In an interactive terminal the deploy asks whether to update,
620
+ defaulting to no — a version you declined is only noted afterwards, not asked again. In CI,
621
+ piped output or `--json` it prints a one-line notice and keeps the pin. The pin never moves
622
+ without a yes or an explicit `xanosdk local-engine update`.
623
+ - **A running engine on a different version** than the one the project now resolves to — after
624
+ an update, or after a teammate moved the pin — is stopped and replaced, and the deploy says
625
+ so.
626
+ - **No lifetime limit.** The engine runs until it is stopped. From v0.1.15 an `--ephemeral`
627
+ engine stops itself after an hour by default, so the SDK starts one with `--ttl 0`, and the
628
+ data and frontend it holds survive between deploys. An ephemeral engine you start by hand
629
+ keeps the one-hour default; its `--ttl <duration>` flag changes it.
630
+
631
+ An engine update restarts the engine, and a restarted engine starts **empty**: the next
632
+ `--keep-data` run (which the scaffolded `npm run xano:deploy:local-engine` passes) seeds it
633
+ rather than merging, so rows entered through your app do not survive the upgrade.
634
+
635
+ **Operating on it.** After a local-engine deploy, the engine is the backend this project
636
+ tracks, so bare `xanosdk test run-all`, `env set`, `env pull`, `tables`, `impersonate` and
637
+ `status` reach it — none of them needs a Xano account. Name it explicitly as `local-engine`,
638
+ or `local-engine:<name>` for another one from `xanosdk local-engine list` (the full grammar is in
639
+ [the CLI guide](cli.md#naming-a-backend)). Two commands cannot serve it and refuse it by name:
640
+ `publish` (a local engine takes its frontend from `deploy --local-engine --static`; or pass
641
+ `--to ephemeral`) and `release create`
642
+ (the cut runs on the instance — pass `--from ephemeral`).
643
+
644
+ | Command | What it does |
645
+ |---|---|
646
+ | `xanosdk local-engine update` | Moves the pin to the latest engine (`--version <v>`: that one, downgrades included), downloads it, and restarts the project's running engine on it. Commit `package.json`. |
647
+ | `xanosdk local-engine cache list` | Each cached engine version, its size on disk, and the engines running on it. |
648
+ | `xanosdk local-engine cache clear` | Removes every cached engine (`--version <v>`: just that one). An engine running on a removed version is stopped first; the next deploy fetches it again. |
649
+
650
+ #### A frontend on the local engine, and server rendering
651
+
652
+ `xanosdk deploy <entry> --local-engine --static <dir>` publishes a built frontend to the
653
+ engine's own static host after the import, as on an ephemeral, and reports it as `static.url`:
654
+ `http://<prefix>.localhost:<port>`. Chrome, Firefox and Safari 26 resolve `*.localhost` to this
655
+ machine by themselves (Safari on macOS 15 and earlier does not); a tool that does not resolve it
656
+ can call the engine's own URL with a `Host: <prefix>.localhost:<port>` header. The engine needs a release
657
+ that hosts static sites: an older one answers the upload with a 501, which the deploy reports
658
+ without failing the backend (exit `3`), and `xanosdk local-engine update` moves the pin on.
659
+
660
+ The engine hosts built output only, and refuses a build of more than 20,000 files or 256 MB unpacked.
661
+ The deploy checks both before the import, so an oversize directory stops the run with nothing
662
+ written. A symbolic link in the directory is not uploaded.
663
+
664
+ A SvelteKit app can also render on the server there. Build it with the SDK's adapter (it needs
665
+ `esbuild` in the app: `npm i -D esbuild`). In a project `xanosdk init` scaffolded, SvelteKit's
666
+ config is the object passed to `sveltekit()` in `vite.config.ts` (a `svelte.config.js` is ignored
667
+ there), and it already builds with this adapter: stop prerendering the routes that should render
668
+ per request (the scaffold prerenders every route from `frontend/src/routes/+layout.ts`):
669
+
670
+ ```ts
671
+ // vite.config.ts, inside sveltekit({ ... })
672
+ import adapter from "@xano/sdk/sveltekit";
673
+ // ...
674
+ adapter: adapter({ out: "frontend/dist" }),
675
+ ```
676
+
677
+ In a plain SvelteKit project it goes wherever that project configures SvelteKit, writing `build/`:
678
+ `sveltekit({ adapter: adapter() })` in `vite.config.ts` in a project `npx sv create` makes (it
679
+ writes no `svelte.config.js`), or `export default { kit: { adapter: adapter() } }` in a project
680
+ that keeps a `svelte.config.js`.
681
+
682
+ `vite build` then writes the client assets and prerendered pages at the output's root, a
683
+ `404.html` fallback shell, and the server half in `.xano-ssr/`. Deployed with
684
+ `--local-engine --static <that directory>`, the engine serves the files as files and renders every other
685
+ path (a dynamic route, `__data.json`, a form action) through the server half, so a crawler that
686
+ fetches `/products/lamp` gets that product's own `<title>` and meta tags. A server `load` reads
687
+ the engine's own URL as `XANO_HOST` from `$env/dynamic/private`:
688
+
689
+ ```js
690
+ // src/routes/products/[slug]/+page.server.js
691
+ import { env } from "$env/dynamic/private";
692
+ export async function load({ params, fetch }) {
693
+ const res = await fetch(`${env.XANO_HOST}/api:shop/products/${params.slug}`);
694
+ return { product: await res.json() };
695
+ }
696
+ ```
697
+
698
+ A rendered page gets the same `window.XANO_HOST` (and `--static-env`) globals the deploy writes
699
+ into the prerendered documents.
700
+
701
+ Any other host (an ephemeral, a tenant, your workspace) is not sent `.xano-ssr/`: it serves the
702
+ static half, and answers every other path with the `404.html` shell (status 404), which boots the
703
+ app in the browser. There, a route whose data comes from a server `load` (`+page.server.js`)
704
+ fails: its `__data.json` is not a file on that host. Prerendered routes, and routes with a
705
+ universal `load` or none, work. A prerendered `/404` route (the scaffold has one) stays
706
+ `404.html` only while every page route is prerendered; once one is not, the adapter writes the shell
707
+ there instead (and says so in the build log), so that route boots — an unknown path still answers
708
+ 404 and renders `+error.svelte`. Two things to know about the server half:
709
+
710
+ - **It can hold secrets.** SvelteKit inlines `$env/static/private` values into
711
+ `.xano-ssr/server.js`, which is why it is never served: the engine answers `/.xano-ssr/` with a 404.
712
+ - **It runs inside the engine, not in Node.** Module state is per runtime (the engine runs up to
713
+ four per site, so keep state in your API), request and response bodies are buffered (no
714
+ streaming), `request.formData()` reads urlencoded forms only, and there is no
715
+ `AsyncLocalStorage`. A render has 30 seconds and may answer up to 10 MB.
716
+
717
+ #### Where the engine keeps its data
718
+
719
+ The cache above holds the engine **binaries**. A running engine keeps its own **runtime data** —
720
+ its unpacked runtime files, its logs, and the records of running engines — beside them, under
721
+ `engine/` in the same directory (`~/.xanosdk/local-engine`, or `XANOSDK_LOCAL_ENGINE_HOME`): in
722
+ `engine/Library/Caches` on macOS and `engine/.cache` on Linux, the directory holding `dist`,
723
+ `instances` and `logs` folders. Its `logs` folder holds one file per engine start; a start that
724
+ fails names it.
725
+
726
+ Expect it to reach roughly 600 MB after the first deploy, and to grow by a similar amount for
727
+ each engine version you run, since every version unpacks its own copy. `xanosdk local-engine cache
728
+ list` shows its size, and `xanosdk local-engine cache clear` (with no `--version`) removes it with the
729
+ engines once none is running — it names any engine still up; stop it and clear again. The next
730
+ deploy recreates what it needs.
731
+
732
+ Versions before this layout unpacked the same runtime into your own per-user cache directory.
733
+ Nothing runs from that copy now; `cache list` shows it when it is there, and
734
+ `xanosdk local-engine cache clear --legacy-runtime` removes it after asking (`--yes` without a
735
+ terminal).
736
+
737
+ #### Running a different engine without moving the pin
738
+
739
+ To try an experimental build, or a published version the project hasn't moved to, override the
740
+ pin for your own runs instead of editing `package.json`:
741
+
742
+ - `--local-engine=<value>` for one deploy;
743
+ - `XANOSDK_LOCAL_ENGINE_OVERRIDE=<value>` for every deploy in this shell, including
744
+ `npm run xano:deploy:local-engine`, which passes a bare flag.
745
+
746
+ Both take a **version** (`v0.1.8`, or `0.1.8`: that release from the release manager), an
747
+ **`https` URL** to an engine archive, or the **path** to an engine archive on this machine. A
748
+ value on the flag beats the variable, and the variable beats the pin. Plain `http://` is refused
749
+ unless the host is this machine (`127.0.0.1`, `::1`, `localhost`) — the engine is a program the
750
+ deploy runs — and so is any other URL scheme (`ftp://`, `file://`). A path that names no file is
751
+ refused too; all of these are usage errors, reported before anything compiles or downloads.
752
+
753
+ An override never reads or writes the pin and never checks for updates. Each deploy that uses
754
+ the variable says so. A URL is printed only when its download fails, and then as its origin and
755
+ path, never its query string (a pre-signed link carries its credential there). The engine it names
756
+ replaces the project's running engine like any other version change (it starts empty and the
757
+ next `--keep-data` run seeds it); unset the variable and the next deploy goes back to the pinned
758
+ engine the same way.
759
+
760
+ ```bash
761
+ export XANOSDK_LOCAL_ENGINE_OVERRIDE=~/Downloads/engine-experimental.tar.gz
762
+ npm run xano:deploy:local-engine # runs the experimental build; package.json untouched
763
+ unset XANOSDK_LOCAL_ENGINE_OVERRIDE # next deploy is back on the pinned version
764
+ ```
765
+
766
+ `XANOSDK_LOCAL_ENGINE_TOKEN`, when set, is sent only to an override URL's host.
767
+ `XANOSDK_ENGINE_RELEASES_URL` points version lookups at a different engine release service. Its
768
+ engines are downloaded fresh on every deploy and kept apart from the version cache, and the
769
+ deploy runs its latest without reading or writing the pin or checking for updates;
770
+ `xanosdk local-engine update` refuses while it is set.
771
+
772
+ ### The release flow
773
+
774
+ This is the path to a real destination. Each step leaves something the next one can name:
775
+
776
+ ```bash
777
+ xanosdk deploy ./xano/index.ts --test # 1. stand it up on an ephemeral, run its tests
778
+ xanosdk release create v1 # 2. cut a release from what just ran (after a local-engine deploy: --from ephemeral)
779
+ xanosdk promote v1 # 3. land it in your workspace, on a branch named for it
780
+ xanosdk tenant deploy acme v1 # …or on a customer tenant
781
+ ```
782
+
783
+ Step 2 is what makes the rest re-runnable. A release is a stored record that this code came
784
+ up and answered, so `promote v1` a week later lands the same bytes — and `xanosdk release
785
+ list` says what you have. Cut a release per merge, per tag, or per deploy you want to keep;
786
+ an existing name is refused rather than replaced, because a tenant may be running it.
787
+
788
+ The platform lands a release itself, so `promote` and `tenant deploy` run no local
789
+ pre-flight: no plan preview, no prune scope. They say so before writing. What they have
790
+ instead is the release — a thing that came up, that can be landed again.
791
+
792
+ `promote` does run the identity check `deploy --to workspace` runs, against the release's
793
+ archive and the branch it will land on: a name the workspace gives another object, or a slug
794
+ pinned in code that another workspace serves, refuses with `SDK_IDENTITY_CONFLICT` (exit 2)
795
+ before anything lands — the platform's landing would otherwise add a suffixed second object or
796
+ mint another slug and report success. A release's identities are fixed when it is cut, so the fix
797
+ is a new release. After landing, `promote` reads the branch back: a pinned slug served under
798
+ another value fails the run (`verify: no`, exit 2, `--set-live` not applied); an unpinned one is
799
+ warned about (`promote.canonical-substituted`).
800
+
801
+ ##### What a landing does to tables
802
+
803
+ Tables have no branch. Every landing reads the destination's tables first and names every
804
+ effect under one code. `promote`, `deploy --to` and `deploy --keep-data` **merge**: the
805
+ release's definitions go over the rows and the stored values stay. `tenant deploy`
806
+ **replaces**: the tenant ends up with exactly the release's tables, and values are rewritten.
807
+
808
+ | Change | Merge | Tenant deploy | Code |
809
+ |---|---|---|---|
810
+ | table not in the release, or there by name under another guid | left alone | **dropped with every row** (counted, named in the confirmation); the same name under another guid is refused first (`SDK_IDENTITY_CONFLICT`), as `promote` refuses it | `plan.table-drop` |
811
+ | column dropped | values destroyed | values destroyed | `plan.column-drop` |
812
+ | column retyped | values re-read (int → text: `11` reads `"11"`); one that does not fit reads null, and reads again if retyped back | int, decimal and text values **converted for good** (`"1.5"` → `2`; `"hello"` → `0`; to bool: `false`); one that cannot convert stops it partway; bool → decimal converts (`true` → `1`), timestamp → bool converts to `false`; bool → int or text and timestamp → text are re-read; bool → timestamp and timestamp → int or decimal stop it partway while the table holds rows | `plan.column-retype` |
813
+ | vector size changed (`vector(3)` → `vector(4)`) | every stored vector reads null while the column has the new size, and reads again if changed back; an insert that sets a vector fails (SQL `22000`) — add the new size as a new column | stops it partway while the table holds rows | `plan.column-retype` |
814
+ | enum value removed | a row holding it reads null until restored | same | `plan.enum-values-removed` |
815
+ | table reference retargeted | ids kept, now naming rows of the new table | same | `plan.tableref-retarget` |
816
+ | index added or dropped | a unique index over duplicate rows refuses the landing | same | `plan.index-change` |
817
+ | column made nullable | a column created required keeps refusing rows without a value | relaxed | `plan.not-null-kept` (merge) |
818
+ | column made required | a row holding null reads the type's empty value | null **rewritten** to the empty value for good | `plan.not-null-tightened` |
819
+ | new table or column | existing rows read the column's default, else the type's empty value | same | `plan.schema-additive` (promote) |
820
+
821
+ A merge keeps the old type's storage, and an insert that leaves the column unset writes the
822
+ new type's empty value into it — `""` for text, email, password or enum, `{}` for json, `0`
823
+ for a number or timestamp, nothing for a date. Where the old storage cannot hold that, every
824
+ such insert fails with an SQL error:
825
+
826
+ | Retyped to | Fails from | Remedy |
827
+ |---|---|---|
828
+ | text, email, password | int, decimal, timestamp, bool, date, json, uuid, geo | a default the old type reads: `"0"`, `"false"`, `"1970-01-01"`, `"{}"`, a uuid (none for geo) |
829
+ | enum | int, decimal, timestamp, bool, date, json, uuid, geo | an enum value the old type reads |
830
+ | json | int, decimal, timestamp, bool, date, uuid, geo | none — set it on every insert |
831
+ | int, decimal, timestamp, bool | date, uuid, geo | none — set it on every insert |
832
+ | uuid | every type but json; one that SETS it fails too unless held as text, email, password or enum | none — add the uuid as a new column (another name) |
833
+
834
+ A column already declaring such a default is not flagged. A tenant deploy converts all but
835
+ bool and timestamp, so only those two retyped to text fail there (SQL `22P02`). The warning
836
+ names each column with its remedy, and `--json` marks it `omittedInsertsFail: true` (and
837
+ `setInsertsFail: true` when setting it fails too). The old storage is the type the column was
838
+ created with: an earlier merge retype does not change it. An ephemeral's landing record tracks
839
+ it (`stored`); with no record, the note says the prediction assumes the created type. Some
840
+ merge readings differ from the rule in the table above: decimal → int reads truncated (`29.789`
841
+ reads `29`, and whole again once retyped back), decimal → text reads padded to the column's
842
+ scale (`"29.78900"`), bool → text reads `"1"` / `""`, and int → date reads the int as epoch
843
+ milliseconds.
844
+
845
+ A table with `useXdo: true` keeps its fields as JSON, so none of the failures above apply there:
846
+ every value stays as written, an insert that leaves the column unset (or sets a uuid) lands,
847
+ and `--json` marks the column `useXdo: true`. Its readings differ too: decimal → text reads as
848
+ written (`"1.5"`), text or enum → timestamp or date can read as written or as the time of the
849
+ read rather than null, and json → date makes every read of the table fail (HTTP 500) while a
850
+ row holds an object there.
851
+
852
+ `promote` lands on a new branch, but these apply to **live** as it lands, before any
853
+ `--set-live`, and deleting the branch does not undo them. So a promote that alters a table
854
+ is refused (exit 2, `SDK_SHARED_SCHEMA_CHANGE`, every change in `details.changes`, additions
855
+ included) unless you pass `--allow-shared-schema-changes`; additions are named and land, and so
856
+ is a non-unique index added or dropped — it changes how rows are found, not what they hold. A
857
+ unique index added stays refused. A table that drops a column and adds another is called out
858
+ (`details.pairedColumns`): columns have no rename identity, so the old values are destroyed and
859
+ the new column starts empty — to keep them, add the new column in one release, backfill it, and
860
+ drop the old one in the next. A workspace whose
861
+ tables cannot be read is refused the same way. `--json` carries the effects as
862
+ `tableEffects`.
863
+
864
+ A `tenant deploy` of a release without rows keeps the rows of every table the release
865
+ carries, and drops the tables it does not carry. A release cut with `--seed` **replaces**
866
+ the rows of each table it carries rows for — every row there now is deleted — and keeps
867
+ every other carried table's. It names each one with both
868
+ counts before asking (`table items: 300 rows replaced by 3 seed rows`, `seedRowsReplaced`
869
+ in `--json`); off a terminal it needs `--yes`.
870
+
871
+ They differ in what they leave behind. `promote` adds a branch and leaves the live one
872
+ serving. `tenant deploy` replaces what the tenant serves with the release, so an API that
873
+ an earlier landing or a `deploy --to tenant:<name>` merge added is removed. Static hosting
874
+ is kept. Cut a release that carries everything the tenant should serve.
875
+
876
+ A `promote` over rows that break a unique index the release adds lands nothing — no
877
+ branch, live as it was — and exits 2 (`SDK_CONSTRAINT_VIOLATION`, `details.constraint`
878
+ naming the index when the release adds exactly one, `uniqueViolation: true`).
879
+
880
+ A `tenant deploy` that stops partway can leave the tenant partly replaced. When the
881
+ tenant's rows break something the release adds — a unique index over rows that share a
882
+ value, a required field that rows leave empty, a stored value a retype cannot convert
883
+ (`constraint.kind: "conversion"`, naming the column when one retype could cause it) — it
884
+ fails with `completed: "no"`, `partlyReplaced: true`, the `constraint` it hit, and
885
+ `recoverWith`: the `tenant deploy` of the release it ran before, which puts its logic and
886
+ definitions back (values a conversion already rewrote stay rewritten). Fix the rows, then land again. For
887
+ an `unknown` outcome, an unmoved `deployedAt` means the release did not land, not that the
888
+ tenant is untouched: land the previous release again to be sure.
889
+
890
+ #### Reading the result: `--json` and exit codes
891
+
892
+ `release create`, `promote`, `tenant deploy` and `release transfer` answer in one shape. Under `--json`, or
893
+ whenever stdout is not a terminal, stdout holds a single JSON document. The command writes
894
+ it on failure as well as success, once it knows what it was about to write. Every date in a
895
+ `--json` document, from any command, is ISO 8601 UTC (`2026-10-01T09:13:16.000Z`):
896
+
897
+ ```jsonc
898
+ {
899
+ "ok": false, // only when the run failed, beside `error`
900
+ "operation": "promote",
901
+ "destination": { "instance": "https://…", "workspaceId": 1, "kind": "workspace" },
902
+ "release": { "instance": "https://…", "workspaceId": 1, "id": 42, "name": "v1" },
903
+ "branch": "v1-20260922T140000Z-3f2a", // the label promote landed on; null for tenant deploy
904
+ "completed": "unknown", // yes | no | unknown
905
+ "steps": [
906
+ { "name": "land", "completed": "unknown" },
907
+ { "name": "verify", "completed": "skipped" }
908
+ ],
909
+ "resolveWith": "xanosdk workspace branch list --json",
910
+ "resolveSteps": ["If \"v1-…\" is absent, nothing landed: re-run as `xanosdk promote v1 --branch v1-… --yes`.", "…"],
911
+ "startedAt": "2026-09-22T14:00:00.000Z",
912
+ "resolveNotBefore": "2026-09-22T14:10:00.000Z",
913
+ "error": { "code": "SDK_ERROR", "message": "…", "exitCode": 9 } // only when the run failed
914
+ }
915
+ ```
916
+
917
+ A run that failed carries `ok: false` and `error` — the same `code` and `message` the failure document
918
+ below carries, and the exit code the run ends with — so `completed: "no"` always arrives
919
+ with its reason. `resolveSteps`, when present, says what to do with each answer `resolveWith` gives. A declined confirmation and a dry run that answered carry none.
920
+
921
+ `destination` is one shape on every command that writes: `{ instance, workspaceId, kind }`
922
+ for the workspace, and for an ephemeral or tenant (`tenant deploy`, `deploy --to tenant:<name>`)
923
+ `{ instance, workspaceId, kind, label, url }` — the instance and workspace it lives under,
924
+ `kind` its actual type (`"ephemeral"` or `"tenant"`, whichever way it was named), `label` its
925
+ bare name, and `url` its own base URL. `branch` is `null` wherever no branch applies — a tenant
926
+ deploy, and a release cut from an ephemeral. Beside it, `selector` spells that destination as `--to` takes
927
+ it (`"workspace"`, `"tenant:acme"`) and `workspaceId` repeats its workspace.
928
+
929
+ `release create` also carries `drift`: whether the source was compared against this
930
+ project's compile (`checked`), how many objects differ, are missing there, or exist only
931
+ there (`count`), and each of them named as `workspace diff` names it (`objects`, a source-only
932
+ one suffixed `(not in this project)`, one missing there `(not on the source)`) — or `checked: false` with the `reason` it could not run.
933
+
934
+ `completed` answers one question: does the outcome you asked for exist?
935
+
936
+ | `completed` | Exit | Meaning | What to do |
937
+ |---|---|---|---|
938
+ | `yes` | 0 | It exists. | Nothing. |
939
+ | `no` | non-zero; 0 when you declined the confirmation | It does not. If the run left something behind (a landed branch, a cut it could not delete), `residue` names it and the command that removes it. | Fix the cause and re-run. |
940
+ | `unknown` | **9** | The write was sent and the answer was lost: a timeout, a dropped connection, a 5xx, or Ctrl-C mid-write. | Run `resolveWith` before anything else. |
941
+
942
+ A run ended by a signal writes `interrupted: true`. With a write on the wire the outcome is `unknown`;
943
+ with none it is `cancelled` (exit 130 for Ctrl-C, 143 for SIGTERM, 129 for SIGHUP). Either way `landed`
944
+ says what this run had already landed, such as the deploy before its rename or static publish.
945
+
946
+ Never retry an `unknown` blind. The platform gives a taken release name a suffix rather
947
+ than refusing it, so a second `release create` after a lost response can leave two
948
+ releases. A read that says "absent" before `resolveNotBefore` is not proof either, because
949
+ the server may still be finishing the write. A step an earlier failure kept from running is
950
+ `skipped`. For `promote`, `verify` only checks the landing: a verification that could not
951
+ run leaves the landing `yes`.
952
+
953
+ A declined confirmation (typing `n`, or Ctrl-D) exits 0 and still answers `--json`. The
954
+ operation commands write their document with the step `no`; `tenant delete`, `ephemeral delete`,
955
+ `release delete` and `workspace branch delete` write `{ verb, destination, name, deleted: false,
956
+ alreadyGone: false, declined: true }` (a branch delete names `branch` instead of `name`).
957
+ `publish` writes its own document with `published: false, declined: true`, and `env set|unset`
958
+ write `{ verb, name, action: null, destination, declined: true }`; each carries `declined: false`
959
+ when it went ahead, so the shape is the same either way.
960
+
961
+ Every other command that fails under `--json` (`deploy` included) writes a failure document
962
+ instead, so stdout is never empty. Branch on `code`, not on the message:
963
+
964
+ ```jsonc
965
+ {
966
+ "ok": false,
967
+ "error": {
968
+ "code": "SDK_SEED_IN_STATIC",
969
+ "message": "Refusing to publish: …",
970
+ "exitCode": 1,
971
+ "details": { "leaks": [{ "file": "assets/index-….js", "table": "user", "column": "password" }] }
972
+ }
973
+ }
974
+ ```
975
+
976
+ `details` is an object, never a bare list: each code's list sits under its own key, beside
977
+ `warnings` (`{ code, message }` each) when the run printed some.
978
+
979
+ | `code` | Means | Extra fields |
980
+ |---|---|---|
981
+ | `SDK_SEED_IN_STATIC` | `deploy --static` found seed values in the build (below). | `details.leaks`: `{ file, table, column }` per leak |
982
+ | `SDK_EXPORT_INVALID` | The workspace failed an export check, or `--strict` promoted a warning. | `details.diagnostics`: `{ severity, code, message }` per failed check; under `--strict`, every warning that fails it; `code` names the check, e.g. `seed.public-seed`; `details.warnings` beside it, empty when the run printed none |
983
+ | `SDK_CREDENTIAL_REJECTED` | The instance, or its sign-in server, refused the credential this run used: exit 1. The message names the sign-in that fixes it. | `details`: `{ profile, credentialType, instance, workspaceId, signIn }` (`profile` is `null` for an environment credential; `instance` and `workspaceId` are `null` for a refused `XANO_REFRESH_TOKEN`; `signIn` is `null` when the fix is a variable or an upgrade rather than a command) |
984
+ | `SDK_USAGE` | The command line was mistyped or incomplete — an unknown flag, a value on a flag that takes none, a flag without the one it needs (`--dry-run` without `--to`), a missing argument, a confirmation with no terminal to ask on, a local file or directory it names that does not exist: exit 1. | `suggestion`: the closest match, when one is close enough |
985
+ | `SDK_ERROR` | Any failure without a code of its own yet — including a NAMED thing that cannot be found (a backend, release, branch, static host, test, profile or local-engine version: exit 8). A network failure or a server error (5xx) is exit 8 before anything is written — the backend's lookup, a read-only command's read (`tables`, `test list`, `env pull`, `status`, `whoami`, `workspace details`, `workspace branch`'s list), a local engine's release lookup or download, and `--keep-data`'s reads ahead of its merge (whether the environment can merge, its live read, the merge preview) got no answer or a 5xx; rerun as printed — and exit 1 at a later step that never reached it (an import that was not sent: nothing was written — retry). A `501` on `--keep-data`'s check is an answer, not an outage: that backend cannot merge, so it is exit 1 with the deploy that replaces instead. | `suggestion`: the near name a not-found one is one slip from, when there is one (`suggestions`: every one, when a command missed several) |
986
+
987
+ A piped stdout gets the document too, without `--json`, so a script reading the pipe never gets
988
+ an empty stdout. The exception is a command whose stdout is the data itself (`export` to stdout,
989
+ `llms`, `completion`, `--help`): a failure appended to it would corrupt it, so those write one
990
+ only under `--json`. A piped `release` or `promote` write prints its operation result (above)
991
+ either way.
992
+
993
+ `SDK_SEED_IN_STATIC` means `deploy --static` found seed values from a non-public column in the
994
+ build. If they are deliberately public (a demo login the frontend shows), declare the columns on
995
+ the table with `publicSeed: ["password"]`. Every deploy path then reads the same declaration.
996
+
997
+ #### Guarding what is live
998
+
999
+ `promote --set-live --expect-live v1` lands only if `v1` is the live branch when the
1000
+ promote starts. It checks again immediately before switching live, and if live moved in
1001
+ between, the new branch stays landed but is not served. The platform has no
1002
+ compare-and-set, so the check narrows the race; it does not close it.
1003
+ `workspace branch set-live <branch> --expect-live v1` takes the same guard, checked before
1004
+ it asks and again before it switches. `deploy --set-live` does not take it.
1005
+
1006
+ A check that does not hold is a conflict: exit 2, `SDK_LIVE_BRANCH_MISMATCH`, and the branch
1007
+ that is live under `details.conflictsWith.live` (`null` when no labelled branch is).
1008
+
1009
+ #### What a release carries
1010
+
1011
+ Every table's **schema**, always. That part is not selectable, so a release cut with no
1012
+ extra flags is not a release "without tables" — it has all of them.
1013
+
1014
+ What it does not carry, unless you ask for it, is any table's **rows**:
1015
+
1016
+ ```bash
1017
+ xanosdk tables # id, guid, and name — of what this project deployed to
1018
+ xanosdk tables ephemeral:e4f2-9ab1 # …or a named env (its name, not its display name)
1019
+ xanosdk tables tenant:acme # …or a tenant
1020
+ xanosdk tables workspace # …or your workspace
1021
+ xanosdk release create v1 --seed # every table's rows ride along
1022
+ xanosdk release create v1 --seed=<guid>,<guid> # only those two tables' rows
1023
+ ```
1024
+
1025
+ Rows are opt-in because a release is durable and can be landed on another environment; data
1026
+ should not travel there by accident. When a selected table has a column the schema marks
1027
+ non-public, the cut names it and asks before going ahead (`--yes` answers in advance).
1028
+
1029
+ Seeded **password** columns carry hashes, and a password hash is keyed to the environment that
1030
+ made it — every workspace, ephemeral and tenant has its own key, and no release carries it. The
1031
+ hashes land unchanged, so logins against those rows work only back on the environment the
1032
+ release was cut from; anywhere else each one answers as a bad password. A landing that hits this
1033
+ warns and names the columns (`passwordHashesUnverifiable` in `deploy --json` and `tenant deploy --json`). To log in
1034
+ elsewhere, deploy the entry file (its seed sends plaintext, which the destination hashes) or
1035
+ reset those passwords after landing. A standard tenant takes only a release, so there the
1036
+ choices are a reset on the tenant, or a release cut without that table's rows.
1037
+
1038
+ A release carries no file **bytes**. A file column's value and an MCP icon declared with
1039
+ `hostedFile()` both point into the source's file library, and a release cut from it copies the
1040
+ pointer without the file — on the backend it lands on, the row's file would be empty and the
1041
+ icon would answer 404. So `release create` refuses (`SDK_RELEASE_FILES_NOT_CARRIED`, exit 2) a
1042
+ cut whose source serves `hostedFile()` icons, or whose `--seed` selects a table with a file
1043
+ column while the source holds files. Land a project like that by deploying it directly, which
1044
+ ships its files: `xanosdk deploy --to tenant:<name> --seed`.
1045
+
1046
+ List from **the source you are cutting from**, not from your workspace. A table this project
1047
+ deployed carries the guid `xano.lock` pins for it wherever it landed, but one created anywhere
1048
+ else — in the dashboard, by another project — has a guid its own backend assigned, so a guid read
1049
+ off one backend is not proof of the same table on another. That is what `tables <backend>` is for.
1050
+
1051
+ #### Why guids, and the one thing to know about them
1052
+
1053
+ Selection is by **guid**. Not by id, and not by name, and both exclusions are about how the
1054
+ wrong choice *fails* rather than about convenience.
1055
+
1056
+ An **id** is a small dense integer, so an id from the wrong backend almost always exists on
1057
+ this one — and names a different table. The server does not refuse an identifier it does not
1058
+ recognize; it ignores it and answers 200. So a stale id cuts a release that reports success
1059
+ and carries the wrong rows into something you can promote to production. A stale **guid** is
1060
+ simply absent from the source's listing, so the cut is refused before anything is written.
1061
+
1062
+ A **name** is the one identifier you can change, so a saved name selection silently
1063
+ re-targets at whatever holds that name after a rename — the same failure by a different
1064
+ route. Names are also unfiltered free text (they may contain a comma), so a comma-separated
1065
+ list of them could not be read back reliably.
1066
+
1067
+ > **A guid survives a redeploy.** A deploy sends the guids `xano.lock` pins — derived from each
1068
+ > table's name (`md5(dbo:<name>)`, 32 hex characters) — and the import keeps them, a full replace
1069
+ > included: the table's **id** moves on every replace, its guid does not. So a guid you wrote down
1070
+ > keeps matching after the next deploy, and is preserved **exactly** from there on: cut a release
1071
+ > from that environment and promote it into a workspace branch and the guid is byte-identical at
1072
+ > every hop, while the ids move.
1073
+ >
1074
+ > What changes one is a **rename** — the name is what the guid derives from. A 32-hex guid that
1075
+ > no longer matches usually means the table was renamed or removed in code since it was noted;
1076
+ > `xanosdk lock rename` carries the identity across a rename you meant. A guid in the engine's own
1077
+ > shape (about 27 base64url characters) belongs to a table this project did not deploy, and does
1078
+ > not travel between backends.
1079
+
1080
+ `release create` checks every guid against the source it is about to read, prints that
1081
+ source's real tables if one does not match, and names the listing verb for that source.
1082
+
1083
+ ### The alternative: merging a local build
1084
+
1085
+ `xanosdk deploy <entry> --to workspace` (or `--to tenant:<name>`) skips the release record and
1086
+ merges the build on your disk straight into the destination. Reach for it when you want the
1087
+ pre-flight the release path does not have — a plan preview, a prune scope, loss warnings —
1088
+ or when you are deleting objects, which a release does not do.
1089
+
1090
+ What it leaves behind is the **landing record** in `xano.lock`: which identities this project
1091
+ wrote to that destination, and the whole scope of a later `--prune` there (see below). Commit
1092
+ the lock after it. What it does not leave is a release: no stored archive, nothing to land again
1093
+ or on a second destination, nothing for a tenant to be pointed at, and no record of what the
1094
+ build *was* beyond the commit you built it from. Prefer the release flow for anything you may
1095
+ need to repeat.
1096
+
1097
+ The flags that describe the ephemeral a plain deploy refreshes — `--name`, `--expires-hours`,
1098
+ `--reset`, `--keep-data`, `--test` (with `--kind`/`--concurrency`), `--no-dev-env`,
1099
+ `--require-microservices`, `--open` — are refused beside `--to`, before sign-in. `--static`
1100
+ publishes beside the merge, but not under `--dry-run`; `--skip-liveness` needs `--static` there.
1101
+
1102
+ | `deploy --to` flag | What it does to the destination |
1103
+ |---|---|
1104
+ | *(none)* | Adds new objects, updates existing ones. Nothing is deleted, no rows are written. |
1105
+ | `--dry-run` | Prints the plan and exits without sending anything. |
1106
+ | `--prune` | Also deletes objects **this project landed on this destination** and no longer defines — per the `landed` record in `xano.lock`, see below. |
1107
+ | `--reset-data` | Empties every table the bundle carries. |
1108
+ | `--seed` | Writes the bundle's table rows **by id**: a row the destination holds at a seed row's id is overwritten (a seed row with no `id` is numbered by position, 1, 2, …). The plan names each table's rows written and how many overwrite a live row, and the confirmation repeats the count. Combine with `--reset-data` to reset **and** re-seed. |
1109
+ | `--replace` | The disposable-environment behavior: wipe the workspace — **including every branch** — and import in its place. See below. |
1110
+ | `--branch <label>` | Land on a branch instead of the live one. See below. |
1111
+ | `--set-live` | Promote that branch to live once the import succeeds. |
1112
+ | `--backup-branch[=<label>]` | Snapshot the live branch's logic first. Switching back restores logic only — see below. |
1113
+ | `--allow-shared-schema-changes` | Proceed even though table and microservice changes reach every branch. |
1114
+ | `--allow-branch-deletion` | Proceed even though `--replace` permanently deletes the workspace's other branches. |
1115
+
1116
+ #### `--replace` clears by workspace, not by branch
1117
+
1118
+ The name suggests it replaces what you are deploying. It does not: the clear runs
1119
+ against the whole **workspace**, so every branch in it is deleted — not hidden,
1120
+ not detached — along with the vault secrets, services, marketplace installs,
1121
+ request history and the **saved versions** that would otherwise be the way back.
1122
+ Nothing restores them afterwards.
1123
+
1124
+ The live branch comes back, because the archive recreates it. Every other branch
1125
+ does not, and that includes the `ide-before-*` and `v2-*` snapshots the platform
1126
+ takes on your behalf — a real workspace was measured holding 1,841 of them.
1127
+
1128
+ So a replace is refused when the workspace has any non-live branch:
1129
+
1130
+ ```
1131
+ ✗ Refusing to `--replace` workspace #33: it has 6 non-live branches — 1 yours,
1132
+ 5 platform backups, and the clear deletes branches by WORKSPACE.
1133
+ ```
1134
+
1135
+ The plan lists them before anything is written (the first ten, plus a count), and
1136
+ marks which were made by the platform, so the number that decides whether to go
1137
+ ahead — how many are **yours** — is the first thing you read. Branches the engine
1138
+ reports with no label are counted too: they cannot be named or exported, and a
1139
+ replace deletes them like any other.
1140
+
1141
+ `--yes` does not cover this; it waives the confirmation, not the loss. To go
1142
+ ahead, pass `--allow-branch-deletion`. To keep the work first, export each branch
1143
+ on its own — a bare `xanosdk workspace export` reads the **live** branch, which is
1144
+ the one a replace restores anyway:
1145
+
1146
+ ```sh
1147
+ xanosdk workspace export --branch branch2
1148
+ ```
1149
+
1150
+ Or drop `--replace`: the default merge leaves other branches untouched. Tenants
1151
+ have no branches, so none of this applies to `--to tenant:<name>`.
1152
+
1153
+ Under `--json`, the same facts arrive as `branchDeletion` — an exact `total`, the
1154
+ `authored` / `platformBackups` split, an `unlabeled` count for the rows that
1155
+ cannot be named, and the `labels` that can — so a tool can show the scope before
1156
+ it asks anyone to approve it. `unknown: true` means the inventory could not be
1157
+ read, which is not the same as nothing to lose.
1158
+
1159
+ ### Where a release lives
1160
+
1161
+ A release is always a record **in your workspace**, whatever it was cut from —
1162
+ that is what `xanosdk release list`, `promote`, and `tenant deploy` all read.
1163
+
1164
+ Cutting from an environment is one call. The platform reads that environment's
1165
+ live state and writes the record here — **nothing is written to the
1166
+ environment**, so a disposable one expiring later takes nothing with it and a
1167
+ sandbox you cut from is left exactly as it was.
1168
+
1169
+ Only a throwaway environment may be a source. `--from tenant:<name>` is accepted
1170
+ for an ephemeral or a sandbox, and refused for a `standard` or `run` tenant: that
1171
+ is a live deployment. `xanosdk tenant get <name>` names the last release landed
1172
+ there, which lands anywhere as it is — but a `deploy --to` merge since may have
1173
+ changed what the tenant runs. To start from what it runs now, `xanosdk pull` it,
1174
+ deploy that to an ephemeral, and cut from there.
1175
+
1176
+ Cutting `--from workspace` packages a branch directly — whatever the workspace
1177
+ currently serves, or the one `--branch <label>` names.
1178
+
1179
+ An instance that predates the one-call cut does not reject the request; it
1180
+ ignores the source and cuts from your workspace instead. The CLI confirms every
1181
+ sourced cut against the workspace audit log, and if the source was not honored it
1182
+ deletes the release it just created and refuses, rather than leaving one whose
1183
+ contents are not what its name claims.
1184
+
1185
+ ### Moving a release to another workspace or instance
1186
+
1187
+ `xanosdk release transfer <name> --to-profile <profile>` copies a stored release into the
1188
+ workspace a stored credential profile is bound to — another workspace, or another
1189
+ instance. It moves the archive and nothing else: no compile, no branch, nothing lands.
1190
+ Run `promote` on the destination afterwards, exactly as you would at the source.
1191
+
1192
+ ```bash
1193
+ xanosdk release transfer v1 --to-profile prod --dry-run # is it already there?
1194
+ xanosdk release transfer v1 --to-profile prod --yes # copy it
1195
+ xanosdk promote v1 --profile prod # land it there
1196
+ ```
1197
+
1198
+ `--to-profile` names a profile from `xanosdk profile list`; the run's own credential is the
1199
+ source. A profile bound to the same instance and workspace is refused.
1200
+
1201
+ A transfer decides "already there" by content, never by name or size: the sha256 of the
1202
+ archive's tar, which is the same on every host that stores it. So it is safe to re-run.
1203
+
1204
+ | The destination holds… | Result |
1205
+ |---|---|
1206
+ | a release with the same content | Reused — nothing imported, `completed: yes`, `reused: true`. |
1207
+ | a **different** release under the name | Refused, exit `2`. Releases are never replaced; `conflictsWith` names the holder. |
1208
+ | neither | Imported under the archive's own name, read back, and hashed again. |
1209
+
1210
+ `--dry-run` writes nothing and answers `present`: `identical`, `conflict` or `absent`.
1211
+ Under `--json` the result is the shape above, with steps `read`, `search`, `import`, `verify`
1212
+ (`read` is the source side, so a release that is not there fails as `read: no`, exit `8`);
1213
+ `release` is the destination's copy, `source` is where it came from, and `sha256` is the
1214
+ digest both sides agree on. A transfer never deletes: if what landed does not hash to what
1215
+ was sent, it exits `2` and `residue` names the import and the `release delete` that
1216
+ removes it.
1217
+
1218
+ ### Pulling a backend into a project
1219
+
1220
+ `xanosdk pull <source>` refreshes `xano/` in a project that already has a frontend, a lockfile,
1221
+ and a git history — where `xanosdk init --from <source>` writes a whole new project instead. It
1222
+ takes the same source vocabulary everything else does: `release:<name>`, `ephemeral:<name>`,
1223
+ `tenant:<name>`, `workspace`, or a bundle path.
1224
+
1225
+ ```bash
1226
+ xanosdk pull release:v1 # refresh xano/ from a release (keeps files you added)
1227
+ xanosdk pull workspace # …or from the live workspace
1228
+ xanosdk pull release:v1 --yes # skip the confirmation and the dirty-tree refusal (CI)
1229
+ ```
1230
+
1231
+ `pull` **replaces** and cannot merge: a file the previous decode wrote that this one does not
1232
+ is deleted, and a decoded file is rewritten, hand edits included. A file no decode wrote (a def
1233
+ you added, notes) is kept and named. It lists the deletions, asks, and refuses a dirty working
1234
+ tree.
1235
+
1236
+ `xano.lock` is **reconciled**, not preserved — an entry survives only when the source carries an
1237
+ object with the same key *and* the same identity, so a lock cannot pin a guid the new backend
1238
+ never had. If none of the lock's identities appear in the source, that is what pointing a
1239
+ project at an unrelated backend looks like, and the pull is refused unless you pass `--yes`.
1240
+
1241
+ ### Deploying to a branch
1242
+
1243
+ `--branch <label>` stages on a Xano branch instead of the live one, which is the safer shape
1244
+ when a workspace has no staging twin. `deploy --to workspace --branch <label>` creates the
1245
+ branch as a clone of live, and refuses a label that already exists (exit 2,
1246
+ `SDK_BRANCH_TAKEN`) — pick a new label for each deploy, or delete the old branch first. Nothing
1247
+ serves the branch until you promote it.
1248
+
1249
+ `promote` lands a release on a branch and takes the same flag:
1250
+
1251
+ ```bash
1252
+ xanosdk promote v1 --branch staging # land the release there, live untouched
1253
+ xanosdk workspace branch list # see what exists, and what is live
1254
+ xanosdk workspace branch set-live staging # promote when you are happy
1255
+ ```
1256
+
1257
+ A promote **always** lands on a branch, named or not: without `--branch` the label is derived
1258
+ from the release and the moment (`v1-20260910T200511Z`), and the run prints it. There is no
1259
+ unnamed landing — a branch the engine leaves unlabelled cannot be selected, promoted or
1260
+ deleted afterwards, so the CLI names it rather than letting that happen.
1261
+
1262
+ `--set-live` does that last step in one go, the moment the import lands — with `--branch` to
1263
+ stage first and cut over immediately, or on its own as the one-shot to production:
1264
+
1265
+ ```bash
1266
+ xanosdk promote v1 --set-live # land on a derived branch and serve it
1267
+ ```
1268
+
1269
+ The merge path takes it too, when you want the preview a release cannot run:
1270
+
1271
+ ```bash
1272
+ xanosdk deploy ./xano/index.ts --to workspace --branch staging --dry-run # preview against the branch
1273
+ xanosdk deploy ./xano/index.ts --to workspace --branch staging # land it, live untouched
1274
+ ```
1275
+
1276
+ There, `--backup-branch` snapshots live first, so the way back is `xanosdk workspace branch
1277
+ set-live <backup-label>`. That restores **logic only**: tables are shared by every branch, so a
1278
+ column or table the deploy dropped stays dropped, with its data, after the switch back. The
1279
+ data-safe path is before landing: read `droppedColumns` in the `--dry-run` plan, and keep the
1280
+ column in code until its data has moved.
1281
+
1282
+ **A branch stages LOGIC, not SCHEMA.** Api groups, queries, functions, and toolsets are
1283
+ per-branch; **tables and microservices are shared across every branch in the workspace**. So
1284
+ landing a table schema change changes it for live too, branch or not — the merge path refuses
1285
+ that combination unless you pass `--allow-shared-schema-changes` (`SDK_SHARED_SCHEMA_CHANGE`, exit 2, as `promote`; under
1286
+ `--dry-run` it is reported in the plan, and `sharedSchemaChanges` counts them), because "I staged
1287
+ it on a branch" is exactly the belief that makes a dropped column surprising.
1288
+
1289
+ **A branch does not carry the workspace's own settings either.** The name, description,
1290
+ preferences, request history, realtime settings and workspace-tier middleware live on the
1291
+ workspace, one copy for every branch. `deploy --to workspace --branch` therefore leaves all of
1292
+ them as the workspace has them — `workspace("…")` does not rename the workspace from a branch —
1293
+ and names each one it held back (`workspaceSettingsNotApplied` under `--json`). They apply when
1294
+ you deploy without `--branch`, onto the live branch; after `--set-live`, that is the branch you
1295
+ staged. Env is the exception: a merge only ever creates a name the workspace lacks, and the staged
1296
+ logic reads it. A `promote` lands a release the server holds, so it keeps the name and description
1297
+ but applies the release's other workspace settings, and says so before it writes.
1298
+
1299
+ **Seeded ROWS are shared too, which gives reference data an order.** A table's rows are
1300
+ workspace-wide for the same reason its schema is, so rows reach production the moment they
1301
+ are applied — even while the logic that reads them is staged on a branch. No ordering makes
1302
+ a half-data, half-logic change atomic; one order is simply less bad than the other:
1303
+
1304
+ ```bash
1305
+ xanosdk workspace reset-tables ./index.ts --table <guid> # dry run, per-table counts
1306
+ xanosdk workspace reset-tables ./index.ts --table <guid> --write # then apply
1307
+ xanosdk promote v2 # then the logic
1308
+ ```
1309
+
1310
+ Rows first, then logic: new rows under old logic break only a feature you are removing, while
1311
+ new logic over old rows breaks the one you are shipping. `reset-tables` empties each named
1312
+ table and re-inserts the rows your project compiles, restarting the key sequence so int ids
1313
+ land where the seed declares them, and touches no other table. The two calls are not one
1314
+ transaction — a failure between them leaves that table empty, which the command says plainly,
1315
+ and the remedy is to run it again, because the rows come from your source.
1316
+
1317
+ **The merge refuses rather than guessing.** It will not proceed unless the server confirms
1318
+ which branch it planned against. An instance whose build predates branch-targeted import
1319
+ fails closed instead of quietly landing on live.
1320
+
1321
+ **An unchanged project is a no-op.** Before importing, the release compares the bundle it is
1322
+ about to send against the workspace it is about to send it to, object by object. When every
1323
+ object is already there, nothing is sent: the plan prints `no changes`, the JSON summary
1324
+ reports `"upToDate": true` with `"operations": 0`, and no `updated_at` moves. A `--dry-run --json`
1325
+ carries the same `upToDate`, and lists each matched object with action `unchanged`. That makes
1326
+ `release` usable as a reconcile step — safe to run on a schedule, in CI on every merge, or
1327
+ behind a "make production match main" button.
1328
+
1329
+ **One document for a dry run and a real run.** `deploy --to … --json` writes the same keys
1330
+ either way, and `dryRun` says which it was:
1331
+
1332
+ | Key | Meaning |
1333
+ | --- | --- |
1334
+ | `dryRun` | `true` for `--dry-run`; nothing was written. |
1335
+ | `landed` | an import ran. `false` with `upToDate: true` is a success. |
1336
+ | `upToDate`, `operations` | already converged; how many operations were (or would be) sent. |
1337
+ | `mode`, `prune`, `truncate` | `merge` or `replace`, and the flags that were in force. |
1338
+ | `records` | rows were actually written — on a dry run, rows the real run would write. |
1339
+ | `workspaceId`, `destination` | where it lands (`destination.kind` `workspace`, `tenant` or `ephemeral`; a tenant's carries `label`, `url` and `display`). |
1340
+ | `plan`, `conflicts`, `canonicals`, `branch` | the plan it was confirmed against, identity conflicts, the public URLs served, the branch (`null` for a tenant). |
1341
+ | `renamedTables`, `droppedColumns`, `retypedColumns`, `narrowedEnums`, `retargetedRefs`, `indexChanges`, `notNullKept`, `notNullTightened`, `storageChanges` | the table effects (see [What a landing does to tables](#what-a-landing-does-to-tables)); `tenant deploy` and `promote` carry the same keys, plus `droppedTables` (`{ table, rows }`), under `tableEffects`. |
1342
+ | `unchangedEnv`, `droppedEnv`, `unappliedDocumentation`, `workspaceRename` | the rest of what the plan cannot say. |
1343
+ | `workspaceSettingsNotApplied` | workspace settings a `--branch` landing held back (`[]` otherwise). |
1344
+
1345
+ Present only when they apply: `branchDeletion`, `unnamedPublicUrls`, `staticRemoved`, and on a
1346
+ real run `setLive` and `sharedSchemaChanges` (with `--branch`), `backupBranch`, `liveRestored`
1347
+ and `static`.
1348
+
1349
+ The comparison is deliberately one-sided: a false "changed" costs one import that was
1350
+ happening anyway, while a false "unchanged" would silently skip a real release. So anything
1351
+ it cannot prove equal counts as changed, and it is skipped entirely for `--replace` (which
1352
+ rebuilds the workspace whatever it holds) and for `--seed`/`--reset-data` (which write table
1353
+ rows, about which the comparison knows nothing). Under `--prune`, an object the workspace holds and the project
1354
+ no longer defines is work to do, so that is not a no-op either.
1355
+
1356
+ On the merge path, anything destructive is **previewed first** — the CLI fetches the plan,
1357
+ prints what would change, and asks (an ephemeral never does). `--yes` skips the prompt for CI but never skips the
1358
+ preview. Start with `xanosdk deploy ./xano/index.ts --to workspace --dry-run` to see the plan
1359
+ without committing to it. (`promote` runs no preview — the platform lands the release — which
1360
+ is the trade the release flow makes and says so before writing.)
1361
+
1362
+ > ⚠️ `--prune` removes tables this project released and no longer defines, and a removed
1363
+ > table takes its rows with it — no flag prevents that. The preview reports it explicitly;
1364
+ > read it before confirming.
1365
+
1366
+ **`--prune` deletes only what this project landed on that destination.** A lock entry is
1367
+ not enough: guids derive from names and every export writes an entry, so two projects that
1368
+ both define a `double` function would each "own" the other's. Instead, `xano.lock` keeps a
1369
+ `landed` record per destination — `<instance host>/workspace/<id>` for a workspace,
1370
+ `<instance host>/tenant/<name>` for a tenant — naming each identity (lock key, guid, and
1371
+ whether a `toolset` entry is an agent or an MCP server) this project actually wrote there. An
1372
+ ephemeral's record (`<instance host>/ephemeral/<name>`) is kept in the uncommitted
1373
+ `.xano/ephemeral.json` instead, never the lock: ephemerals are throwaway, so recording them
1374
+ there churned the committed file on every new one. `ephemeral delete`, `tenant delete`, and an
1375
+ expired or recreated ephemeral clear its record; `tenant delete` of a standard tenant removes
1376
+ its entry from `xano.lock`. A planned deletion is this project's only when the record for *that* destination names
1377
+ its key, its guid and its kind; anything else — a table built in the UI, another project's
1378
+ agent of the same name, an object this project only ever exported — is refused, and nothing
1379
+ is sent. That refusal is a conflict, not a usage mistake: it exits `2` with
1380
+ `SDK_PRUNE_OUT_OF_SCOPE`, and `--yes` cannot waive it. The plan says of each object why it
1381
+ would go — "this project no longer declares it" only when the record shows this project landed
1382
+ it there and its lock entry came from this project's own source, "adopted by `lock import`, not
1383
+ declared by this project" when the entry only ever came from `lock import` (the lock marks such
1384
+ an entry `"adopted": true` until an export of this project declares it), "never declared it"
1385
+ when neither the record nor the lock names it. Once declared, an adopted entry is marked
1386
+ `"imported": true` instead: its guid is still the live object's, so an export never offers it
1387
+ as an orphan's new name and `lock rename` refuses to replace it.
1388
+
1389
+ What writes the record, always after the write answered (never on a dry run or a decline):
1390
+
1391
+ | Command | Record for that destination |
1392
+ |---|---|
1393
+ | `deploy --to workspace\|tenant:<x>` (merge) | adds what it sent — a converged run too |
1394
+ | `deploy --to … --replace` | becomes exactly what it sent |
1395
+ | `deploy --to … --prune` | drops what it deleted |
1396
+ | `deploy` to the project's ephemeral | becomes exactly what it sent (both arms leave only what was sent) |
1397
+ | `promote <release>` | adds the release's objects — only when every identity it carries matches this lock |
1398
+ | `tenant deploy`, `deploy release:<name>` | becomes the release's objects when they match this lock; cleared when they do not (said as a clear) |
1399
+
1400
+ A release deploy onto a branch counts for the whole workspace: a new branch starts as a copy of
1401
+ live, objects keep their guids across branches, and tables are shared by all of them. Exports
1402
+ never touch the record, so it never trips `--frozen-lock` or `export --check`. It is only as
1403
+ good as it is committed: a deploy from CI records into CI's checkout, so commit the lock back
1404
+ (or prune from where the record is), and a fresh clone of a lock without one prunes nothing.
1405
+ The refusal says so, and names the destination key. The remedy is one landing without
1406
+ `--prune`; an object removed from the project before its landing was ever recorded is deleted
1407
+ by hand, or defined again, deployed, and then removed. A prune with no lock at all has no record
1408
+ either, so it is the same refusal — `SDK_PRUNE_OUT_OF_SCOPE`, exit `2`, the refusal document
1409
+ under `--json` — saying there is no lock; reaching that usually means `--no-lock` suppressed
1410
+ it, and the remedy is the same one landing without `--prune` (and without `--no-lock`), then
1411
+ committing the lock. A lock alone does not unlock a prune: `xanosdk export` writes one with no
1412
+ landing record. Deploying a pre-exported `--bundle` carries no entry file to find a lock beside,
1413
+ so name it with `--lock=<path>` — without one that is a usage error (exit `1`). None of
1414
+ that applies to an ephemeral: its record is `.xano/ephemeral.json` in the project the run is in —
1415
+ the directory you deploy from when it is a project (it has `.xano/` or its own `xano/` entry),
1416
+ otherwise the entry's (`suites/alpha/.xano/` for `xanosdk deploy suites/alpha/xano/index.ts` typed
1417
+ from a repository root holding no project) — written lock or not, so a prune there needs no lock,
1418
+ and one refused there names that file and asks for a landing of that entry, with nothing to
1419
+ commit. The credential in `.xano/auth.json` is read from the same project. Commands that default
1420
+ to the tracked ephemeral read it from there too: run them from `suites/alpha`, or from a root
1421
+ holding no project give them the entry (`--entry=suites/alpha/xano/index.ts`). From inside a
1422
+ project, an entry another project owns (one below a directory with its own `xano/index.ts`,
1423
+ `../beta/xano/index.ts` from `suites/alpha`) is refused (exit `1`) with the command to run it
1424
+ from that project; a second entry of the same project, or a file no project owns, deploys, and a
1425
+ refresh from another entry than the one that last landed asks first (`ephemeral.entry-changed`).
1426
+
1427
+ A merge that would turn an object into a different KIND under the same identity — an MCP
1428
+ server onto someone's agent of the same name, which share a guid — is refused as a conflict
1429
+ (exit 2), naming both kinds, rather than reported as an update.
1430
+
1431
+ **A dropped column is destructive, and does not need a flag to happen.** Removing a column
1432
+ from a table schema and releasing destroys the column and every value in it. The server's
1433
+ plan calls that a routine in-place update, so the release compares your schema against the
1434
+ live workspace, names each column that would be dropped, and asks before doing it — on an
1435
+ ordinary release, with no destructive flag passed. That comparison needs the target read: a
1436
+ read that loses its connection or gets a server error is tried again a few times, and if it
1437
+ still fails a real deploy is refused before anything is written (exit 8, with the command to
1438
+ run again) — there is no flag to deploy without the check. `--dry-run` still prints the plan,
1439
+ with a warning naming why the target could not be read.
1440
+
1441
+ **Environment variables are add-only on a release.** A merge creates keys that do not exist
1442
+ yet and leaves existing ones as they are, so changing a value in code and releasing will not
1443
+ change it on the workspace. The preview names any key it will decline to update. To change
1444
+ one, set it on the workspace directly, or use `--replace` (which rebuilds the workspace).
1445
+
1446
+ A merge matches objects by the stable identity your project assigns them, so it only
1447
+ recognizes a workspace it has deployed to before. A `--replace` keeps the identities the
1448
+ project sends, so a workspace it rebuilt is one of those. Deploying into one built by hand
1449
+ matches nothing: every object is a create, and with `--prune` the workspace is emptied and rebuilt rather than updated. The
1450
+ preview says so in as many words when it happens. To adopt objects that are already there,
1451
+ pin their `guid` on the matching defs first. Deploys are **authenticated over OAuth** — sign in once,
1452
+ and the CLI refreshes tokens automatically. The target instance comes from your token
1453
+ (never a stray flag), and the CLI prints what it's about to do before it touches anything.
1454
+
1455
+ **A name the workspace already uses for a different object refuses the release.** Before the
1456
+ import runs, the CLI reads the live workspace and compares it to the archive by lock key. An
1457
+ object this project would create under a name the workspace already holds with a *different*
1458
+ identity is refused: the import would not update it, it would create a **second** object beside
1459
+ it under a suffixed name and report success — with an auth table, that binds every endpoint to
1460
+ one table while tokens are minted against the other. Three ways forward, all named in the
1461
+ message: adopt the objects that are already there with `xanosdk lock import` and commit the lock,
1462
+ rename yours, or release into a workspace that does not hold them. A workspace that could not be
1463
+ read refuses too — a failed read is not evidence that there is no collision.
1464
+
1465
+ **A public URL slug is unique across the whole instance, not per workspace.** Two workspaces on
1466
+ one instance cannot serve the same slug, and no flag makes them share one. A slug this project
1467
+ does not pin is a preference, and the import settles it: an update keeps the slug the object
1468
+ already had (`kept`), and a create whose slug is taken gets a random token instead (`minted`).
1469
+ Every slug served under a value other than the one compiled is printed, lands in the JSON
1470
+ summary, and **refuses `--static`** — a frontend built against the compiled value answers 404 on
1471
+ every route derived from it. Only the frontend publish is refused; the backend release stands. A slug pinned in code is a contract rather than a preference: the
1472
+ instance serves it or refuses the release — from the object's first release on, dry run
1473
+ included, before the lock has recorded it.
1474
+
1475
+ **A conflict refuses and exits `2`, and `--yes` cannot waive it.** `--yes` waives the
1476
+ confirmation, which is a question about whether you want a plan applied; a taken identity is not
1477
+ a question. Exit `2` is distinct from an ordinary failure's `1` on purpose — a `1` is worth
1478
+ retrying, and nothing about running a conflicting release again changes the answer. Nothing is
1479
+ written, and nothing is written back to the lock. When the holder is this workspace, every
1480
+ remedy is in your own project: pin the identity that is already there so the release updates it
1481
+ (`guid: "<it>"` on the def — a def's explicit guid wins over the lock's entry, and the next build
1482
+ re-pins the lock and says so; the message offers this first, because it takes over that one
1483
+ object), rename the object, or adopt everything the workspace holds — `lock import` takes an
1484
+ exported bundle, so the message names the export that writes one (`workspace export`, or
1485
+ `ephemeral export <name> --format json` for an ephemeral) and then `lock import … --yes`: the
1486
+ import overwrites the identity the lock pins, which is the adoption you chose. It adopts **every**
1487
+ identity in that file, not only the one that conflicted: each becomes this project's, and once a
1488
+ deploy or release records it as landed, a `--prune` from this project can delete it. Run
1489
+ without `--yes`, `lock import` lists what it would overwrite and asks on a terminal; with no
1490
+ terminal it refuses and names the flag. A standard tenant cannot be
1491
+ exported, so there the remedies are the pin (the guid is in the message) or a rename. When the holder
1492
+ is **another workspace on the instance, no remedy in this project applies** — the message names
1493
+ that workspace by id and says the fix lives there. On `--to tenant:<name>` the refusal names the
1494
+ tenant or ephemeral you chose (with its display name), never its internal workspace. A refusal
1495
+ at apply time reports that the destination was not changed, and names any backup branch taken
1496
+ moments earlier as redundant rather than as a rollback target.
1497
+
1498
+ Under `--json` every refusal — a taken identity, a kind swap, a prune out of scope, or one at
1499
+ apply time — writes the failure document every command writes: `{ ok: false, error: { code,
1500
+ message, exitCode, details } }`. `error.code` names the refusal — `SDK_IDENTITY_CONFLICT`,
1501
+ `SDK_KIND_CONFLICT`, `SDK_PRUNE_OUT_OF_SCOPE`, or `SDK_IMPORT_REFUSED` for one at apply time
1502
+ (`SDK_IDENTITY_CONFLICT` when the instance's reason was a taken identity) — and `error.exitCode`
1503
+ is `2`, or `1` for an apply-time refusal that is not a conflict. `error.details` carries the
1504
+ refusal itself: `refused` (`identityConflict`, `kindConflict`, `pruneOutOfScope` or
1505
+ `importRefused`), `landed` (`false`), `conflicts`, `kindConflicts`, `outOfScope` (each object as
1506
+ `{ type, name, label, reason? }`), `canonicals` and `landingRecord` (`null`) — each always
1507
+ present on a `deploy --to` refusal, a list empty when it does not apply — plus the dry run's
1508
+ `dryRun`, `declined`, `workspaceId`, `destination`, `mode`, `prune` and `branch` once the
1509
+ destination was read, and `serverCode`, the instance's own code, on a refusal at apply time.
1510
+ A `--keep-data` merge's refusal is smaller: `refused` (`identityConflict`, `renamePending`,
1511
+ `pruneOutOfScope`, `storageModeChange`, `tableTrigger` or `uniqueViolation`), `conflicts`, `renames` and `outOfScope`, plus
1512
+ `serverCode` when the environment refused — [above](#keeping-your-data-between-deploys).
1513
+
1514
+ **CI & agents** run headless with the meta credential — `XANO_INSTANCE_URL`, `XANO_WORKSPACE_ID`
1515
+ and `XANO_META_TOKEN`, [above](#signing-in--deploying-in-depth) — no browser, nothing on disk,
1516
+ nothing that rotates:
1517
+
1518
+ ```bash
1519
+ XANO_INSTANCE_URL=… XANO_WORKSPACE_ID=… XANO_META_TOKEN=… npx xanosdk deploy ./xano/index.ts
1520
+ ```
1521
+
1522
+ > ⚠️ A deploy without `--keep-data` is a full replace of the target environment, including
1523
+ > its table records, before importing. The blast radius is your own disposable ephemeral — but anything
1524
+ > you only ever created by hand in it (or any data it accumulated) is gone. That's exactly
1525
+ > why a real workspace or tenant is reached only by naming it, with `deploy --to`.