@polardbx/pxbaas 0.0.0-stage → 0.1.0-beta.2

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 (267) hide show
  1. package/README.md +107 -2
  2. package/bin/pxbaas.js +13 -0
  3. package/dist/app/preflight.d.ts +38 -0
  4. package/dist/app/preflight.d.ts.map +1 -0
  5. package/dist/app/preflight.js +79 -0
  6. package/dist/app/preflight.js.map +1 -0
  7. package/dist/app/spec-lint.d.ts +45 -0
  8. package/dist/app/spec-lint.d.ts.map +1 -0
  9. package/dist/app/spec-lint.js +407 -0
  10. package/dist/app/spec-lint.js.map +1 -0
  11. package/dist/app/templates.d.ts +143 -0
  12. package/dist/app/templates.d.ts.map +1 -0
  13. package/dist/app/templates.js +151 -0
  14. package/dist/app/templates.js.map +1 -0
  15. package/dist/cli/root.d.ts +3 -0
  16. package/dist/cli/root.d.ts.map +1 -0
  17. package/dist/cli/root.js +69 -0
  18. package/dist/cli/root.js.map +1 -0
  19. package/dist/commands/app/index.d.ts +8 -0
  20. package/dist/commands/app/index.d.ts.map +1 -0
  21. package/dist/commands/app/index.js +1476 -0
  22. package/dist/commands/app/index.js.map +1 -0
  23. package/dist/commands/auth/index.d.ts +3 -0
  24. package/dist/commands/auth/index.d.ts.map +1 -0
  25. package/dist/commands/auth/index.js +198 -0
  26. package/dist/commands/auth/index.js.map +1 -0
  27. package/dist/commands/completion/index.d.ts +3 -0
  28. package/dist/commands/completion/index.d.ts.map +1 -0
  29. package/dist/commands/completion/index.js +10 -0
  30. package/dist/commands/completion/index.js.map +1 -0
  31. package/dist/commands/data/index.d.ts +3 -0
  32. package/dist/commands/data/index.d.ts.map +1 -0
  33. package/dist/commands/data/index.js +245 -0
  34. package/dist/commands/data/index.js.map +1 -0
  35. package/dist/commands/db/index.d.ts +15 -0
  36. package/dist/commands/db/index.d.ts.map +1 -0
  37. package/dist/commands/db/index.js +389 -0
  38. package/dist/commands/db/index.js.map +1 -0
  39. package/dist/commands/doctor/index.d.ts +3 -0
  40. package/dist/commands/doctor/index.d.ts.map +1 -0
  41. package/dist/commands/doctor/index.js +305 -0
  42. package/dist/commands/doctor/index.js.map +1 -0
  43. package/dist/commands/functions/delete.d.ts +3 -0
  44. package/dist/commands/functions/delete.d.ts.map +1 -0
  45. package/dist/commands/functions/delete.js +55 -0
  46. package/dist/commands/functions/delete.js.map +1 -0
  47. package/dist/commands/functions/deploy.d.ts +3 -0
  48. package/dist/commands/functions/deploy.d.ts.map +1 -0
  49. package/dist/commands/functions/deploy.js +118 -0
  50. package/dist/commands/functions/deploy.js.map +1 -0
  51. package/dist/commands/functions/index.d.ts +3 -0
  52. package/dist/commands/functions/index.d.ts.map +1 -0
  53. package/dist/commands/functions/index.js +15 -0
  54. package/dist/commands/functions/index.js.map +1 -0
  55. package/dist/commands/functions/list.d.ts +6 -0
  56. package/dist/commands/functions/list.d.ts.map +1 -0
  57. package/dist/commands/functions/list.js +63 -0
  58. package/dist/commands/functions/list.js.map +1 -0
  59. package/dist/commands/functions/new.d.ts +3 -0
  60. package/dist/commands/functions/new.d.ts.map +1 -0
  61. package/dist/commands/functions/new.js +57 -0
  62. package/dist/commands/functions/new.js.map +1 -0
  63. package/dist/commands/gen/index.d.ts +3 -0
  64. package/dist/commands/gen/index.d.ts.map +1 -0
  65. package/dist/commands/gen/index.js +320 -0
  66. package/dist/commands/gen/index.js.map +1 -0
  67. package/dist/commands/instance/index.d.ts +8 -0
  68. package/dist/commands/instance/index.d.ts.map +1 -0
  69. package/dist/commands/instance/index.js +361 -0
  70. package/dist/commands/instance/index.js.map +1 -0
  71. package/dist/commands/login/index.d.ts +3 -0
  72. package/dist/commands/login/index.d.ts.map +1 -0
  73. package/dist/commands/login/index.js +160 -0
  74. package/dist/commands/login/index.js.map +1 -0
  75. package/dist/commands/login/login.d.ts +3 -0
  76. package/dist/commands/login/login.d.ts.map +1 -0
  77. package/dist/commands/login/login.js +185 -0
  78. package/dist/commands/login/login.js.map +1 -0
  79. package/dist/commands/logout/index.d.ts +3 -0
  80. package/dist/commands/logout/index.d.ts.map +1 -0
  81. package/dist/commands/logout/index.js +41 -0
  82. package/dist/commands/logout/index.js.map +1 -0
  83. package/dist/commands/profile/import-env.d.ts +13 -0
  84. package/dist/commands/profile/import-env.d.ts.map +1 -0
  85. package/dist/commands/profile/import-env.js +81 -0
  86. package/dist/commands/profile/import-env.js.map +1 -0
  87. package/dist/commands/profile/index.d.ts +3 -0
  88. package/dist/commands/profile/index.d.ts.map +1 -0
  89. package/dist/commands/profile/index.js +112 -0
  90. package/dist/commands/profile/index.js.map +1 -0
  91. package/dist/commands/rest/index.d.ts +3 -0
  92. package/dist/commands/rest/index.d.ts.map +1 -0
  93. package/dist/commands/rest/index.js +102 -0
  94. package/dist/commands/rest/index.js.map +1 -0
  95. package/dist/commands/skill/index.d.ts +11 -0
  96. package/dist/commands/skill/index.d.ts.map +1 -0
  97. package/dist/commands/skill/index.js +153 -0
  98. package/dist/commands/skill/index.js.map +1 -0
  99. package/dist/commands/status/index.d.ts +3 -0
  100. package/dist/commands/status/index.d.ts.map +1 -0
  101. package/dist/commands/status/index.js +59 -0
  102. package/dist/commands/status/index.js.map +1 -0
  103. package/dist/commands/storage/index.d.ts +3 -0
  104. package/dist/commands/storage/index.d.ts.map +1 -0
  105. package/dist/commands/storage/index.js +198 -0
  106. package/dist/commands/storage/index.js.map +1 -0
  107. package/dist/commands/whoami/index.d.ts +3 -0
  108. package/dist/commands/whoami/index.d.ts.map +1 -0
  109. package/dist/commands/whoami/index.js +82 -0
  110. package/dist/commands/whoami/index.js.map +1 -0
  111. package/dist/credentials/commit.d.ts +38 -0
  112. package/dist/credentials/commit.d.ts.map +1 -0
  113. package/dist/credentials/commit.js +57 -0
  114. package/dist/credentials/commit.js.map +1 -0
  115. package/dist/credentials/credential-store.d.ts +57 -0
  116. package/dist/credentials/credential-store.d.ts.map +1 -0
  117. package/dist/credentials/credential-store.js +180 -0
  118. package/dist/credentials/credential-store.js.map +1 -0
  119. package/dist/credentials/env-file.d.ts +44 -0
  120. package/dist/credentials/env-file.d.ts.map +1 -0
  121. package/dist/credentials/env-file.js +118 -0
  122. package/dist/credentials/env-file.js.map +1 -0
  123. package/dist/credentials/formatter.d.ts +39 -0
  124. package/dist/credentials/formatter.d.ts.map +1 -0
  125. package/dist/credentials/formatter.js +79 -0
  126. package/dist/credentials/formatter.js.map +1 -0
  127. package/dist/credentials/index.d.ts +13 -0
  128. package/dist/credentials/index.d.ts.map +1 -0
  129. package/dist/credentials/index.js +13 -0
  130. package/dist/credentials/index.js.map +1 -0
  131. package/dist/credentials/jwt-introspect.d.ts +32 -0
  132. package/dist/credentials/jwt-introspect.d.ts.map +1 -0
  133. package/dist/credentials/jwt-introspect.js +64 -0
  134. package/dist/credentials/jwt-introspect.js.map +1 -0
  135. package/dist/credentials/masker.d.ts +20 -0
  136. package/dist/credentials/masker.d.ts.map +1 -0
  137. package/dist/credentials/masker.js +37 -0
  138. package/dist/credentials/masker.js.map +1 -0
  139. package/dist/credentials/profile.d.ts +83 -0
  140. package/dist/credentials/profile.d.ts.map +1 -0
  141. package/dist/credentials/profile.js +184 -0
  142. package/dist/credentials/profile.js.map +1 -0
  143. package/dist/credentials/tenant-guard.d.ts +16 -0
  144. package/dist/credentials/tenant-guard.d.ts.map +1 -0
  145. package/dist/credentials/tenant-guard.js +53 -0
  146. package/dist/credentials/tenant-guard.js.map +1 -0
  147. package/dist/credentials/tty-guard.d.ts +19 -0
  148. package/dist/credentials/tty-guard.d.ts.map +1 -0
  149. package/dist/credentials/tty-guard.js +47 -0
  150. package/dist/credentials/tty-guard.js.map +1 -0
  151. package/dist/db/apply-schema.d.ts +144 -0
  152. package/dist/db/apply-schema.d.ts.map +1 -0
  153. package/dist/db/apply-schema.js +359 -0
  154. package/dist/db/apply-schema.js.map +1 -0
  155. package/dist/db/sql-file.d.ts +104 -0
  156. package/dist/db/sql-file.d.ts.map +1 -0
  157. package/dist/db/sql-file.js +584 -0
  158. package/dist/db/sql-file.js.map +1 -0
  159. package/dist/instance/provision.d.ts +89 -0
  160. package/dist/instance/provision.d.ts.map +1 -0
  161. package/dist/instance/provision.js +139 -0
  162. package/dist/instance/provision.js.map +1 -0
  163. package/dist/instance/zero-store.d.ts +47 -0
  164. package/dist/instance/zero-store.d.ts.map +1 -0
  165. package/dist/instance/zero-store.js +91 -0
  166. package/dist/instance/zero-store.js.map +1 -0
  167. package/dist/main.d.ts +3 -0
  168. package/dist/main.d.ts.map +1 -0
  169. package/dist/main.js +46 -0
  170. package/dist/main.js.map +1 -0
  171. package/dist/skill/assets.d.ts +38 -0
  172. package/dist/skill/assets.d.ts.map +1 -0
  173. package/dist/skill/assets.js +111 -0
  174. package/dist/skill/assets.js.map +1 -0
  175. package/dist/skill/hint.d.ts +39 -0
  176. package/dist/skill/hint.d.ts.map +1 -0
  177. package/dist/skill/hint.js +109 -0
  178. package/dist/skill/hint.js.map +1 -0
  179. package/dist/skill/hosts.d.ts +21 -0
  180. package/dist/skill/hosts.d.ts.map +1 -0
  181. package/dist/skill/hosts.js +38 -0
  182. package/dist/skill/hosts.js.map +1 -0
  183. package/dist/skill/installer.d.ts +88 -0
  184. package/dist/skill/installer.d.ts.map +1 -0
  185. package/dist/skill/installer.js +203 -0
  186. package/dist/skill/installer.js.map +1 -0
  187. package/dist/skill/ownership.d.ts +35 -0
  188. package/dist/skill/ownership.d.ts.map +1 -0
  189. package/dist/skill/ownership.js +52 -0
  190. package/dist/skill/ownership.js.map +1 -0
  191. package/package.json +63 -3
  192. package/skills/pxbaas-app-spec/SKILL.md +163 -0
  193. package/skills/pxbaas-app-spec/references/deploy-options.md +157 -0
  194. package/skills/pxbaas-app-spec/references/guardrails-pitfalls.md +89 -0
  195. package/skills/pxbaas-app-spec/references/runtime-contract.md +151 -0
  196. package/skills/pxbaas-app-spec/references/ssr-session.md +121 -0
  197. package/skills/pxbaas-app-spec/references/templates-examples.md +311 -0
  198. package/skills/pxbaas-cli/SKILL.md +171 -0
  199. package/skills/pxbaas-cli/references/api-reference.md +68 -0
  200. package/skills/pxbaas-cli/references/app-deploy.md +170 -0
  201. package/skills/pxbaas-cli/references/auth-and-credentials.md +100 -0
  202. package/skills/pxbaas-cli/references/data-postgrest.md +85 -0
  203. package/skills/pxbaas-cli/references/db-and-meta.md +97 -0
  204. package/skills/pxbaas-cli/references/edge-functions.md +71 -0
  205. package/skills/pxbaas-cli/references/multi-tenant-apps.md +138 -0
  206. package/skills/pxbaas-cli/references/provision.md +86 -0
  207. package/skills/pxbaas-cli/references/safety-and-two-phase.md +49 -0
  208. package/skills/pxbaas-cli/references/storage.md +61 -0
  209. package/skills/pxbaas-cli/references/troubleshooting.md +73 -0
  210. package/templates/webapp/api/index.ts +55 -0
  211. package/templates/webapp/csr-bff/README.md +81 -0
  212. package/templates/webapp/csr-bff/backend/index.ts +168 -0
  213. package/templates/webapp/csr-bff/db/rls.json +13 -0
  214. package/templates/webapp/csr-bff/db/schema.sql +49 -0
  215. package/templates/webapp/csr-bff/frontend/dist/admin.html +101 -0
  216. package/templates/webapp/csr-bff/frontend/dist/article.html +44 -0
  217. package/templates/webapp/csr-bff/frontend/dist/blog.js +206 -0
  218. package/templates/webapp/csr-bff/frontend/dist/blogger.html +53 -0
  219. package/templates/webapp/csr-bff/frontend/dist/index.html +70 -0
  220. package/templates/webapp/csr-bff/frontend/dist/login.html +56 -0
  221. package/templates/webapp/csr-bff/frontend/dist/profile.html +59 -0
  222. package/templates/webapp/csr-bff/frontend/dist/signup.html +78 -0
  223. package/templates/webapp/csr-bff/frontend/dist/style.css +27 -0
  224. package/templates/webapp/manifest.json +299 -0
  225. package/templates/webapp/mpa/about.html +29 -0
  226. package/templates/webapp/mpa/app.js +42 -0
  227. package/templates/webapp/mpa/index.html +42 -0
  228. package/templates/webapp/mpa/index.ts +60 -0
  229. package/templates/webapp/mpa/style.css +42 -0
  230. package/templates/webapp/react-vite/frontend/index.html +12 -0
  231. package/templates/webapp/react-vite/frontend/package-lock.json +1826 -0
  232. package/templates/webapp/react-vite/frontend/package.json +23 -0
  233. package/templates/webapp/react-vite/frontend/src/App.tsx +34 -0
  234. package/templates/webapp/react-vite/frontend/src/main.tsx +13 -0
  235. package/templates/webapp/react-vite/frontend/src/style.css +46 -0
  236. package/templates/webapp/react-vite/frontend/tsconfig.json +20 -0
  237. package/templates/webapp/react-vite/frontend/vite.config.ts +10 -0
  238. package/templates/webapp/react-vite-hono/backend/index.ts +21 -0
  239. package/templates/webapp/react-vite-hono/frontend/index.html +12 -0
  240. package/templates/webapp/react-vite-hono/frontend/package-lock.json +1826 -0
  241. package/templates/webapp/react-vite-hono/frontend/package.json +23 -0
  242. package/templates/webapp/react-vite-hono/frontend/src/App.tsx +47 -0
  243. package/templates/webapp/react-vite-hono/frontend/src/main.tsx +13 -0
  244. package/templates/webapp/react-vite-hono/frontend/src/style.css +66 -0
  245. package/templates/webapp/react-vite-hono/frontend/tsconfig.json +20 -0
  246. package/templates/webapp/react-vite-hono/frontend/vite.config.ts +10 -0
  247. package/templates/webapp/spa/index.ts +119 -0
  248. package/templates/webapp/ssr-auth/backend/index.ts +133 -0
  249. package/templates/webapp/ssr-auth/frontend/dist/index.html +15 -0
  250. package/templates/webapp/ssr-blog/README.md +111 -0
  251. package/templates/webapp/ssr-blog/backend/index.ts +764 -0
  252. package/templates/webapp/ssr-blog/db/rls.json +13 -0
  253. package/templates/webapp/ssr-blog/db/schema.sql +51 -0
  254. package/templates/webapp/ssr-blog/frontend/dist/index.html +39 -0
  255. package/templates/webapp/ssr-frameworks/README.md +64 -0
  256. package/templates/webapp/ssr-frameworks/backend/index.ts +241 -0
  257. package/templates/webapp/ssr-frameworks/frontend/dist/index.html +31 -0
  258. package/templates/webapp/ssr-hono/backend/index.ts +92 -0
  259. package/templates/webapp/ssr-hono/frontend/dist/index.html +12 -0
  260. package/templates/webapp/ssr-shop/README.md +80 -0
  261. package/templates/webapp/ssr-shop/backend/index.ts +491 -0
  262. package/templates/webapp/ssr-shop/db/rls.json +16 -0
  263. package/templates/webapp/ssr-shop/db/schema.sql +90 -0
  264. package/templates/webapp/ssr-shop/frontend/dist/index.html +12 -0
  265. package/templates/webapp/vanilla-static/frontend/app.js +6 -0
  266. package/templates/webapp/vanilla-static/frontend/index.html +19 -0
  267. package/templates/webapp/vanilla-static/frontend/style.css +43 -0
@@ -0,0 +1,1476 @@
1
+ import axios from "axios";
2
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { AppsApiError, AppsDriver, FunctionsDriver, ShareLinksDriver, } from "@polardbx/pxbaas-client";
6
+ import { ProfileManager } from "../../credentials/profile.js";
7
+ import { CredentialStore } from "../../credentials/credential-store.js";
8
+ import { formatError, formatSuccess, formatWarning, } from "../../credentials/index.js";
9
+ import { HONO_VERSION_PLACEHOLDER, scaffoldApp, listTemplates, listTemplateIdsSync, } from "../../app/templates.js";
10
+ import { runPreflight, isPublicEndpoint } from "../../app/preflight.js";
11
+ import { lintAppSource } from "../../app/spec-lint.js";
12
+ import { applySchema, getSchemaSink, } from "../../db/apply-schema.js";
13
+ import { parseRlsJson, extractCreateTableNames } from "../../db/sql-file.js";
14
+ const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
15
+ /**
16
+ * `pxbaas app` — scaffold, deploy, publish, verify, and share legacy Edge Function
17
+ * demos or schemaVersion 2 Webapps. Webapp deploys add capability gating, artifact
18
+ * linting, immutable candidates, and generation-CAS lifecycle operations.
19
+ */
20
+ export function registerAppCommands(program) {
21
+ const appCmd = program
22
+ .command("app")
23
+ .description("Scaffold, deploy, publish, and manage BaaS apps");
24
+ // Build the --template help from the bundled manifest so it never drifts from
25
+ // the real template set (D6). Falls back to a hint when assets are unavailable.
26
+ const templateIds = listTemplateIdsSync();
27
+ const templateHelp = templateIds.length
28
+ ? `Template id (${templateIds.join("|")})`
29
+ : "Template id (run `pxbaas app templates` for the full list)";
30
+ appCmd
31
+ .command("new <name>")
32
+ .description("Scaffold a legacy Edge Function app or schemaVersion 2 Webapp")
33
+ .option("--template <id>", templateHelp, "spa")
34
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
35
+ .option("--slug <slug>", "Function slug / URL path segment (default: <name>)")
36
+ .option("--force", "Overwrite an existing scaffold")
37
+ .option("--json", "Output in JSON format")
38
+ .action(async (name, options) => {
39
+ try {
40
+ await handleNew(name, options, program);
41
+ }
42
+ catch (e) {
43
+ fail(e, options.json);
44
+ }
45
+ });
46
+ appCmd
47
+ .command("deploy <name>")
48
+ .description("Deploy a legacy app or upload and publish a schemaVersion 2 Webapp")
49
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
50
+ .option("--slug <slug>", "Override function slug")
51
+ .option("--entrypoint <path>", "Override entrypoint file")
52
+ .option("--frontend <directory>", "Prebuilt frontend artifact directory for mode=webapp")
53
+ .option("--backend <directory>", "Backend source directory for mode=webapp")
54
+ .option("--backend-framework <framework>", "Backend framework (deno|hono)")
55
+ .option("--verify-jwt", "Require JWT (safer, but the URL is no longer publicly openable)")
56
+ .option("--dry-run", "Run preflight + spec review and show the plan without deploying")
57
+ .option("--skip-spec-check", "Skip the pxbaas-app-spec compliance lint (not recommended)")
58
+ .option("--json", "Output in JSON format")
59
+ .action(async (name, options) => {
60
+ try {
61
+ await handleDeploy(name, options, program);
62
+ }
63
+ catch (e) {
64
+ fail(e, options.json);
65
+ }
66
+ });
67
+ appCmd
68
+ .command("capabilities")
69
+ .description("Show App delivery capabilities reported by the active instance")
70
+ .option("--json", "Output in JSON format")
71
+ .action(async (options) => {
72
+ try {
73
+ await handleCapabilities(options, program);
74
+ }
75
+ catch (e) {
76
+ fail(e, isJson(options, program));
77
+ }
78
+ });
79
+ for (const action of ["publish", "rollback", "unpublish"]) {
80
+ appCmd
81
+ .command(`${action} <name>`)
82
+ .description(`${action[0].toUpperCase()}${action.slice(1)} a mode=webapp App mount`)
83
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
84
+ .option("--json", "Output in JSON format")
85
+ .action(async (name, options) => {
86
+ try {
87
+ await handleLifecycle(action, name, options, program);
88
+ }
89
+ catch (e) {
90
+ fail(e, isJson(options, program));
91
+ }
92
+ });
93
+ }
94
+ appCmd
95
+ .command("verify <name>")
96
+ .description("Verify the deployed webapp (cold-start retry + content-type + API ping)")
97
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
98
+ .option("--slug <slug>", "Override function slug")
99
+ .option("--timeout <seconds>", "Max seconds to wait through cold start", "60")
100
+ .option("--json", "Output in JSON format")
101
+ .action(async (name, options) => {
102
+ try {
103
+ await handleVerify(name, options, program);
104
+ }
105
+ catch (e) {
106
+ fail(e, options.json);
107
+ }
108
+ });
109
+ appCmd
110
+ .command("url <name>")
111
+ .description("Print the shareable URL for a deployed webapp")
112
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
113
+ .option("--slug <slug>", "Override function slug")
114
+ .option("--json", "Output in JSON format")
115
+ .action(async (name, options) => {
116
+ try {
117
+ await handleUrl(name, options, program);
118
+ }
119
+ catch (e) {
120
+ fail(e, options.json);
121
+ }
122
+ });
123
+ appCmd
124
+ .command("list")
125
+ .description("List deployed edge functions (apps)")
126
+ .option("--json", "Output in JSON format")
127
+ .action(async (options) => {
128
+ try {
129
+ await handleList(options, program);
130
+ }
131
+ catch (e) {
132
+ fail(e, options.json);
133
+ }
134
+ });
135
+ appCmd
136
+ .command("remove <name>")
137
+ .description("Remove a deployed webapp function (destructive; needs --confirm)")
138
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
139
+ .option("--slug <slug>", "Override function slug")
140
+ .option("--confirm", "Actually delete (required)")
141
+ .option("--json", "Output in JSON format")
142
+ .action(async (name, options) => {
143
+ try {
144
+ await handleRemove(name, options, program);
145
+ }
146
+ catch (e) {
147
+ fail(e, options.json);
148
+ }
149
+ });
150
+ appCmd
151
+ .command("destroy <name>")
152
+ .description("Permanently destroy a mode=webapp App: delete its edge functions + mount record (needs --confirm). By default DB tables are kept; pass --purge-db to also drop the app's tables + RLS.")
153
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
154
+ .option("--confirm", "Actually destroy (required)")
155
+ .option("--purge-db", "Also drop the app's DB tables and remove its RLS registry rows (destructive)")
156
+ .option("--schema <schema>", "Single-tenant schema the app's tables live in (used with --purge-db)", "public")
157
+ .option("--json", "Output in JSON format")
158
+ .action(async (name, options) => {
159
+ try {
160
+ await handleDestroy(name, options, program);
161
+ }
162
+ catch (e) {
163
+ fail(e, isJson(options, program));
164
+ }
165
+ });
166
+ appCmd
167
+ .command("templates")
168
+ .description("List available webapp templates")
169
+ .option("--json", "Output in JSON format")
170
+ .action(async (options) => {
171
+ try {
172
+ const templates = await listTemplates();
173
+ if (isJson(options, program))
174
+ console.log(JSON.stringify({ templates }, null, 2));
175
+ else
176
+ for (const t of templates)
177
+ console.log(`${t.id}\t${t.description}`);
178
+ }
179
+ catch (e) {
180
+ fail(e, options.json);
181
+ }
182
+ });
183
+ appCmd
184
+ .command("db-init <name>")
185
+ .description("Apply the app's db/schema.sql (+ optional db/rls.json) to the active database (tables + RLS + seed) — single-tenant, or your tenant db in multi-tenant. Shows the plan and requires authorization.")
186
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
187
+ .option("--schema <schema>", "Target schema for the __SCHEMA__ placeholder (single-tenant only)", "public")
188
+ .option("--dry-run", "Show the statements without executing them")
189
+ .option("--yes", "Authorize non-interactively (skip the confirm prompt)")
190
+ .option("--json", "Output in JSON format")
191
+ .action(async (name, options) => {
192
+ try {
193
+ await handleDbInit(name, options, program);
194
+ }
195
+ catch (e) {
196
+ fail(e, isJson(options, program));
197
+ }
198
+ });
199
+ registerShareCommands(appCmd, program);
200
+ }
201
+ function isJson(options, program) {
202
+ return Boolean(options.json || program.opts().json);
203
+ }
204
+ function fail(error, json) {
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ const code = error instanceof AppsApiError ? error.code : "CLI_ERROR";
207
+ if (json)
208
+ console.log(JSON.stringify({ code, message }, null, 2));
209
+ else
210
+ console.error(formatError(`${code}: ${message}`));
211
+ process.exit(1);
212
+ }
213
+ function validateSlug(slug) {
214
+ if (!SLUG_RE.test(slug) || slug.length > 128) {
215
+ throw new Error(`Invalid slug '${slug}'. Must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ and be <=128 chars.`);
216
+ }
217
+ }
218
+ async function readManifest(dir, name) {
219
+ const p = join(dir, name, "app.json");
220
+ if (!existsSync(p))
221
+ return {};
222
+ try {
223
+ return JSON.parse(await readFile(p, "utf8"));
224
+ }
225
+ catch {
226
+ return {};
227
+ }
228
+ }
229
+ async function collectFiles(appDir, baseDir = appDir) {
230
+ const out = [];
231
+ const entries = await readdir(appDir, { withFileTypes: true });
232
+ for (const e of entries) {
233
+ // NOTE: app.json IS collected on purpose — it carries the deploy `mode`
234
+ // (spa|api|mpa) that the proxy static branch reads back from
235
+ // edge_function_files to decide whether to serve real static assets.
236
+ if (e.name.startsWith(".") || e.name === "node_modules")
237
+ continue;
238
+ const full = join(appDir, e.name);
239
+ if (e.isDirectory()) {
240
+ // Recurse so nested assets (assets/style.css, pages/about.html) keep their
241
+ // directory structure; relativePath is posix-normalized for the multipart filename.
242
+ out.push(...(await collectFiles(full, baseDir)));
243
+ }
244
+ else if (e.isFile()) {
245
+ out.push({
246
+ path: full,
247
+ relativePath: relative(baseDir, full).split("\\").join("/"),
248
+ });
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+ async function getDriver() {
254
+ const profileManager = new ProfileManager();
255
+ const currentProfile = await profileManager.getCurrentProfile();
256
+ if (!currentProfile) {
257
+ throw new Error("No active profile. Run `pxbaas provision` (free Zero instance) or `pxbaas login` first.");
258
+ }
259
+ const credentials = await new CredentialStore().getCredentials(currentProfile.name);
260
+ if (!credentials) {
261
+ throw new Error(`No credentials for profile '${currentProfile.name}'. Run \`pxbaas provision\` or \`pxbaas login\`.`);
262
+ }
263
+ const driver = new FunctionsDriver({
264
+ endpoint: currentProfile.endpoint,
265
+ serviceRoleKey: credentials.serviceRoleKey,
266
+ tenantId: currentProfile.tenantId,
267
+ });
268
+ const shareDriver = new ShareLinksDriver({
269
+ endpoint: currentProfile.endpoint,
270
+ serviceRoleKey: credentials.serviceRoleKey,
271
+ });
272
+ const appsDriver = new AppsDriver({
273
+ endpoint: currentProfile.endpoint,
274
+ serviceRoleKey: credentials.serviceRoleKey,
275
+ });
276
+ return {
277
+ driver,
278
+ shareDriver,
279
+ appsDriver,
280
+ endpoint: currentProfile.endpoint,
281
+ tenantId: currentProfile.tenantId,
282
+ anonKey: credentials.anonKey,
283
+ };
284
+ }
285
+ /**
286
+ * `pxbaas app db-init <name>` — apply the app's bundled db/schema.sql (plus an
287
+ * optional declarative db/rls.json) so a freshly published app has its tables +
288
+ * RLS + seed. Mode-aware: single-tenant runs through /pg/query (global admin);
289
+ * multi-tenant runs through /api/tenant-ddl with the per-tenant service_role key
290
+ * (DDL on the tenant's own db, RLS via the structured central-registry endpoints).
291
+ * Delegates to the shared apply engine (show plan -> authorize -> run -> stop on
292
+ * first error, no rollback).
293
+ */
294
+ async function handleDbInit(name, options, program) {
295
+ const appDir = join(options.dir, name);
296
+ const schemaFile = join(appDir, "db", "schema.sql");
297
+ if (!existsSync(schemaFile)) {
298
+ throw new AppsApiError("APP_DB_SCHEMA_NOT_FOUND", `No db/schema.sql found for app '${name}' at ${schemaFile}. ` +
299
+ "This app may not need a database, or --dir is wrong.");
300
+ }
301
+ const raw = await readFile(schemaFile, "utf8");
302
+ const json = isJson(options, program);
303
+ // Preferred declarative RLS source (db/rls.json); absent -> the engine falls
304
+ // back to parsing RLS INSERTs embedded in schema.sql (multi-tenant) or runs
305
+ // them verbatim (single-tenant).
306
+ const rlsFile = join(appDir, "db", "rls.json");
307
+ let rls = null;
308
+ if (existsSync(rlsFile)) {
309
+ rls = parseRlsJson(await readFile(rlsFile, "utf8"));
310
+ }
311
+ // Mode-aware sink: single-tenant /pg/query, or multi-tenant /api/tenant-ddl.
312
+ const sink = await getSchemaSink("service_role");
313
+ const result = await applySchema({
314
+ rawSql: raw,
315
+ source: schemaFile,
316
+ rls,
317
+ schema: options.schema,
318
+ dryRun: Boolean(options.dryRun),
319
+ assumeYes: Boolean(options.yes),
320
+ json,
321
+ }, sink);
322
+ if (json) {
323
+ console.log(JSON.stringify({ app: name, ...result }, null, 2));
324
+ }
325
+ if (result.failed > 0)
326
+ process.exit(1);
327
+ }
328
+ function shareUrl(endpoint, slug) {
329
+ return `${endpoint.replace(/\/$/, "")}/functions/v1/${slug}`;
330
+ }
331
+ async function handleNew(name, options, program) {
332
+ const slug = options.slug ?? name;
333
+ validateSlug(slug);
334
+ const result = await scaffoldApp({
335
+ name,
336
+ slug,
337
+ template: options.template,
338
+ dir: options.dir,
339
+ force: options.force,
340
+ });
341
+ if (isJson(options, program)) {
342
+ console.log(JSON.stringify({
343
+ status: "scaffolded",
344
+ app: name,
345
+ slug,
346
+ appDir: result.appDir,
347
+ files: result.files,
348
+ template: options.template,
349
+ spec: "pxbaas-app-spec",
350
+ nextSteps: result.nextSteps,
351
+ note: "Follow the pxbaas-app-spec skill; `pxbaas app deploy` lints the code against it automatically.",
352
+ }, null, 2));
353
+ return;
354
+ }
355
+ console.log(formatSuccess(`Scaffolded webapp '${name}' (template: ${options.template}) at ${result.appDir}`));
356
+ console.log(` Files: ${result.files.join(", ")}`);
357
+ console.log(` Spec: follow the 'pxbaas-app-spec' skill — \`pxbaas app deploy\` lints against it automatically.`);
358
+ console.log(` Next: edit ${join(result.appDir, result.manifest.entrypoint)}, then \`pxbaas app deploy ${name}\`.`);
359
+ for (const step of result.nextSteps) {
360
+ console.log(` TODO: ${step}`);
361
+ }
362
+ }
363
+ async function handleCapabilities(options, program) {
364
+ const { appsDriver } = await getDriver();
365
+ const capabilities = await appsDriver.capabilities();
366
+ const exposure = appExposure(capabilities);
367
+ const output = { status: "ok", exposure, capabilities };
368
+ if (isJson(options, program)) {
369
+ console.log(JSON.stringify(output, null, 2));
370
+ return;
371
+ }
372
+ console.log(`App delivery: ${capabilities.schemaVersions.includes(2) ? "available" : "unavailable"}`);
373
+ console.log(` Exposure: ${exposure}`);
374
+ console.log(` Endpoint: ${capabilities.appEndpoint ?? "-"}`);
375
+ console.log(` Static: ${capabilities.publicStatic ? "enabled" : "disabled"}`);
376
+ console.log(` Deno: ${capabilities.nativeDeno ? "enabled" : "disabled"}`);
377
+ console.log(` Hono: ${capabilities.honoVersion ?? "disabled"}`);
378
+ console.log(` SSR libs: ${(capabilities.ssrLibs ?? []).join(", ") || "none"}`);
379
+ console.log(` Session: ${capabilities.sessionMode && capabilities.sessionMode !== "none" ? capabilities.sessionMode : "disabled"}`);
380
+ }
381
+ async function handleLifecycle(action, name, options, program) {
382
+ const manifest = await requireWebappManifest(options.dir, name);
383
+ const deployment = manifest.deployment;
384
+ if (!deployment)
385
+ throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
386
+ const { appsDriver } = await getDriver();
387
+ const capabilities = await appsDriver.capabilities();
388
+ assertWebappCapabilities(capabilities, manifest.backend ?? null);
389
+ let mount;
390
+ if (action === "publish") {
391
+ if (!deployment.candidateFunctionSlug) {
392
+ throw new AppsApiError("APP_CANDIDATE_NOT_FOUND", `App '${name}' has no candidate to publish`);
393
+ }
394
+ mount = await appsDriver.publish(deployment.mountId, {
395
+ candidateFunctionSlug: deployment.candidateFunctionSlug,
396
+ expectedGeneration: deployment.generation,
397
+ });
398
+ }
399
+ else {
400
+ mount = await appsDriver[action](deployment.mountId, {
401
+ expectedGeneration: deployment.generation,
402
+ });
403
+ }
404
+ manifest.deployment = {
405
+ mountId: mount.mountId,
406
+ generation: mount.generation,
407
+ ...(action === "publish"
408
+ ? {}
409
+ : { candidateFunctionSlug: deployment.candidateFunctionSlug }),
410
+ };
411
+ await writeManifest(options.dir, name, manifest);
412
+ emitWebappResult(action === "publish"
413
+ ? "published"
414
+ : action === "rollback"
415
+ ? "rolled-back"
416
+ : "unpublished", name, mount, capabilities, options, program);
417
+ }
418
+ async function handleDeploy(name, options, program) {
419
+ const json = isJson(options, program);
420
+ const manifest = await readManifest(options.dir, name);
421
+ if (manifest.mode === "webapp") {
422
+ await handleWebappDeploy(name, manifest, options, program);
423
+ return;
424
+ }
425
+ const slug = options.slug ?? manifest.slug ?? name;
426
+ validateSlug(slug);
427
+ const entrypoint = options.entrypoint ?? manifest.entrypoint ?? "index.ts";
428
+ const verifyJwt = options.verifyJwt ?? manifest.verifyJwt ?? false;
429
+ const appDir = join(options.dir, name);
430
+ const entryPath = join(appDir, entrypoint);
431
+ const entrypointExists = existsSync(entryPath);
432
+ const source = entrypointExists ? await readFile(entryPath, "utf8") : "";
433
+ // Deploy mode (spa|api|mpa). The proxy enables the static-asset branch only
434
+ // for mpa; app.json is uploaded so the proxy can read this back.
435
+ const mode = manifest.mode ?? manifest.template ?? "spa";
436
+ const files = await collectFiles(appDir);
437
+ const existingFiles = files.map((f) => f.relativePath);
438
+ // For mpa, the pages/assets are separate files — scan them too for root-absolute
439
+ // refs (the entrypoint alone no longer contains the markup).
440
+ const auxiliarySources = [];
441
+ if (mode === "mpa") {
442
+ for (const f of files) {
443
+ const rel = f.relativePath;
444
+ if (rel === entrypoint || rel === "app.json")
445
+ continue;
446
+ if (/\.(html?|css|js|mjs)$/i.test(rel))
447
+ auxiliarySources.push(await readFile(f.path, "utf8"));
448
+ }
449
+ }
450
+ const declaredFiles = mode === "mpa"
451
+ ? [...(manifest.pages ?? []), ...(manifest.assets ?? [])]
452
+ : [];
453
+ const { driver, endpoint } = await getDriver();
454
+ let functionsCount = 0;
455
+ try {
456
+ functionsCount = (await driver.list()).length;
457
+ }
458
+ catch {
459
+ functionsCount = 0; // preflight should not hard-fail if list is unavailable
460
+ }
461
+ const pre = runPreflight({
462
+ source,
463
+ endpoint,
464
+ functionsCount,
465
+ verifyJwt,
466
+ entrypointExists,
467
+ });
468
+ // Automatic pxbaas-app-spec compliance review (skip with --skip-spec-check).
469
+ const spec = options.skipSpecCheck
470
+ ? { skipped: true, passed: true, blockers: [], warnings: [] }
471
+ : {
472
+ skipped: false,
473
+ ...lintAppSource(source, {
474
+ verifyJwt,
475
+ mode,
476
+ declaredFiles,
477
+ existingFiles,
478
+ auxiliarySources,
479
+ }),
480
+ };
481
+ const fmt = (f) => `[${f.rule}] ${f.message} (see ${f.specRef})`;
482
+ const allBlockers = [...pre.blockers, ...spec.blockers.map(fmt)];
483
+ const allWarnings = [...pre.warnings, ...spec.warnings.map(fmt)];
484
+ if (options.dryRun) {
485
+ const out = {
486
+ status: "dry-run",
487
+ app: name,
488
+ slug,
489
+ mode,
490
+ verifyJwt,
491
+ shareUrl: shareUrl(endpoint, slug),
492
+ preflight: pre,
493
+ specLint: spec,
494
+ };
495
+ if (json)
496
+ console.log(JSON.stringify(out, null, 2));
497
+ else {
498
+ console.log(formatWarning(`Dry run for '${name}' -> ${shareUrl(endpoint, slug)}`));
499
+ for (const b of allBlockers)
500
+ console.error(formatError(b));
501
+ for (const w of allWarnings)
502
+ console.log(formatWarning(w));
503
+ if (allBlockers.length === 0)
504
+ console.log(" pxbaas-app-spec review + preflight passed. Re-run without --dry-run to deploy.");
505
+ }
506
+ if (allBlockers.length > 0)
507
+ process.exit(1);
508
+ return;
509
+ }
510
+ if (allBlockers.length > 0) {
511
+ if (json) {
512
+ console.log(JSON.stringify({
513
+ status: "blocked",
514
+ app: name,
515
+ slug,
516
+ preflight: pre,
517
+ specLint: spec,
518
+ }, null, 2));
519
+ }
520
+ else {
521
+ console.error(formatError("pxbaas-app-spec compliance review failed — fix these before deploying:"));
522
+ for (const b of allBlockers)
523
+ console.error(formatError(b));
524
+ }
525
+ process.exit(1);
526
+ }
527
+ const info = await driver.deploy({
528
+ slug,
529
+ entrypointPath: entrypoint,
530
+ verifyJwt,
531
+ files,
532
+ });
533
+ const url = shareUrl(endpoint, slug);
534
+ // deploy is an upsert: version 1 means this call created the function; >1 means it updated an existing one.
535
+ const action = (info?.version ?? 1) === 1 ? "created" : "updated";
536
+ const out = {
537
+ status: "deployed",
538
+ action,
539
+ app: name,
540
+ slug,
541
+ mode,
542
+ verifyJwt,
543
+ shareUrl: url,
544
+ function: info,
545
+ preflight: pre,
546
+ specLint: spec,
547
+ };
548
+ if (json) {
549
+ console.log(JSON.stringify(out, null, 2));
550
+ return;
551
+ }
552
+ console.log(formatSuccess(`${action === "created" ? "Created" : "Updated"} webapp '${name}' (slug: ${slug}, mode: ${mode}, verify_jwt: ${verifyJwt}, version: ${info?.version ?? 1}) — passed pxbaas-app-spec review`));
553
+ for (const w of allWarnings)
554
+ console.log(formatWarning(w));
555
+ console.log(` Share URL: ${url}`);
556
+ if (mode === "mpa") {
557
+ for (const p of manifest.pages ?? [])
558
+ console.log(` Page: ${url.replace(/\/$/, "")}/${p}`);
559
+ }
560
+ console.log(` Verify: pxbaas app verify ${name}`);
561
+ }
562
+ async function handleWebappDeploy(name, manifestInput, options, program) {
563
+ const manifest = requireWebappManifestValue(manifestInput, name);
564
+ const frontend = manifest.frontend;
565
+ if (!frontend)
566
+ throw new AppsApiError("APP_MANIFEST_INVALID", "mode=webapp requires a frontend contract");
567
+ const framework = parseBackendFramework(options.backendFramework ?? manifest.backend?.framework);
568
+ let backend = manifest.backend
569
+ ? { ...manifest.backend }
570
+ : null;
571
+ if ((options.backend || options.backendFramework) && !backend) {
572
+ backend = {
573
+ entrypoint: "backend/index.ts",
574
+ framework: framework ?? "deno",
575
+ pathMode: "strip-app-api-prefix",
576
+ verifyJwt: options.verifyJwt ?? false,
577
+ secrets: [],
578
+ egressPolicy: "deny",
579
+ };
580
+ }
581
+ else if (backend) {
582
+ backend.framework = framework ?? backend.framework;
583
+ backend.verifyJwt = options.verifyJwt ?? backend.verifyJwt;
584
+ }
585
+ const { driver, appsDriver } = await getDriver();
586
+ const capabilities = await appsDriver.capabilities();
587
+ assertWebappCapabilities(capabilities, backend);
588
+ const appDir = join(options.dir, name);
589
+ const defaultFrontendDir = manifest.template === "vanilla-static"
590
+ ? join(appDir, "frontend")
591
+ : join(appDir, frontend.root);
592
+ const frontendDir = options.frontend ?? defaultFrontendDir;
593
+ const frontendRaw = await collectStagingFiles(frontendDir);
594
+ const frontendFiles = frontendRaw.map((file) => `${frontend.root}/${file.relativePath}`.replace(/\/+/g, "/"));
595
+ const frontendUpload = frontendRaw
596
+ .filter((file) => !file.relativePath.endsWith("/"))
597
+ .map((file) => ({
598
+ ...file,
599
+ relativePath: `${frontend.root}/${file.relativePath}`.replace(/\/+/g, "/"),
600
+ }));
601
+ const frontendSources = await readTextSources(frontendUpload);
602
+ const frontendProjectDir = join(appDir, "frontend");
603
+ const frontendProjectFiles = existsSync(join(frontendProjectDir, "src"))
604
+ ? await collectStagingFiles(join(frontendProjectDir, "src"))
605
+ : [];
606
+ const frontendProjectSources = (await readTextSources(frontendProjectFiles)).map((file) => file.source);
607
+ const viteConfigPath = join(frontendProjectDir, "vite.config.ts");
608
+ const frontendConfigSource = existsSync(viteConfigPath)
609
+ ? await readFile(viteConfigPath, "utf8")
610
+ : undefined;
611
+ let backendSource = "";
612
+ let backendUpload = [];
613
+ if (backend) {
614
+ const backendRoot = dirname(backend.entrypoint);
615
+ const backendDir = options.backend ?? join(appDir, backendRoot);
616
+ const backendRaw = await collectStagingFiles(backendDir);
617
+ backendUpload = backendRaw
618
+ .filter((file) => !file.relativePath.endsWith("/"))
619
+ .map((file) => ({
620
+ ...file,
621
+ relativePath: `${backendRoot}/${file.relativePath}`.replace(/\/+/g, "/"),
622
+ }));
623
+ const entryFile = backendUpload.find((file) => file.relativePath === backend.entrypoint);
624
+ if (!entryFile) {
625
+ throw new AppsApiError("APP_ARTIFACT_INVALID", `Backend entrypoint not found: ${backend.entrypoint}`);
626
+ }
627
+ backendSource = await readFile(entryFile.path, "utf8");
628
+ if (backend.framework === "hono") {
629
+ backendSource = backendSource.replaceAll(HONO_VERSION_PLACEHOLDER, capabilities.honoVersion);
630
+ entryFile.content = backendSource;
631
+ }
632
+ }
633
+ const artifactManifest = {
634
+ schemaVersion: 2,
635
+ mode: "webapp",
636
+ name,
637
+ frontend,
638
+ backend,
639
+ // Preserve the SSR publish-probe path in the uploaded candidate (R2-01);
640
+ // without it the platform loses the fallback=worker probe contract.
641
+ ...(manifest.ssrProbePath ? { ssrProbePath: manifest.ssrProbePath } : {}),
642
+ };
643
+ const appJson = JSON.stringify(artifactManifest, null, 2) + "\n";
644
+ const uploadFiles = [
645
+ ...frontendUpload,
646
+ ...backendUpload,
647
+ {
648
+ path: join(appDir, "app.json"),
649
+ relativePath: "app.json",
650
+ content: appJson,
651
+ },
652
+ ];
653
+ const spec = options.skipSpecCheck
654
+ ? {
655
+ skipped: true,
656
+ passed: true,
657
+ blockers: [],
658
+ warnings: [],
659
+ }
660
+ : {
661
+ skipped: false,
662
+ ...lintAppSource("", {
663
+ mode: "webapp",
664
+ verifyJwt: backend?.verifyJwt ?? false,
665
+ frontendFiles,
666
+ frontendSources,
667
+ frontendConfigSource,
668
+ frontendProjectSources,
669
+ requireHashRouter: manifest.template?.startsWith("react-") ?? false,
670
+ backendSource,
671
+ backendFramework: backend?.framework,
672
+ honoVersion: capabilities.honoVersion,
673
+ ssrLibs: capabilities.ssrLibs,
674
+ secrets: backend?.secrets,
675
+ egressPolicy: backend?.egressPolicy,
676
+ ssrFallback: manifest.frontend?.fallback === "worker",
677
+ ssrProbePath: manifest.ssrProbePath,
678
+ backendSession: backend?.session,
679
+ sessionMode: capabilities.sessionMode,
680
+ sessionHeaders: capabilities.sessionHeaders,
681
+ }),
682
+ };
683
+ if (!spec.passed) {
684
+ const details = spec.blockers
685
+ .map((item) => `[${item.rule}] ${item.message}`)
686
+ .join("; ");
687
+ throw new AppsApiError("APP_ARTIFACT_INVALID", details);
688
+ }
689
+ await assertArtifactLimits(uploadFiles, capabilities);
690
+ if (options.dryRun) {
691
+ const output = {
692
+ status: "dry-run",
693
+ app: name,
694
+ mode: "webapp",
695
+ capabilities,
696
+ specLint: spec,
697
+ };
698
+ if (isJson(options, program))
699
+ console.log(JSON.stringify(output, null, 2));
700
+ else
701
+ console.log(formatSuccess(`Webapp '${name}' passed capability and artifact checks.`));
702
+ return;
703
+ }
704
+ const mount = manifest.deployment
705
+ ? await appsDriver.get(manifest.deployment.mountId)
706
+ : await appsDriver.create({ appName: name });
707
+ const candidateFunctionSlug = createCandidateSlug(mount.id);
708
+ const entrypoint = backend?.entrypoint ?? `${frontend.root}/${frontend.index}`;
709
+ await driver.deploy({
710
+ slug: candidateFunctionSlug,
711
+ entrypointPath: entrypoint,
712
+ verifyJwt: backend?.verifyJwt ?? false,
713
+ files: uploadFiles,
714
+ });
715
+ manifest.backend = backend;
716
+ manifest.deployment = {
717
+ mountId: mount.mountId,
718
+ candidateFunctionSlug,
719
+ generation: mount.generation,
720
+ };
721
+ await writeManifest(options.dir, name, manifest);
722
+ const published = await appsDriver.publish(mount.mountId, {
723
+ candidateFunctionSlug,
724
+ expectedGeneration: mount.generation,
725
+ });
726
+ manifest.deployment = {
727
+ mountId: published.mountId,
728
+ generation: published.generation,
729
+ };
730
+ await writeManifest(options.dir, name, manifest);
731
+ emitWebappResult("published", name, published, capabilities, options, program, { candidateFunctionSlug, specLint: spec });
732
+ }
733
+ function parseBackendFramework(value) {
734
+ if (value === undefined)
735
+ return undefined;
736
+ if (value !== "deno" && value !== "hono") {
737
+ throw new AppsApiError("APP_MANIFEST_INVALID", "--backend-framework must be deno or hono");
738
+ }
739
+ return value;
740
+ }
741
+ function assertWebappCapabilities(capabilities, backend) {
742
+ if (!capabilities.schemaVersions.includes(2) ||
743
+ !capabilities.appEndpoint ||
744
+ !capabilities.publicStatic) {
745
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "Webapp static delivery is not enabled on this instance");
746
+ }
747
+ if (backend && !capabilities.nativeDeno) {
748
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "Native Deno App backends are not enabled on this instance");
749
+ }
750
+ if (backend?.framework === "hono" &&
751
+ (!capabilities.honoVersion || capabilities.honoOfflineReady !== true)) {
752
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "Hono is not enabled with an offline-ready exact version on this instance");
753
+ }
754
+ if (backend &&
755
+ (backend.secrets.length > 0 || backend.egressPolicy !== "deny")) {
756
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "The first Webapp release requires no Secrets and egressPolicy=deny");
757
+ }
758
+ // 方案 C: session contract gate (mirrors Studio validateCandidate).
759
+ if (backend?.session === "proxy-injected" &&
760
+ capabilities.sessionMode !== "proxy-injected-header") {
761
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "This instance does not enable App session injection (capabilities.sessionMode != proxy-injected-header). Use backend.session=\"browser-side\" or \"none\".");
762
+ }
763
+ }
764
+ async function collectStagingFiles(root, current = root) {
765
+ if (!existsSync(root))
766
+ throw new AppsApiError("APP_ARTIFACT_INVALID", `Artifact directory not found: ${root}`);
767
+ const files = [];
768
+ for (const entry of await readdir(current, { withFileTypes: true })) {
769
+ const full = join(current, entry.name);
770
+ const relativePath = relative(root, full).split("\\").join("/");
771
+ if (entry.isDirectory()) {
772
+ if (entry.name === "node_modules")
773
+ files.push({ path: full, relativePath: `${relativePath}/` });
774
+ else if (entry.name !== ".git")
775
+ files.push(...(await collectStagingFiles(root, full)));
776
+ }
777
+ else if (entry.isFile()) {
778
+ files.push({ path: full, relativePath });
779
+ }
780
+ }
781
+ return files;
782
+ }
783
+ async function readTextSources(files) {
784
+ const textFiles = files.filter((file) => /\.(?:html?|css|js|mjs|cjs|jsx|ts|tsx|json|svg|txt)$/i.test(file.relativePath));
785
+ return Promise.all(textFiles.map(async (file) => ({
786
+ path: file.relativePath,
787
+ source: await readFile(file.path, "utf8"),
788
+ })));
789
+ }
790
+ async function assertArtifactLimits(files, capabilities) {
791
+ if (files.length > capabilities.limits.maxFiles) {
792
+ throw new AppsApiError("APP_LIMIT_EXCEEDED", `Artifact has ${files.length} files; maximum is ${capabilities.limits.maxFiles}`);
793
+ }
794
+ let total = 0;
795
+ for (const file of files) {
796
+ const bytes = file.content === undefined
797
+ ? (await stat(file.path)).size
798
+ : Buffer.byteLength(file.content);
799
+ if (bytes > capabilities.limits.maxFileBytes) {
800
+ throw new AppsApiError("APP_LIMIT_EXCEEDED", `${file.relativePath} exceeds ${capabilities.limits.maxFileBytes} bytes`);
801
+ }
802
+ total += bytes;
803
+ }
804
+ if (total > capabilities.limits.maxBundleBytes) {
805
+ throw new AppsApiError("APP_LIMIT_EXCEEDED", `Artifact is ${total} bytes; maximum is ${capabilities.limits.maxBundleBytes}`);
806
+ }
807
+ }
808
+ function createCandidateSlug(appId) {
809
+ const deployId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
810
+ return `app_${appId}_r_${deployId}`;
811
+ }
812
+ function appExposure(capabilities) {
813
+ return capabilities.originMode === "isolated" &&
814
+ capabilities.hostEnforced === true &&
815
+ capabilities.publicExposureAllowed === true
816
+ ? "Public"
817
+ : "Preview";
818
+ }
819
+ function emitWebappResult(status, name, mount, capabilities, options, program, extra = {}) {
820
+ const exposure = appExposure(capabilities);
821
+ const output = {
822
+ status,
823
+ app: name,
824
+ mount,
825
+ exposure,
826
+ ...(exposure === "Public"
827
+ ? { publicUrl: mount.url }
828
+ : { previewUrl: mount.url }),
829
+ ...extra,
830
+ };
831
+ if (isJson(options, program))
832
+ console.log(JSON.stringify(output, null, 2));
833
+ else {
834
+ console.log(formatSuccess(`${status} Webapp '${name}' (generation ${mount.generation}).`));
835
+ console.log(` ${exposure} URL: ${mount.url}`);
836
+ }
837
+ }
838
+ async function requireWebappManifest(dir, name) {
839
+ return requireWebappManifestValue(await readManifest(dir, name), name);
840
+ }
841
+ function requireWebappManifestValue(value, name) {
842
+ if (value.mode !== "webapp" || value.schemaVersion !== 2 || !value.frontend) {
843
+ throw new AppsApiError("APP_MANIFEST_INVALID", `App '${name}' is not a schemaVersion 2 webapp`);
844
+ }
845
+ // R2-09: fully validate a declared backend contract instead of trusting the
846
+ // cast, so a backend missing framework/entrypoint cannot slip through and
847
+ // silently skip the backend lint.
848
+ const backend = value.backend;
849
+ if (backend !== undefined && backend !== null) {
850
+ const b = backend;
851
+ if (typeof b.entrypoint !== "string" ||
852
+ !b.entrypoint.startsWith("backend/") ||
853
+ (b.framework !== "deno" && b.framework !== "hono") ||
854
+ b.pathMode !== "strip-app-api-prefix" ||
855
+ typeof b.verifyJwt !== "boolean" ||
856
+ !Array.isArray(b.secrets) ||
857
+ b.egressPolicy !== "deny") {
858
+ throw new AppsApiError("APP_MANIFEST_INVALID", `App '${name}' has an invalid backend contract: requires entrypoint under backend/, framework deno|hono, pathMode strip-app-api-prefix, boolean verifyJwt, a secrets array, and egressPolicy "deny"`);
859
+ }
860
+ }
861
+ // R2-01 (defense in depth): fallback="worker" requires a valid ssrProbePath.
862
+ if (value.frontend.fallback === "worker" &&
863
+ (typeof value.ssrProbePath !== "string" ||
864
+ !value.ssrProbePath.startsWith("/"))) {
865
+ throw new AppsApiError("APP_MANIFEST_INVALID", `App '${name}' declares frontend.fallback="worker" but is missing a valid ssrProbePath`);
866
+ }
867
+ return value;
868
+ }
869
+ async function writeManifest(dir, name, manifest) {
870
+ await writeFile(join(dir, name, "app.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
871
+ }
872
+ async function handleVerify(name, options, program) {
873
+ const json = isJson(options, program);
874
+ const manifest = await readManifest(options.dir, name);
875
+ if (manifest.mode === "webapp") {
876
+ await handleWebappVerify(name, requireWebappManifestValue(manifest, name), options, program);
877
+ return;
878
+ }
879
+ const slug = options.slug ?? manifest.slug ?? name;
880
+ const { endpoint, tenantId, anonKey } = await getDriver();
881
+ const url = shareUrl(endpoint, slug);
882
+ const timeoutSec = Number(options.timeout ?? "60");
883
+ const deadline = Date.now() + timeoutSec * 1000;
884
+ // Build auth headers for multi-tenant Kong gateway (key-auth is enforced
885
+ // even when verify_jwt=false on the function itself).
886
+ const verifyHeaders = {
887
+ apikey: anonKey,
888
+ Authorization: `Bearer ${anonKey}`,
889
+ };
890
+ if (tenantId) {
891
+ verifyHeaders["X-Tenant-Id"] = tenantId;
892
+ }
893
+ let httpStatus = 0;
894
+ let contentType = "";
895
+ let attempts = 0;
896
+ const startedAt = Date.now();
897
+ while (Date.now() < deadline) {
898
+ attempts++;
899
+ try {
900
+ const res = await axios.get(url, {
901
+ headers: verifyHeaders,
902
+ validateStatus: () => true,
903
+ timeout: 10000,
904
+ });
905
+ httpStatus = res.status;
906
+ contentType = String(res.headers["content-type"] ?? "");
907
+ if (httpStatus === 200)
908
+ break;
909
+ }
910
+ catch {
911
+ httpStatus = 0;
912
+ }
913
+ if (!json)
914
+ process.stderr.write(` attempt#${attempts} HTTP ${httpStatus || "---"} (cold start?), retrying in 5s\n`);
915
+ await new Promise((r) => setTimeout(r, 5000));
916
+ }
917
+ const firstByteMs = Date.now() - startedAt;
918
+ const coldStart = attempts > 1;
919
+ // Ping the built-in API (spa uses ?action=time, api uses ?action=health).
920
+ const apiAction = manifest.template === "api" ? "health" : "time";
921
+ let apiStatus = 0;
922
+ try {
923
+ const r = await axios.get(`${url}?action=${apiAction}`, {
924
+ headers: verifyHeaders,
925
+ validateStatus: () => true,
926
+ timeout: 10000,
927
+ });
928
+ apiStatus = r.status;
929
+ }
930
+ catch {
931
+ apiStatus = 0;
932
+ }
933
+ // MPA: probe every declared page/asset — each must be 200 with a matching
934
+ // content-type (served by the proxy static branch, not the worker).
935
+ const mode = manifest.mode ?? manifest.template ?? "spa";
936
+ const expectedCtype = (f) => {
937
+ if (/\.html?$/i.test(f))
938
+ return "text/html";
939
+ if (/\.css$/i.test(f))
940
+ return "text/css";
941
+ if (/\.m?js$/i.test(f))
942
+ return "javascript";
943
+ if (/\.json$/i.test(f))
944
+ return "application/json";
945
+ if (/\.svg$/i.test(f))
946
+ return "image/svg+xml";
947
+ return "";
948
+ };
949
+ const assetChecks = [];
950
+ if (mode === "mpa") {
951
+ const base = url.replace(/\/$/, "");
952
+ for (const f of [...(manifest.pages ?? []), ...(manifest.assets ?? [])]) {
953
+ let s = 0;
954
+ let ct = "";
955
+ try {
956
+ const r = await axios.get(`${base}/${f}`, {
957
+ headers: verifyHeaders,
958
+ validateStatus: () => true,
959
+ timeout: 10000,
960
+ });
961
+ s = r.status;
962
+ ct = String(r.headers["content-type"] ?? "");
963
+ }
964
+ catch {
965
+ s = 0;
966
+ }
967
+ const want = expectedCtype(f);
968
+ assetChecks.push({
969
+ path: f,
970
+ httpStatus: s,
971
+ contentType: ct,
972
+ ok: s === 200 && (!want || ct.includes(want)),
973
+ });
974
+ }
975
+ }
976
+ const assetsOk = assetChecks.every((a) => a.ok);
977
+ const ok = httpStatus === 200 && assetsOk;
978
+ const out = {
979
+ status: ok ? "ok" : "failed",
980
+ shareUrl: url,
981
+ mode,
982
+ httpStatus,
983
+ contentType,
984
+ attempts,
985
+ coldStart,
986
+ firstByteMs,
987
+ apiPing: { action: apiAction, httpStatus: apiStatus },
988
+ assets: assetChecks,
989
+ };
990
+ if (json) {
991
+ console.log(JSON.stringify(out, null, 2));
992
+ process.exit(ok ? 0 : 1);
993
+ }
994
+ if (ok)
995
+ console.log(formatSuccess(`Webapp reachable: ${url} (HTTP 200, ${contentType}${coldStart ? ", cold start" : ""})`));
996
+ else
997
+ console.error(formatError(`Webapp not reachable after ${attempts} attempt(s): last HTTP ${httpStatus || "n/a"} — ${url}`));
998
+ console.log(` API ping ?action=${apiAction}: HTTP ${apiStatus}`);
999
+ for (const a of assetChecks) {
1000
+ const line = ` Asset ${a.path}: HTTP ${a.httpStatus || "---"}${a.contentType ? ", " + a.contentType : ""}`;
1001
+ if (a.ok)
1002
+ console.log(line);
1003
+ else
1004
+ console.error(formatError(line + " (expected 200 + matching content-type)"));
1005
+ }
1006
+ process.exit(ok ? 0 : 1);
1007
+ }
1008
+ async function handleWebappVerify(name, manifest, options, program) {
1009
+ if (!manifest.deployment)
1010
+ throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
1011
+ const { appsDriver } = await getDriver();
1012
+ const mount = await appsDriver.get(manifest.deployment.mountId);
1013
+ const timeoutSec = Number(options.timeout ?? "60");
1014
+ const deadline = Date.now() + timeoutSec * 1000;
1015
+ let httpStatus = 0;
1016
+ let contentType = "";
1017
+ let attempts = 0;
1018
+ while (Date.now() < deadline) {
1019
+ attempts++;
1020
+ try {
1021
+ const response = await axios.get(mount.url, {
1022
+ validateStatus: () => true,
1023
+ timeout: 10_000,
1024
+ });
1025
+ httpStatus = response.status;
1026
+ contentType = String(response.headers["content-type"] ?? "");
1027
+ if (httpStatus === 200)
1028
+ break;
1029
+ }
1030
+ catch {
1031
+ httpStatus = 0;
1032
+ }
1033
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
1034
+ }
1035
+ // Probe the backend only when app.json declares an explicit backend.healthPath
1036
+ // (P1-2): a valid backend is NOT required to expose a fixed /api/health route.
1037
+ // Without healthPath, verify checks only the mount homepage.
1038
+ let backendStatus = null;
1039
+ const healthPath = manifest.backend?.healthPath;
1040
+ if (manifest.backend && healthPath) {
1041
+ const probePath = healthPath.startsWith("/") ? healthPath : `/${healthPath}`;
1042
+ try {
1043
+ const response = await axios.get(`${mount.url.replace(/\/$/, "")}${probePath}`, { validateStatus: () => true, timeout: 10_000 });
1044
+ backendStatus = response.status;
1045
+ }
1046
+ catch {
1047
+ backendStatus = 0;
1048
+ }
1049
+ }
1050
+ const ok = httpStatus === 200 && (backendStatus === null || backendStatus === 200);
1051
+ const output = {
1052
+ status: ok ? "ok" : "failed",
1053
+ app: name,
1054
+ url: mount.url,
1055
+ httpStatus,
1056
+ contentType,
1057
+ attempts,
1058
+ backendStatus,
1059
+ };
1060
+ if (isJson(options, program))
1061
+ console.log(JSON.stringify(output, null, 2));
1062
+ else if (ok)
1063
+ console.log(formatSuccess(`Webapp reachable: ${mount.url}`));
1064
+ else
1065
+ console.error(formatError(`Webapp verification failed: ${mount.url}`));
1066
+ if (!ok)
1067
+ process.exit(1);
1068
+ }
1069
+ async function handleUrl(name, options, program) {
1070
+ const manifest = await readManifest(options.dir, name);
1071
+ if (manifest.mode === "webapp") {
1072
+ const webapp = requireWebappManifestValue(manifest, name);
1073
+ if (!webapp.deployment)
1074
+ throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
1075
+ const { appsDriver } = await getDriver();
1076
+ const [mount, capabilities] = await Promise.all([
1077
+ appsDriver.get(webapp.deployment.mountId),
1078
+ appsDriver.capabilities(),
1079
+ ]);
1080
+ emitWebappResult("url", name, mount, capabilities, options, program);
1081
+ return;
1082
+ }
1083
+ const slug = options.slug ?? manifest.slug ?? name;
1084
+ const { endpoint } = await getDriver();
1085
+ const url = shareUrl(endpoint, slug);
1086
+ const publicReachable = isPublicEndpoint(endpoint);
1087
+ if (isJson(options, program)) {
1088
+ console.log(JSON.stringify({ app: name, slug, shareUrl: url, publicReachable }, null, 2));
1089
+ return;
1090
+ }
1091
+ console.log(url);
1092
+ if (!publicReachable)
1093
+ console.log(formatWarning("Endpoint is not publicly reachable; this URL will not work cross-machine."));
1094
+ }
1095
+ async function handleList(options, program) {
1096
+ const { driver } = await getDriver();
1097
+ const functions = await driver.list();
1098
+ if (isJson(options, program)) {
1099
+ console.log(JSON.stringify({ count: functions.length, functions }, null, 2));
1100
+ return;
1101
+ }
1102
+ if (functions.length === 0) {
1103
+ console.log("No deployed functions.");
1104
+ return;
1105
+ }
1106
+ for (const f of functions) {
1107
+ console.log(`${f.slug}\t${f.status}\tv${f.version}\tverify_jwt=${f.verify_jwt}`);
1108
+ }
1109
+ }
1110
+ async function handleRemove(name, options, program) {
1111
+ const json = isJson(options, program);
1112
+ const manifest = await readManifest(options.dir, name);
1113
+ // D6: `app remove` deletes ONE legacy Edge Function by slug. A webapp owns
1114
+ // auto-generated `app_<id>_r_` candidates plus a mount record; the old path
1115
+ // took slug=name, missed the real candidate, and left the mount behind. Route
1116
+ // webapps to `app destroy` instead of silently doing the wrong thing.
1117
+ if (manifest.mode === "webapp") {
1118
+ throw new AppsApiError("APP_MANIFEST_INVALID", `'${name}' is a schemaVersion 2 webapp. Use \`pxbaas app destroy ${name} --confirm\` to delete its edge functions and mount record (app remove only handles legacy spa/api/mpa functions).`);
1119
+ }
1120
+ const slug = options.slug ?? manifest.slug ?? name;
1121
+ if (!options.confirm) {
1122
+ const preview = {
1123
+ status: "dry-run",
1124
+ app: name,
1125
+ slug,
1126
+ note: "pass --confirm to delete the deployed function (irreversible)",
1127
+ };
1128
+ if (json)
1129
+ console.log(JSON.stringify(preview, null, 2));
1130
+ else
1131
+ console.log(formatWarning(`Preview: remove function '${slug}'. Re-run with --confirm to delete (irreversible).`));
1132
+ return;
1133
+ }
1134
+ const { driver } = await getDriver();
1135
+ await driver.delete(slug);
1136
+ if (json)
1137
+ console.log(JSON.stringify({ status: "removed", app: name, slug }, null, 2));
1138
+ else
1139
+ console.log(formatSuccess(`Removed function '${slug}'.`));
1140
+ }
1141
+ /**
1142
+ * `pxbaas app destroy <name>` — permanently tear down a schemaVersion 2 webapp:
1143
+ * delete its edge-func candidates and its mount-id record. Two-phase: without
1144
+ * --confirm it only previews what will be reclaimed. Idempotent server-side.
1145
+ *
1146
+ * DB business tables are kept by default (scope decision 2026-10-16). With
1147
+ * `--purge-db` the app's own tables + RLS registry rows are also removed via the
1148
+ * helpers below (mode-aware: single-tenant /pg/query, multi-tenant /api/tenant-ddl).
1149
+ */
1150
+ /**
1151
+ * Derive the DB tables an app owns, from its bundled db/schema.sql (CREATE TABLE
1152
+ * names) and db/rls.json (declared tables/policies). Used by `destroy --purge-db`.
1153
+ */
1154
+ async function deriveAppTables(appDir) {
1155
+ const set = new Set();
1156
+ const schemaFile = join(appDir, "db", "schema.sql");
1157
+ if (existsSync(schemaFile)) {
1158
+ for (const t of extractCreateTableNames(await readFile(schemaFile, "utf8"))) {
1159
+ set.add(t);
1160
+ }
1161
+ }
1162
+ const rlsFile = join(appDir, "db", "rls.json");
1163
+ if (existsSync(rlsFile)) {
1164
+ const rls = parseRlsJson(await readFile(rlsFile, "utf8"));
1165
+ for (const t of rls.tables)
1166
+ set.add(t.table_name);
1167
+ for (const p of rls.policies)
1168
+ set.add(p.table_name);
1169
+ }
1170
+ return [...set];
1171
+ }
1172
+ /**
1173
+ * Drop the app's tables + remove its RLS registry rows via the mode-appropriate
1174
+ * channel. RLS is purged before the DROP so policy cleanup still resolves.
1175
+ */
1176
+ async function purgeAppDatabase(appDir, schema) {
1177
+ const tables = await deriveAppTables(appDir);
1178
+ const sink = await getSchemaSink("service_role");
1179
+ const ctx = { schema };
1180
+ const dropped = [];
1181
+ const errors = [];
1182
+ let policiesDeleted = 0;
1183
+ let toggleRowsRemoved = true;
1184
+ for (const t of tables) {
1185
+ try {
1186
+ const r = await sink.purgeRlsForTable(t, ctx);
1187
+ policiesDeleted += r.policiesDeleted;
1188
+ if (!r.toggleRemoved)
1189
+ toggleRowsRemoved = false;
1190
+ await sink.dropTable(t, ctx);
1191
+ dropped.push(t);
1192
+ }
1193
+ catch (e) {
1194
+ errors.push(`${t}: ${e instanceof Error ? e.message : String(e)}`);
1195
+ }
1196
+ }
1197
+ return { tables, dropped, policiesDeleted, toggleRowsRemoved, errors };
1198
+ }
1199
+ async function handleDestroy(name, options, program) {
1200
+ const json = isJson(options, program);
1201
+ const manifest = await readManifest(options.dir, name);
1202
+ // D6: destroy is webapp-only; legacy spa/api/mpa keep using `app remove`.
1203
+ if (manifest.mode !== "webapp") {
1204
+ throw new AppsApiError("APP_MANIFEST_INVALID", `App '${name}' is not a schemaVersion 2 webapp. Use \`pxbaas app remove ${name} --confirm\` to delete a legacy Edge Function.`);
1205
+ }
1206
+ const webapp = requireWebappManifestValue(manifest, name);
1207
+ const deployment = webapp.deployment;
1208
+ if (!deployment) {
1209
+ throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state; nothing to destroy`);
1210
+ }
1211
+ const { appsDriver } = await getDriver();
1212
+ // Phase 1: preview (no --confirm). Pull the live mount so the operator sees
1213
+ // exactly which functions + mount record will be reclaimed.
1214
+ if (!options.confirm) {
1215
+ let mount = null;
1216
+ try {
1217
+ mount = await appsDriver.get(deployment.mountId);
1218
+ }
1219
+ catch {
1220
+ mount = null; // already gone server-side; preview is still informative
1221
+ }
1222
+ const slugs = new Set();
1223
+ if (mount?.activeFunctionSlug)
1224
+ slugs.add(mount.activeFunctionSlug);
1225
+ if (mount?.previousFunctionSlug)
1226
+ slugs.add(mount.previousFunctionSlug);
1227
+ if (deployment.candidateFunctionSlug)
1228
+ slugs.add(deployment.candidateFunctionSlug);
1229
+ const purgeTables = options.purgeDb
1230
+ ? await deriveAppTables(join(options.dir, name))
1231
+ : [];
1232
+ const preview = {
1233
+ status: "dry-run",
1234
+ app: name,
1235
+ mountId: deployment.mountId,
1236
+ mountStatus: mount?.status ?? "NOT_FOUND",
1237
+ functions: [...slugs],
1238
+ tablesUntouched: !options.purgeDb,
1239
+ ...(options.purgeDb ? { purgeDb: { tables: purgeTables } } : {}),
1240
+ note: options.purgeDb
1241
+ ? "pass --confirm to destroy: deletes the edge functions + mount record (irreversible) AND drops the listed DB tables + their RLS rows."
1242
+ : "pass --confirm to destroy: deletes the edge functions + mount record (irreversible). DB business tables are NOT dropped.",
1243
+ };
1244
+ if (json) {
1245
+ console.log(JSON.stringify(preview, null, 2));
1246
+ return;
1247
+ }
1248
+ console.log(formatWarning(`Preview: destroy webapp '${name}' (mount ${deployment.mountId}). Re-run with --confirm.`));
1249
+ console.log(` Functions to delete: ${[...slugs].join(", ") || "(none)"}`);
1250
+ console.log(` Mount record: ${deployment.mountId} (${preview.mountStatus})`);
1251
+ if (options.purgeDb) {
1252
+ console.log(formatWarning(` --purge-db will DROP tables: ${purgeTables.join(", ") || "(none detected)"}`));
1253
+ console.log(formatWarning(" Their RLS rows are removed too (policies + the central rls_tables toggle). On instances predating the tenant RLS-table DELETE endpoint the toggle row is left — harmless, and re-created on the next db-init."));
1254
+ }
1255
+ else {
1256
+ console.log(formatWarning(" DB business tables are NOT dropped; clean them up yourself if needed."));
1257
+ }
1258
+ return;
1259
+ }
1260
+ // Phase 2: destroy. Pass the local orphan candidate (if any) so an uploaded
1261
+ // but never-published function is reclaimed too.
1262
+ const result = await appsDriver.destroy(deployment.mountId, {
1263
+ ...(deployment.candidateFunctionSlug
1264
+ ? { candidateFunctionSlug: deployment.candidateFunctionSlug }
1265
+ : {}),
1266
+ });
1267
+ // D5: clear ONLY the local deployment state; keep the source tree so the app
1268
+ // can be re-deployed later.
1269
+ delete webapp.deployment;
1270
+ await writeManifest(options.dir, name, webapp);
1271
+ // Optional DB purge (Q4): drop the app's own tables + RLS rows via the
1272
+ // mode-appropriate channel. Runs AFTER the mount/functions are reclaimed.
1273
+ const purge = options.purgeDb
1274
+ ? await purgeAppDatabase(join(options.dir, name), options.schema ?? "public")
1275
+ : null;
1276
+ const failed = result.deletedFunctions.filter((f) => !f.deleted);
1277
+ if (json) {
1278
+ console.log(JSON.stringify({
1279
+ status: "destroyed",
1280
+ app: name,
1281
+ mountId: result.mountId,
1282
+ alreadyGone: result.alreadyGone ?? false,
1283
+ deletedFunctions: result.deletedFunctions,
1284
+ tablesUntouched: !purge,
1285
+ ...(purge ? { purgeDb: purge } : {}),
1286
+ }, null, 2));
1287
+ if (failed.length > 0 || (purge && purge.errors.length > 0))
1288
+ process.exit(1);
1289
+ return;
1290
+ }
1291
+ console.log(formatSuccess(`Destroyed webapp '${name}' (mount ${result.mountId}${result.alreadyGone ? ", already gone" : ""}).`));
1292
+ for (const f of result.deletedFunctions) {
1293
+ if (f.deleted)
1294
+ console.log(` Deleted function: ${f.slug}`);
1295
+ else
1296
+ console.error(formatError(` Failed to delete function: ${f.slug} — ${f.error}`));
1297
+ }
1298
+ if (purge) {
1299
+ console.log(formatSuccess(` Purged DB: dropped ${purge.dropped.length}/${purge.tables.length} table(s), removed ${purge.policiesDeleted} RLS policy/policies.`));
1300
+ if (!purge.toggleRowsRemoved) {
1301
+ console.log(formatWarning(" Some central rls_tables toggle rows remain (this instance predates the tenant RLS-table DELETE endpoint); harmless and re-created on the next db-init."));
1302
+ }
1303
+ for (const e of purge.errors)
1304
+ console.error(formatError(` Purge failed for ${e}`));
1305
+ }
1306
+ else {
1307
+ console.log(formatWarning(" DB business tables were NOT dropped; clean them up yourself if needed."));
1308
+ }
1309
+ if (failed.length > 0 || (purge && purge.errors.length > 0))
1310
+ process.exit(1);
1311
+ }
1312
+ function registerShareCommands(appCmd, program) {
1313
+ const shareCmd = appCmd
1314
+ .command("share")
1315
+ .description("Create, inspect, revoke, and verify secure MPA share links");
1316
+ shareCmd
1317
+ .command("create <slug>")
1318
+ .description("Create a secure MPA share link and print its one-time entry URL")
1319
+ .option("--expires-in <seconds>", "Lifetime in seconds", "86400")
1320
+ .option("--expires-at <iso8601>", "Absolute RFC3339 expiry (instead of --expires-in)")
1321
+ .option("--max-uses <count>", "Maximum ticket redemptions")
1322
+ .option("--tenant-id <tenant>", "Explicit target tenant (required for platform credentials)")
1323
+ .option("--json", "Output in JSON format")
1324
+ .action(async (slug, options) => {
1325
+ try {
1326
+ await handleShareCreate(slug, options, program);
1327
+ }
1328
+ catch (e) {
1329
+ fail(e, isJson(options, program));
1330
+ }
1331
+ });
1332
+ shareCmd
1333
+ .command("list <slug>")
1334
+ .description("List secure MPA share links without exposing tickets or sessions")
1335
+ .option("--tenant-id <tenant>", "Explicit target tenant (required for platform credentials)")
1336
+ .option("--json", "Output in JSON format")
1337
+ .action(async (slug, options) => {
1338
+ try {
1339
+ await handleShareList(slug, options, program);
1340
+ }
1341
+ catch (e) {
1342
+ fail(e, isJson(options, program));
1343
+ }
1344
+ });
1345
+ shareCmd
1346
+ .command("revoke <slug> <share-id>")
1347
+ .description("Revoke a secure MPA share link (requires --confirm)")
1348
+ .option("--tenant-id <tenant>", "Explicit target tenant (required for platform credentials)")
1349
+ .option("--confirm", "Actually revoke the share link")
1350
+ .option("--json", "Output in JSON format")
1351
+ .action(async (slug, shareId, options) => {
1352
+ try {
1353
+ await handleShareRevoke(slug, shareId, options, program);
1354
+ }
1355
+ catch (e) {
1356
+ fail(e, isJson(options, program));
1357
+ }
1358
+ });
1359
+ shareCmd
1360
+ .command("verify <slug> <share-id>")
1361
+ .description("Verify a share link status without revealing a ticket or cookie")
1362
+ .option("--tenant-id <tenant>", "Explicit target tenant (required for platform credentials)")
1363
+ .option("--json", "Output in JSON format")
1364
+ .action(async (slug, shareId, options) => {
1365
+ try {
1366
+ await handleShareVerify(slug, shareId, options, program);
1367
+ }
1368
+ catch (e) {
1369
+ fail(e, isJson(options, program));
1370
+ }
1371
+ });
1372
+ }
1373
+ function secureShareUrl(endpoint, mountId, ticket) {
1374
+ return `${endpoint.replace(/\/$/, "")}/share/v1/${encodeURIComponent(mountId)}/${encodeURIComponent(ticket)}`;
1375
+ }
1376
+ function parsePositiveInteger(value, optionName) {
1377
+ if (!/^[1-9]\d*$/.test(value)) {
1378
+ throw new Error(`${optionName} must be a positive integer.`);
1379
+ }
1380
+ const parsed = Number(value);
1381
+ if (!Number.isSafeInteger(parsed) || parsed > 4_294_967_295) {
1382
+ throw new Error(`${optionName} must be at most 4294967295.`);
1383
+ }
1384
+ return parsed;
1385
+ }
1386
+ async function handleShareCreate(slug, options, program) {
1387
+ validateSlug(slug);
1388
+ if (options.expiresAt) {
1389
+ const expiresAt = new Date(options.expiresAt);
1390
+ if (!Number.isFinite(expiresAt.getTime()) ||
1391
+ expiresAt.getTime() <= Date.now()) {
1392
+ throw new Error("--expires-at must be a future ISO-8601 timestamp.");
1393
+ }
1394
+ }
1395
+ const maxUses = options.maxUses
1396
+ ? parsePositiveInteger(options.maxUses, "--max-uses")
1397
+ : undefined;
1398
+ const expiresInSeconds = options.expiresAt
1399
+ ? undefined
1400
+ : parsePositiveInteger(options.expiresIn, "--expires-in");
1401
+ const { shareDriver, endpoint } = await getDriver();
1402
+ const link = await shareDriver.create(slug, {
1403
+ expiresAt: options.expiresAt,
1404
+ expiresInSeconds,
1405
+ maxUses,
1406
+ tenantId: options.tenantId,
1407
+ });
1408
+ // Do not emit the raw ticket as a separate field or diagnostic. The entry URL
1409
+ // is the only one-time delivery channel; list/verify/revoke never contain it.
1410
+ const { ticket, ...share } = link;
1411
+ const entryUrl = secureShareUrl(endpoint, link.mount_id, ticket);
1412
+ const output = { status: "created", slug, entryUrl, share };
1413
+ if (isJson(options, program)) {
1414
+ console.log(JSON.stringify(output, null, 2));
1415
+ return;
1416
+ }
1417
+ console.log(formatSuccess(`Created secure MPA share for '${slug}'. Save this entry URL now; it cannot be recovered:`));
1418
+ console.log(` ${entryUrl}`);
1419
+ console.log(` Expires: ${share.expires_at}`);
1420
+ console.log(` Share ID: ${share.id}`);
1421
+ }
1422
+ async function handleShareList(slug, options, program) {
1423
+ validateSlug(slug);
1424
+ const { shareDriver } = await getDriver();
1425
+ const shares = await shareDriver.list(slug, options.tenantId);
1426
+ if (isJson(options, program)) {
1427
+ console.log(JSON.stringify({ slug, count: shares.length, shares }, null, 2));
1428
+ return;
1429
+ }
1430
+ if (shares.length === 0) {
1431
+ console.log("No secure MPA share links.");
1432
+ return;
1433
+ }
1434
+ for (const share of shares) {
1435
+ console.log(`${share.id}\t${share.status}\texpires=${share.expires_at}\tuses=${share.used_count}/${share.max_uses ?? "unlimited"}`);
1436
+ }
1437
+ }
1438
+ async function handleShareRevoke(slug, shareId, options, program) {
1439
+ validateSlug(slug);
1440
+ if (!options.confirm) {
1441
+ const preview = {
1442
+ status: "dry-run",
1443
+ slug,
1444
+ shareId,
1445
+ note: "pass --confirm to revoke this share link and invalidate all of its active sessions",
1446
+ };
1447
+ if (isJson(options, program))
1448
+ console.log(JSON.stringify(preview, null, 2));
1449
+ else
1450
+ console.log(formatWarning(`Preview: revoke share '${shareId}' for '${slug}'. Re-run with --confirm.`));
1451
+ return;
1452
+ }
1453
+ const { shareDriver } = await getDriver();
1454
+ const share = await shareDriver.revoke(slug, shareId, options.tenantId);
1455
+ if (isJson(options, program)) {
1456
+ console.log(JSON.stringify({ status: "revoked", slug, share }, null, 2));
1457
+ return;
1458
+ }
1459
+ console.log(formatSuccess(`Revoked share '${share.id}' for '${slug}'.`));
1460
+ }
1461
+ async function handleShareVerify(slug, shareId, options, program) {
1462
+ validateSlug(slug);
1463
+ const { shareDriver } = await getDriver();
1464
+ const share = await shareDriver.get(slug, shareId, options.tenantId);
1465
+ const output = {
1466
+ status: share.status === "ACTIVE" ? "active" : "inactive",
1467
+ slug,
1468
+ share,
1469
+ };
1470
+ if (isJson(options, program)) {
1471
+ console.log(JSON.stringify(output, null, 2));
1472
+ return;
1473
+ }
1474
+ console.log(`${share.id}: ${share.status} (expires ${share.expires_at}, uses ${share.used_count}/${share.max_uses ?? "unlimited"})`);
1475
+ }
1476
+ //# sourceMappingURL=index.js.map