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

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 (312) hide show
  1. package/README.md +223 -2
  2. package/bin/pxbaas.js +13 -0
  3. package/dist/app/local-index.d.ts +70 -0
  4. package/dist/app/local-index.d.ts.map +1 -0
  5. package/dist/app/local-index.js +140 -0
  6. package/dist/app/local-index.js.map +1 -0
  7. package/dist/app/preflight.d.ts +61 -0
  8. package/dist/app/preflight.d.ts.map +1 -0
  9. package/dist/app/preflight.js +115 -0
  10. package/dist/app/preflight.js.map +1 -0
  11. package/dist/app/spec-lint.d.ts +45 -0
  12. package/dist/app/spec-lint.d.ts.map +1 -0
  13. package/dist/app/spec-lint.js +423 -0
  14. package/dist/app/spec-lint.js.map +1 -0
  15. package/dist/app/templates.d.ts +167 -0
  16. package/dist/app/templates.d.ts.map +1 -0
  17. package/dist/app/templates.js +151 -0
  18. package/dist/app/templates.js.map +1 -0
  19. package/dist/cli/help-guides.d.ts +17 -0
  20. package/dist/cli/help-guides.d.ts.map +1 -0
  21. package/dist/cli/help-guides.js +132 -0
  22. package/dist/cli/help-guides.js.map +1 -0
  23. package/dist/cli/root.d.ts +3 -0
  24. package/dist/cli/root.d.ts.map +1 -0
  25. package/dist/cli/root.js +75 -0
  26. package/dist/cli/root.js.map +1 -0
  27. package/dist/commands/app/index.d.ts +8 -0
  28. package/dist/commands/app/index.d.ts.map +1 -0
  29. package/dist/commands/app/index.js +1907 -0
  30. package/dist/commands/app/index.js.map +1 -0
  31. package/dist/commands/auth/index.d.ts +3 -0
  32. package/dist/commands/auth/index.d.ts.map +1 -0
  33. package/dist/commands/auth/index.js +198 -0
  34. package/dist/commands/auth/index.js.map +1 -0
  35. package/dist/commands/completion/index.d.ts +3 -0
  36. package/dist/commands/completion/index.d.ts.map +1 -0
  37. package/dist/commands/completion/index.js +14 -0
  38. package/dist/commands/completion/index.js.map +1 -0
  39. package/dist/commands/data/index.d.ts +55 -0
  40. package/dist/commands/data/index.d.ts.map +1 -0
  41. package/dist/commands/data/index.js +407 -0
  42. package/dist/commands/data/index.js.map +1 -0
  43. package/dist/commands/db/index.d.ts +29 -0
  44. package/dist/commands/db/index.d.ts.map +1 -0
  45. package/dist/commands/db/index.js +500 -0
  46. package/dist/commands/db/index.js.map +1 -0
  47. package/dist/commands/doctor/index.d.ts +3 -0
  48. package/dist/commands/doctor/index.d.ts.map +1 -0
  49. package/dist/commands/doctor/index.js +305 -0
  50. package/dist/commands/doctor/index.js.map +1 -0
  51. package/dist/commands/functions/delete.d.ts +3 -0
  52. package/dist/commands/functions/delete.d.ts.map +1 -0
  53. package/dist/commands/functions/delete.js +55 -0
  54. package/dist/commands/functions/delete.js.map +1 -0
  55. package/dist/commands/functions/deploy.d.ts +3 -0
  56. package/dist/commands/functions/deploy.d.ts.map +1 -0
  57. package/dist/commands/functions/deploy.js +118 -0
  58. package/dist/commands/functions/deploy.js.map +1 -0
  59. package/dist/commands/functions/index.d.ts +3 -0
  60. package/dist/commands/functions/index.d.ts.map +1 -0
  61. package/dist/commands/functions/index.js +15 -0
  62. package/dist/commands/functions/index.js.map +1 -0
  63. package/dist/commands/functions/list.d.ts +6 -0
  64. package/dist/commands/functions/list.d.ts.map +1 -0
  65. package/dist/commands/functions/list.js +63 -0
  66. package/dist/commands/functions/list.js.map +1 -0
  67. package/dist/commands/functions/new.d.ts +7 -0
  68. package/dist/commands/functions/new.d.ts.map +1 -0
  69. package/dist/commands/functions/new.js +72 -0
  70. package/dist/commands/functions/new.js.map +1 -0
  71. package/dist/commands/gen/index.d.ts +3 -0
  72. package/dist/commands/gen/index.d.ts.map +1 -0
  73. package/dist/commands/gen/index.js +323 -0
  74. package/dist/commands/gen/index.js.map +1 -0
  75. package/dist/commands/guide/index.d.ts +8 -0
  76. package/dist/commands/guide/index.d.ts.map +1 -0
  77. package/dist/commands/guide/index.js +20 -0
  78. package/dist/commands/guide/index.js.map +1 -0
  79. package/dist/commands/instance/index.d.ts +8 -0
  80. package/dist/commands/instance/index.d.ts.map +1 -0
  81. package/dist/commands/instance/index.js +481 -0
  82. package/dist/commands/instance/index.js.map +1 -0
  83. package/dist/commands/login/index.d.ts +3 -0
  84. package/dist/commands/login/index.d.ts.map +1 -0
  85. package/dist/commands/login/index.js +145 -0
  86. package/dist/commands/login/index.js.map +1 -0
  87. package/dist/commands/logout/index.d.ts +3 -0
  88. package/dist/commands/logout/index.d.ts.map +1 -0
  89. package/dist/commands/logout/index.js +41 -0
  90. package/dist/commands/logout/index.js.map +1 -0
  91. package/dist/commands/profile/import-env.d.ts +18 -0
  92. package/dist/commands/profile/import-env.d.ts.map +1 -0
  93. package/dist/commands/profile/import-env.js +206 -0
  94. package/dist/commands/profile/import-env.js.map +1 -0
  95. package/dist/commands/profile/index.d.ts +3 -0
  96. package/dist/commands/profile/index.d.ts.map +1 -0
  97. package/dist/commands/profile/index.js +124 -0
  98. package/dist/commands/profile/index.js.map +1 -0
  99. package/dist/commands/rest/index.d.ts +3 -0
  100. package/dist/commands/rest/index.d.ts.map +1 -0
  101. package/dist/commands/rest/index.js +102 -0
  102. package/dist/commands/rest/index.js.map +1 -0
  103. package/dist/commands/skill/index.d.ts +11 -0
  104. package/dist/commands/skill/index.d.ts.map +1 -0
  105. package/dist/commands/skill/index.js +228 -0
  106. package/dist/commands/skill/index.js.map +1 -0
  107. package/dist/commands/status/index.d.ts +3 -0
  108. package/dist/commands/status/index.d.ts.map +1 -0
  109. package/dist/commands/status/index.js +59 -0
  110. package/dist/commands/status/index.js.map +1 -0
  111. package/dist/commands/storage/index.d.ts +3 -0
  112. package/dist/commands/storage/index.d.ts.map +1 -0
  113. package/dist/commands/storage/index.js +400 -0
  114. package/dist/commands/storage/index.js.map +1 -0
  115. package/dist/commands/tenant/index.d.ts +19 -0
  116. package/dist/commands/tenant/index.d.ts.map +1 -0
  117. package/dist/commands/tenant/index.js +667 -0
  118. package/dist/commands/tenant/index.js.map +1 -0
  119. package/dist/commands/whoami/index.d.ts +3 -0
  120. package/dist/commands/whoami/index.d.ts.map +1 -0
  121. package/dist/commands/whoami/index.js +101 -0
  122. package/dist/commands/whoami/index.js.map +1 -0
  123. package/dist/credentials/commit.d.ts +38 -0
  124. package/dist/credentials/commit.d.ts.map +1 -0
  125. package/dist/credentials/commit.js +57 -0
  126. package/dist/credentials/commit.js.map +1 -0
  127. package/dist/credentials/credential-store.d.ts +86 -0
  128. package/dist/credentials/credential-store.d.ts.map +1 -0
  129. package/dist/credentials/credential-store.js +282 -0
  130. package/dist/credentials/credential-store.js.map +1 -0
  131. package/dist/credentials/env-file.d.ts +85 -0
  132. package/dist/credentials/env-file.d.ts.map +1 -0
  133. package/dist/credentials/env-file.js +193 -0
  134. package/dist/credentials/env-file.js.map +1 -0
  135. package/dist/credentials/formatter.d.ts +39 -0
  136. package/dist/credentials/formatter.d.ts.map +1 -0
  137. package/dist/credentials/formatter.js +79 -0
  138. package/dist/credentials/formatter.js.map +1 -0
  139. package/dist/credentials/index.d.ts +13 -0
  140. package/dist/credentials/index.d.ts.map +1 -0
  141. package/dist/credentials/index.js +13 -0
  142. package/dist/credentials/index.js.map +1 -0
  143. package/dist/credentials/jwt-introspect.d.ts +39 -0
  144. package/dist/credentials/jwt-introspect.d.ts.map +1 -0
  145. package/dist/credentials/jwt-introspect.js +64 -0
  146. package/dist/credentials/jwt-introspect.js.map +1 -0
  147. package/dist/credentials/masker.d.ts +23 -0
  148. package/dist/credentials/masker.d.ts.map +1 -0
  149. package/dist/credentials/masker.js +40 -0
  150. package/dist/credentials/masker.js.map +1 -0
  151. package/dist/credentials/profile.d.ts +95 -0
  152. package/dist/credentials/profile.d.ts.map +1 -0
  153. package/dist/credentials/profile.js +207 -0
  154. package/dist/credentials/profile.js.map +1 -0
  155. package/dist/credentials/tenant-guard.d.ts +16 -0
  156. package/dist/credentials/tenant-guard.d.ts.map +1 -0
  157. package/dist/credentials/tenant-guard.js +53 -0
  158. package/dist/credentials/tenant-guard.js.map +1 -0
  159. package/dist/credentials/tty-guard.d.ts +29 -0
  160. package/dist/credentials/tty-guard.d.ts.map +1 -0
  161. package/dist/credentials/tty-guard.js +79 -0
  162. package/dist/credentials/tty-guard.js.map +1 -0
  163. package/dist/db/apply-schema.d.ts +144 -0
  164. package/dist/db/apply-schema.d.ts.map +1 -0
  165. package/dist/db/apply-schema.js +359 -0
  166. package/dist/db/apply-schema.js.map +1 -0
  167. package/dist/db/introspect.d.ts +54 -0
  168. package/dist/db/introspect.d.ts.map +1 -0
  169. package/dist/db/introspect.js +140 -0
  170. package/dist/db/introspect.js.map +1 -0
  171. package/dist/db/sql-file.d.ts +104 -0
  172. package/dist/db/sql-file.d.ts.map +1 -0
  173. package/dist/db/sql-file.js +584 -0
  174. package/dist/db/sql-file.js.map +1 -0
  175. package/dist/instance/provision.d.ts +89 -0
  176. package/dist/instance/provision.d.ts.map +1 -0
  177. package/dist/instance/provision.js +139 -0
  178. package/dist/instance/provision.js.map +1 -0
  179. package/dist/instance/zero-store.d.ts +47 -0
  180. package/dist/instance/zero-store.d.ts.map +1 -0
  181. package/dist/instance/zero-store.js +91 -0
  182. package/dist/instance/zero-store.js.map +1 -0
  183. package/dist/instance/zero-whitelist.d.ts +39 -0
  184. package/dist/instance/zero-whitelist.d.ts.map +1 -0
  185. package/dist/instance/zero-whitelist.js +67 -0
  186. package/dist/instance/zero-whitelist.js.map +1 -0
  187. package/dist/main.d.ts +3 -0
  188. package/dist/main.d.ts.map +1 -0
  189. package/dist/main.js +69 -0
  190. package/dist/main.js.map +1 -0
  191. package/dist/skill/agents-md.d.ts +25 -0
  192. package/dist/skill/agents-md.d.ts.map +1 -0
  193. package/dist/skill/agents-md.js +62 -0
  194. package/dist/skill/agents-md.js.map +1 -0
  195. package/dist/skill/assets.d.ts +38 -0
  196. package/dist/skill/assets.d.ts.map +1 -0
  197. package/dist/skill/assets.js +111 -0
  198. package/dist/skill/assets.js.map +1 -0
  199. package/dist/skill/hint.d.ts +39 -0
  200. package/dist/skill/hint.d.ts.map +1 -0
  201. package/dist/skill/hint.js +109 -0
  202. package/dist/skill/hint.js.map +1 -0
  203. package/dist/skill/hosts.d.ts +45 -0
  204. package/dist/skill/hosts.d.ts.map +1 -0
  205. package/dist/skill/hosts.js +96 -0
  206. package/dist/skill/hosts.js.map +1 -0
  207. package/dist/skill/installer.d.ts +100 -0
  208. package/dist/skill/installer.d.ts.map +1 -0
  209. package/dist/skill/installer.js +241 -0
  210. package/dist/skill/installer.js.map +1 -0
  211. package/dist/skill/ownership.d.ts +35 -0
  212. package/dist/skill/ownership.d.ts.map +1 -0
  213. package/dist/skill/ownership.js +52 -0
  214. package/dist/skill/ownership.js.map +1 -0
  215. package/dist/tenant/bundle.d.ts +66 -0
  216. package/dist/tenant/bundle.d.ts.map +1 -0
  217. package/dist/tenant/bundle.js +132 -0
  218. package/dist/tenant/bundle.js.map +1 -0
  219. package/dist/tenant/guide.d.ts +50 -0
  220. package/dist/tenant/guide.d.ts.map +1 -0
  221. package/dist/tenant/guide.js +152 -0
  222. package/dist/tenant/guide.js.map +1 -0
  223. package/dist/tenant/probe.d.ts +65 -0
  224. package/dist/tenant/probe.d.ts.map +1 -0
  225. package/dist/tenant/probe.js +159 -0
  226. package/dist/tenant/probe.js.map +1 -0
  227. package/dist/tenant/verify.d.ts +37 -0
  228. package/dist/tenant/verify.d.ts.map +1 -0
  229. package/dist/tenant/verify.js +132 -0
  230. package/dist/tenant/verify.js.map +1 -0
  231. package/dist/version.d.ts +29 -0
  232. package/dist/version.d.ts.map +1 -0
  233. package/dist/version.js +65 -0
  234. package/dist/version.js.map +1 -0
  235. package/package.json +63 -3
  236. package/skills/pxbaas-app-spec/SKILL.md +165 -0
  237. package/skills/pxbaas-app-spec/references/deploy-options.md +158 -0
  238. package/skills/pxbaas-app-spec/references/guardrails-pitfalls.md +89 -0
  239. package/skills/pxbaas-app-spec/references/runtime-contract.md +151 -0
  240. package/skills/pxbaas-app-spec/references/ssr-session.md +121 -0
  241. package/skills/pxbaas-app-spec/references/templates-examples.md +311 -0
  242. package/skills/pxbaas-cli/SKILL.md +186 -0
  243. package/skills/pxbaas-cli/references/api-reference.md +68 -0
  244. package/skills/pxbaas-cli/references/app-deploy.md +186 -0
  245. package/skills/pxbaas-cli/references/auth-and-credentials.md +116 -0
  246. package/skills/pxbaas-cli/references/data-postgrest.md +101 -0
  247. package/skills/pxbaas-cli/references/db-and-meta.md +102 -0
  248. package/skills/pxbaas-cli/references/edge-functions.md +71 -0
  249. package/skills/pxbaas-cli/references/multi-tenant-apps.md +138 -0
  250. package/skills/pxbaas-cli/references/provision.md +101 -0
  251. package/skills/pxbaas-cli/references/safety-and-two-phase.md +78 -0
  252. package/skills/pxbaas-cli/references/storage.md +61 -0
  253. package/skills/pxbaas-cli/references/tenant-admin.md +122 -0
  254. package/skills/pxbaas-cli/references/troubleshooting.md +76 -0
  255. package/templates/webapp/api/index.ts +55 -0
  256. package/templates/webapp/csr-bff/README.md +81 -0
  257. package/templates/webapp/csr-bff/backend/index.ts +168 -0
  258. package/templates/webapp/csr-bff/db/rls.json +13 -0
  259. package/templates/webapp/csr-bff/db/schema.sql +49 -0
  260. package/templates/webapp/csr-bff/frontend/dist/admin.html +101 -0
  261. package/templates/webapp/csr-bff/frontend/dist/article.html +44 -0
  262. package/templates/webapp/csr-bff/frontend/dist/blog.js +206 -0
  263. package/templates/webapp/csr-bff/frontend/dist/blogger.html +53 -0
  264. package/templates/webapp/csr-bff/frontend/dist/index.html +70 -0
  265. package/templates/webapp/csr-bff/frontend/dist/login.html +56 -0
  266. package/templates/webapp/csr-bff/frontend/dist/profile.html +59 -0
  267. package/templates/webapp/csr-bff/frontend/dist/signup.html +78 -0
  268. package/templates/webapp/csr-bff/frontend/dist/style.css +27 -0
  269. package/templates/webapp/manifest.json +419 -0
  270. package/templates/webapp/mpa/about.html +29 -0
  271. package/templates/webapp/mpa/app.js +42 -0
  272. package/templates/webapp/mpa/index.html +42 -0
  273. package/templates/webapp/mpa/index.ts +60 -0
  274. package/templates/webapp/mpa/style.css +42 -0
  275. package/templates/webapp/react-vite/frontend/index.html +12 -0
  276. package/templates/webapp/react-vite/frontend/package-lock.json +1826 -0
  277. package/templates/webapp/react-vite/frontend/package.json +23 -0
  278. package/templates/webapp/react-vite/frontend/src/App.tsx +34 -0
  279. package/templates/webapp/react-vite/frontend/src/main.tsx +13 -0
  280. package/templates/webapp/react-vite/frontend/src/style.css +46 -0
  281. package/templates/webapp/react-vite/frontend/tsconfig.json +20 -0
  282. package/templates/webapp/react-vite/frontend/vite.config.ts +10 -0
  283. package/templates/webapp/react-vite-hono/backend/index.ts +38 -0
  284. package/templates/webapp/react-vite-hono/frontend/index.html +12 -0
  285. package/templates/webapp/react-vite-hono/frontend/package-lock.json +1826 -0
  286. package/templates/webapp/react-vite-hono/frontend/package.json +23 -0
  287. package/templates/webapp/react-vite-hono/frontend/src/App.tsx +47 -0
  288. package/templates/webapp/react-vite-hono/frontend/src/main.tsx +13 -0
  289. package/templates/webapp/react-vite-hono/frontend/src/style.css +66 -0
  290. package/templates/webapp/react-vite-hono/frontend/tsconfig.json +20 -0
  291. package/templates/webapp/react-vite-hono/frontend/vite.config.ts +10 -0
  292. package/templates/webapp/spa/index.ts +119 -0
  293. package/templates/webapp/ssr-auth/backend/index.ts +133 -0
  294. package/templates/webapp/ssr-auth/frontend/dist/index.html +15 -0
  295. package/templates/webapp/ssr-blog/README.md +111 -0
  296. package/templates/webapp/ssr-blog/backend/index.ts +764 -0
  297. package/templates/webapp/ssr-blog/db/rls.json +13 -0
  298. package/templates/webapp/ssr-blog/db/schema.sql +51 -0
  299. package/templates/webapp/ssr-blog/frontend/dist/index.html +39 -0
  300. package/templates/webapp/ssr-frameworks/README.md +64 -0
  301. package/templates/webapp/ssr-frameworks/backend/index.ts +241 -0
  302. package/templates/webapp/ssr-frameworks/frontend/dist/index.html +31 -0
  303. package/templates/webapp/ssr-hono/backend/index.ts +111 -0
  304. package/templates/webapp/ssr-hono/frontend/dist/index.html +12 -0
  305. package/templates/webapp/ssr-shop/README.md +80 -0
  306. package/templates/webapp/ssr-shop/backend/index.ts +491 -0
  307. package/templates/webapp/ssr-shop/db/rls.json +16 -0
  308. package/templates/webapp/ssr-shop/db/schema.sql +90 -0
  309. package/templates/webapp/ssr-shop/frontend/dist/index.html +12 -0
  310. package/templates/webapp/vanilla-static/frontend/dist/app.js +6 -0
  311. package/templates/webapp/vanilla-static/frontend/dist/index.html +19 -0
  312. package/templates/webapp/vanilla-static/frontend/dist/style.css +43 -0
@@ -0,0 +1,1907 @@
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 { checkQuota, FALLBACK_QUOTA_LIMIT, isPublicEndpoint, quotaFindings, runPreflight, } from "../../app/preflight.js";
11
+ import { forgetLocalMount, lookupLocalMount, recordLocalMount, } from "../../app/local-index.js";
12
+ import { lintAppSource } from "../../app/spec-lint.js";
13
+ import { applySchema, getSchemaSink, } from "../../db/apply-schema.js";
14
+ import { parseRlsJson, extractCreateTableNames } from "../../db/sql-file.js";
15
+ const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
16
+ /**
17
+ * `pxbaas app` — scaffold, deploy, publish, verify, and share legacy Edge Function
18
+ * demos or schemaVersion 2 Webapps. Webapp deploys add capability gating, artifact
19
+ * linting, immutable candidates, and generation-CAS lifecycle operations.
20
+ */
21
+ export function registerAppCommands(program) {
22
+ const appCmd = program
23
+ .command("app")
24
+ .description("Scaffold, deploy, publish, and manage BaaS apps");
25
+ // Build the --template help from the bundled manifest so it never drifts from
26
+ // the real template set (D6). Falls back to a hint when assets are unavailable.
27
+ const templateIds = listTemplateIdsSync();
28
+ const templateHelp = templateIds.length
29
+ ? `Template id (${templateIds.join("|")})`
30
+ : "Template id (run `pxbaas app templates` for the full list)";
31
+ appCmd
32
+ .command("new <name>")
33
+ .description("Scaffold a legacy Edge Function app or schemaVersion 2 Webapp")
34
+ .option("--template <id>", templateHelp, "spa")
35
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
36
+ .option("--slug <slug>", "Function slug / URL path segment (default: <name>)")
37
+ .option("--force", "Overwrite an existing scaffold")
38
+ .option("--json", "Output in JSON format")
39
+ .action(async (name, options) => {
40
+ try {
41
+ await handleNew(name, options, program);
42
+ }
43
+ catch (e) {
44
+ fail(e, options.json);
45
+ }
46
+ });
47
+ appCmd
48
+ .command("deploy <name>")
49
+ .description("Deploy a legacy app or upload and publish a schemaVersion 2 Webapp")
50
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
51
+ .option("--slug <slug>", "Override function slug")
52
+ .option("--entrypoint <path>", "Override entrypoint file")
53
+ .option("--frontend <directory>", "Prebuilt frontend artifact directory for mode=webapp")
54
+ .option("--backend <directory>", "Backend source directory for mode=webapp")
55
+ .option("--backend-framework <framework>", "Backend framework (deno|hono)")
56
+ .option("--verify-jwt", "Require JWT (safer, but the URL is no longer publicly openable)")
57
+ .option("--dry-run", "Run preflight + spec review and show the plan without deploying")
58
+ .option("--skip-spec-check", "Skip the pxbaas-app-spec compliance lint (not recommended)")
59
+ .option("--quota-limit <n>", "Override the Edge Function quota limit for the preflight check (authoritative). Defaults to the instance-reported limit.")
60
+ .option("--json", "Output in JSON format")
61
+ .action(async (name, options) => {
62
+ try {
63
+ await handleDeploy(name, options, program);
64
+ }
65
+ catch (e) {
66
+ fail(e, options.json);
67
+ }
68
+ });
69
+ appCmd
70
+ .command("capabilities")
71
+ .description("Show App delivery capabilities reported by the active instance")
72
+ .option("--json", "Output in JSON format")
73
+ .action(async (options) => {
74
+ try {
75
+ await handleCapabilities(options, program);
76
+ }
77
+ catch (e) {
78
+ fail(e, isJson(options, program));
79
+ }
80
+ });
81
+ for (const action of ["publish", "rollback", "unpublish"]) {
82
+ appCmd
83
+ .command(`${action} <name>`)
84
+ .description(`${action[0].toUpperCase()}${action.slice(1)} a mode=webapp App mount`)
85
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
86
+ .option("--json", "Output in JSON format")
87
+ .action(async (name, options) => {
88
+ try {
89
+ await handleLifecycle(action, name, options, program);
90
+ }
91
+ catch (e) {
92
+ fail(e, isJson(options, program));
93
+ }
94
+ });
95
+ }
96
+ appCmd
97
+ .command("verify <name>")
98
+ .description("Verify the deployed webapp (cold-start retry + content-type + API ping)")
99
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
100
+ .option("--slug <slug>", "Override function slug")
101
+ .option("--timeout <seconds>", "Max seconds to wait through cold start", "60")
102
+ .option("--json", "Output in JSON format")
103
+ .action(async (name, options) => {
104
+ try {
105
+ await handleVerify(name, options, program);
106
+ }
107
+ catch (e) {
108
+ fail(e, options.json);
109
+ }
110
+ });
111
+ appCmd
112
+ .command("url <name>")
113
+ .description("Print the shareable URL for a deployed webapp")
114
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
115
+ .option("--slug <slug>", "Override function slug")
116
+ .option("--mount-id <mountId>", "Query this server-side mount directly (works when app.json lost its deployment state)")
117
+ .option("--json", "Output in JSON format")
118
+ .action(async (name, options) => {
119
+ try {
120
+ await handleUrl(name, options, program);
121
+ }
122
+ catch (e) {
123
+ fail(e, options.json);
124
+ }
125
+ });
126
+ appCmd
127
+ .command("adopt <name>")
128
+ .description("Adopt an existing server-side App mount into the local app.json deployment state (recovery for a lost or stale mountId)")
129
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
130
+ .requiredOption("--mount-id <mountId>", "Server-side mount id (m-...) to adopt; take it from the last successful deploy output or `pxbaas app url <name> --json`")
131
+ .option("--generation <n>", "Generation to record for the CAS lifecycle ops (default: the server-reported value)")
132
+ .option("--json", "Output in JSON format")
133
+ .action(async (name, options) => {
134
+ try {
135
+ await handleAdopt(name, options, program);
136
+ }
137
+ catch (e) {
138
+ fail(e, isJson(options, program));
139
+ }
140
+ });
141
+ appCmd
142
+ .command("list")
143
+ .description("List deployed edge functions (apps)")
144
+ .option("--json", "Output in JSON format")
145
+ .action(async (options) => {
146
+ try {
147
+ await handleList(options, program);
148
+ }
149
+ catch (e) {
150
+ fail(e, options.json);
151
+ }
152
+ });
153
+ appCmd
154
+ .command("remove <name>")
155
+ .description("Remove a deployed webapp function (destructive; needs --confirm)")
156
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
157
+ .option("--slug <slug>", "Override function slug")
158
+ .option("--confirm", "Actually delete (required)")
159
+ .option("--json", "Output in JSON format")
160
+ .action(async (name, options) => {
161
+ try {
162
+ await handleRemove(name, options, program);
163
+ }
164
+ catch (e) {
165
+ fail(e, options.json);
166
+ }
167
+ });
168
+ appCmd
169
+ .command("destroy <name>")
170
+ .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.")
171
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
172
+ .option("--confirm", "Actually destroy (required)")
173
+ .option("--mount-id <mountId>", "Destroy this server-side mount even when app.json has no (or a stale) deployment state")
174
+ .option("--purge-db", "Also drop the app's DB tables and remove its RLS registry rows (destructive)")
175
+ .option("--schema <schema>", "Single-tenant schema the app's tables live in (used with --purge-db)", "public")
176
+ .option("--json", "Output in JSON format")
177
+ .action(async (name, options) => {
178
+ try {
179
+ await handleDestroy(name, options, program);
180
+ }
181
+ catch (e) {
182
+ fail(e, isJson(options, program));
183
+ }
184
+ });
185
+ appCmd
186
+ .command("templates")
187
+ .description("List available webapp templates")
188
+ .option("--json", "Output in JSON format")
189
+ .action(async (options) => {
190
+ try {
191
+ const templates = await listTemplates();
192
+ if (isJson(options, program))
193
+ console.log(JSON.stringify({ templates }, null, 2));
194
+ else
195
+ printGroupedTemplates(templates);
196
+ }
197
+ catch (e) {
198
+ fail(e, options.json);
199
+ }
200
+ });
201
+ appCmd
202
+ .command("db-init <name>")
203
+ .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.")
204
+ .option("--dir <directory>", "Parent directory for the app", "./apps")
205
+ .option("--schema <schema>", "Target schema for the __SCHEMA__ placeholder (single-tenant only)", "public")
206
+ .option("--dry-run", "Show the statements without executing them")
207
+ .option("--yes", "Authorize non-interactively (skip the confirm prompt)")
208
+ .option("--json", "Output in JSON format")
209
+ .action(async (name, options) => {
210
+ try {
211
+ await handleDbInit(name, options, program);
212
+ }
213
+ catch (e) {
214
+ fail(e, isJson(options, program));
215
+ }
216
+ });
217
+ registerShareCommands(appCmd, program);
218
+ }
219
+ function isJson(options, program) {
220
+ return Boolean(options.json || program.opts().json);
221
+ }
222
+ /**
223
+ * Recovery guidance for the local-state/server-state desync errors.
224
+ *
225
+ * These three used to dead-end the operator: the app is still live on the
226
+ * instance, but with no usable mountId the CLI can neither publish nor destroy
227
+ * it, and the bare server message ("App mount not found") gives no way out. The
228
+ * hints are printed separately instead of being folded into `message` so the
229
+ * server-owned `{code,message}` payload stays untouched for `--json` consumers.
230
+ */
231
+ const RECOVERY_HINTS = {
232
+ APP_MOUNT_NOT_FOUND: [
233
+ " The mountId recorded locally does not exist on this instance (reinstalled instance, or the mount was cleaned up elsewhere). Do not keep re-running deploy.",
234
+ " Get the real mountId from the last successful deploy output, `pxbaas app url <name> --json`, or `.pxbaas/apps.json`, then:",
235
+ " pxbaas app url <name> --mount-id <m-...> # is the app still live?",
236
+ " pxbaas app adopt <name> --mount-id <m-...> [--generation <n>] # take it over; deploy/publish/rollback/destroy work again",
237
+ " pxbaas app destroy <name> --confirm --mount-id <m-...> # or reclaim it",
238
+ " Only when the server truly has no such App: delete the `deployment` block from app.json and re-run `pxbaas app deploy <name>`.",
239
+ ].join("\n"),
240
+ APP_MOUNT_CONFLICT: [
241
+ " The server already has an App with this name but app.json holds no deployment state, so this working copy lost track of a live mount. Re-running deploy will keep failing, and `app destroy <name>` alone cannot help either.",
242
+ " Get the real mountId from the last successful deploy output, `pxbaas app url <name> --json`, or `.pxbaas/apps.json`, then either:",
243
+ " pxbaas app adopt <name> --mount-id <m-...> # take the existing mount over, then deploy again",
244
+ " pxbaas app destroy <name> --confirm --mount-id <m-...> # reclaim it; the next deploy creates a fresh mount",
245
+ ].join("\n"),
246
+ APP_DEPLOYMENT_NOT_FOUND: [
247
+ " No local deployment state, so the CLI has no mountId to act on. Recover the mount instead of re-deploying (a re-deploy will hit APP_MOUNT_CONFLICT and orphan the live app):",
248
+ " pxbaas app destroy <name> --confirm --mount-id <m-...> # destroy without any local state",
249
+ " pxbaas app adopt <name> --mount-id <m-...> # write the server mount back into app.json",
250
+ " pxbaas app url <name> --mount-id <m-...> # just check whether it is still live",
251
+ ].join("\n"),
252
+ };
253
+ function fail(error, json) {
254
+ const message = error instanceof Error ? error.message : String(error);
255
+ const code = error instanceof AppsApiError ? error.code : "CLI_ERROR";
256
+ if (json)
257
+ console.log(JSON.stringify({ code, message }, null, 2));
258
+ else
259
+ console.error(formatError(`${code}: ${message}`));
260
+ const hint = RECOVERY_HINTS[code];
261
+ if (hint)
262
+ console.error(hint);
263
+ process.exit(1);
264
+ }
265
+ /** Surface a best-effort side effect (the workspace index) without failing. */
266
+ function warnIfFailed(message) {
267
+ if (message)
268
+ console.error(formatWarning(message));
269
+ }
270
+ function formatFinding(f) {
271
+ return `[${f.rule}] ${f.message} (see ${f.specRef})`;
272
+ }
273
+ /** Compact capability tag so templates can be picked by metadata, not by id. */
274
+ function templateMetaTag(t) {
275
+ const m = t.meta;
276
+ if (!m)
277
+ return "";
278
+ const parts = [m.render, m.frontend, m.backend];
279
+ if (m.session !== "none")
280
+ parts.push(`session=${m.session}`);
281
+ if (m.hasDb)
282
+ parts.push("db");
283
+ if (m.coldStart !== "light")
284
+ parts.push(`cold=${m.coldStart}`);
285
+ return `[${parts.join(" ")}]`;
286
+ }
287
+ /** Group templates by delivery mode and purpose; ids stay unchanged. */
288
+ function printGroupedTemplates(templates) {
289
+ const groups = [
290
+ {
291
+ title: "Legacy Edge Function demos (single worker at /functions/v1/<slug>):",
292
+ match: (t) => t.meta?.delivery === "legacy",
293
+ },
294
+ {
295
+ title: "Webapp starters and validation (schemaVersion 2 at /apps/<mount>):",
296
+ match: (t) => t.meta?.delivery === "webapp" &&
297
+ (t.meta.purpose === "starter" || t.meta.purpose === "validation"),
298
+ },
299
+ {
300
+ title: "Webapp reference apps (session contract and/or db/schema.sql):",
301
+ match: (t) => t.meta?.delivery === "webapp" && t.meta.purpose === "reference",
302
+ },
303
+ {
304
+ title: "Webapp showcases:",
305
+ match: (t) => t.meta?.delivery === "webapp" && t.meta.purpose === "showcase",
306
+ },
307
+ ];
308
+ for (const g of groups) {
309
+ const rows = templates.filter(g.match);
310
+ if (rows.length === 0)
311
+ continue;
312
+ console.log(g.title);
313
+ for (const t of rows)
314
+ console.log(` ${t.id.padEnd(16)} ${templateMetaTag(t).padEnd(48)} ${t.description}`);
315
+ console.log();
316
+ }
317
+ for (const t of templates.filter((x) => !groups.some((g) => g.match(x))))
318
+ console.log(` ${t.id}\t${t.description}`);
319
+ }
320
+ function validateSlug(slug) {
321
+ if (!SLUG_RE.test(slug) || slug.length > 128) {
322
+ throw new Error(`Invalid slug '${slug}'. Must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ and be <=128 chars.`);
323
+ }
324
+ }
325
+ async function readManifest(dir, name) {
326
+ const p = join(dir, name, "app.json");
327
+ if (!existsSync(p))
328
+ return {};
329
+ try {
330
+ return JSON.parse(await readFile(p, "utf8"));
331
+ }
332
+ catch {
333
+ return {};
334
+ }
335
+ }
336
+ async function collectFiles(appDir, baseDir = appDir) {
337
+ const out = [];
338
+ const entries = await readdir(appDir, { withFileTypes: true });
339
+ for (const e of entries) {
340
+ // NOTE: app.json IS collected on purpose — it carries the deploy `mode`
341
+ // (spa|api|mpa) that the proxy static branch reads back from
342
+ // edge_function_files to decide whether to serve real static assets.
343
+ if (e.name.startsWith(".") || e.name === "node_modules")
344
+ continue;
345
+ const full = join(appDir, e.name);
346
+ if (e.isDirectory()) {
347
+ // Recurse so nested assets (assets/style.css, pages/about.html) keep their
348
+ // directory structure; relativePath is posix-normalized for the multipart filename.
349
+ out.push(...(await collectFiles(full, baseDir)));
350
+ }
351
+ else if (e.isFile()) {
352
+ out.push({
353
+ path: full,
354
+ relativePath: relative(baseDir, full).split("\\").join("/"),
355
+ });
356
+ }
357
+ }
358
+ return out;
359
+ }
360
+ async function getDriver() {
361
+ const profileManager = new ProfileManager();
362
+ const currentProfile = await profileManager.getCurrentProfile();
363
+ if (!currentProfile) {
364
+ throw new Error("No active profile. Run `pxbaas provision` (free Zero instance) or `pxbaas login` first.");
365
+ }
366
+ const credentials = await new CredentialStore().getCredentials(currentProfile.name);
367
+ if (!credentials) {
368
+ throw new Error(`No credentials for profile '${currentProfile.name}'. Run \`pxbaas provision\` or \`pxbaas login\`.`);
369
+ }
370
+ const driver = new FunctionsDriver({
371
+ endpoint: currentProfile.endpoint,
372
+ serviceRoleKey: credentials.serviceRoleKey,
373
+ tenantId: currentProfile.tenantId,
374
+ });
375
+ const shareDriver = new ShareLinksDriver({
376
+ endpoint: currentProfile.endpoint,
377
+ serviceRoleKey: credentials.serviceRoleKey,
378
+ });
379
+ const appsDriver = new AppsDriver({
380
+ endpoint: currentProfile.endpoint,
381
+ serviceRoleKey: credentials.serviceRoleKey,
382
+ });
383
+ return {
384
+ driver,
385
+ shareDriver,
386
+ appsDriver,
387
+ endpoint: currentProfile.endpoint,
388
+ tenantId: currentProfile.tenantId,
389
+ anonKey: credentials.anonKey,
390
+ };
391
+ }
392
+ /**
393
+ * Resolve the effective Edge Function quota limit and its source for preflight.
394
+ * Precedence: --quota-limit (flag) > instance capabilities.maxFunctions
395
+ * (platform) > client fallback (advisory, non-blocking). Never throws — an
396
+ * unavailable capabilities read degrades to the fallback so a quota lookup can
397
+ * never block a deploy; the server-side DB trigger remains the authoritative
398
+ * gate. (gomoku retrospective 弯路5)
399
+ */
400
+ async function resolveQuotaLimit(appsDriver, flagValue) {
401
+ const parsed = flagValue != null ? Number(flagValue) : NaN;
402
+ if (Number.isFinite(parsed) && parsed > 0) {
403
+ return { quotaLimit: Math.floor(parsed), quotaLimitSource: "flag" };
404
+ }
405
+ let quotaLimit;
406
+ let quotaLimitSource = "fallback";
407
+ try {
408
+ const caps = await appsDriver.capabilities();
409
+ const max = caps?.maxFunctions;
410
+ if (typeof max === "number" && Number.isFinite(max) && max > 0) {
411
+ quotaLimit = max;
412
+ quotaLimitSource = "platform";
413
+ }
414
+ }
415
+ catch {
416
+ // fall through to the advisory client fallback
417
+ }
418
+ return {
419
+ quotaLimit,
420
+ quotaLimitSource,
421
+ // The flag is documented as authoritative, so a value the CLI cannot honor
422
+ // must never be dropped without a word (缺陷3 Q2).
423
+ ...(flagValue != null
424
+ ? {
425
+ flagWarning: `--quota-limit '${flagValue}' was NOT consumed: it must be a positive integer. ` +
426
+ `This run fell back to the ${quotaLimitSource} limit (${quotaLimit ?? FALLBACK_QUOTA_LIMIT}). ` +
427
+ `Re-run with e.g. --quota-limit 500 to make the quota check authoritative.`,
428
+ }
429
+ : {}),
430
+ };
431
+ }
432
+ /**
433
+ * Edge Function count for the quota gate. A failing `list` degrades to 0 rather
434
+ * than hard-failing: preflight must never be the reason a deploy breaks, and an
435
+ * unreadable count only weakens an advisory check (弯路5).
436
+ */
437
+ async function safeFunctionsCount(driver) {
438
+ try {
439
+ return (await driver.list()).length;
440
+ }
441
+ catch {
442
+ return 0;
443
+ }
444
+ }
445
+ /**
446
+ * Resolve the deployment state a lifecycle command should act on: `app.json`
447
+ * first, then the workspace index (缺陷5 A4).
448
+ *
449
+ * Without the index fallback, a manifest that lost its `deployment` block leaves
450
+ * a live mount unreachable — deploy answers APP_MOUNT_CONFLICT and destroy
451
+ * answers APP_DEPLOYMENT_NOT_FOUND. An index entry whose mount is gone
452
+ * server-side is dropped on the spot so the caller can create a fresh one.
453
+ * Returns undefined when neither source knows the app.
454
+ */
455
+ async function resolveDeployment(appsDriver, manifest, dir, name, scope) {
456
+ if (manifest.deployment) {
457
+ return { ...manifest.deployment, source: "manifest" };
458
+ }
459
+ const found = await lookupLocalMount(dir, name, scope);
460
+ if (found.ignored)
461
+ console.error(formatWarning(found.ignored));
462
+ if (!found.entry)
463
+ return undefined;
464
+ try {
465
+ const mount = await appsDriver.get(found.entry.mountId);
466
+ console.error(formatWarning(`↻ Recovered the deployment state for '${name}' from ${found.path} ` +
467
+ `(mount ${mount.mountId}, generation ${mount.generation}); app.json has none. ` +
468
+ `Run \`pxbaas app adopt ${name} --mount-id ${mount.mountId}\` to write it back permanently.`));
469
+ return {
470
+ mountId: mount.mountId,
471
+ generation: mount.generation,
472
+ source: "index",
473
+ mount,
474
+ };
475
+ }
476
+ catch (e) {
477
+ if (!(e instanceof AppsApiError) || e.code !== "APP_MOUNT_NOT_FOUND")
478
+ throw e;
479
+ warnIfFailed(await forgetLocalMount(dir, name, found.entry.mountId));
480
+ console.error(formatWarning(`Dropping the ${found.path} entry for '${name}': mount ${found.entry.mountId} no longer exists server-side.`));
481
+ return undefined;
482
+ }
483
+ }
484
+ /**
485
+ * `pxbaas app db-init <name>` — apply the app's bundled db/schema.sql (plus an
486
+ * optional declarative db/rls.json) so a freshly published app has its tables +
487
+ * RLS + seed. Mode-aware: single-tenant runs through /pg/query (global admin);
488
+ * multi-tenant runs through /api/tenant-ddl with the per-tenant service_role key
489
+ * (DDL on the tenant's own db, RLS via the structured central-registry endpoints).
490
+ * Delegates to the shared apply engine (show plan -> authorize -> run -> stop on
491
+ * first error, no rollback).
492
+ */
493
+ async function handleDbInit(name, options, program) {
494
+ const appDir = join(options.dir, name);
495
+ const schemaFile = join(appDir, "db", "schema.sql");
496
+ if (!existsSync(schemaFile)) {
497
+ throw new AppsApiError("APP_DB_SCHEMA_NOT_FOUND", `No db/schema.sql found for app '${name}' at ${schemaFile}. ` +
498
+ "This app may not need a database, or --dir is wrong.");
499
+ }
500
+ const raw = await readFile(schemaFile, "utf8");
501
+ const json = isJson(options, program);
502
+ // Preferred declarative RLS source (db/rls.json); absent -> the engine falls
503
+ // back to parsing RLS INSERTs embedded in schema.sql (multi-tenant) or runs
504
+ // them verbatim (single-tenant).
505
+ const rlsFile = join(appDir, "db", "rls.json");
506
+ let rls = null;
507
+ if (existsSync(rlsFile)) {
508
+ rls = parseRlsJson(await readFile(rlsFile, "utf8"));
509
+ }
510
+ // Mode-aware sink: single-tenant /pg/query, or multi-tenant /api/tenant-ddl.
511
+ const sink = await getSchemaSink("service_role");
512
+ const result = await applySchema({
513
+ rawSql: raw,
514
+ source: schemaFile,
515
+ rls,
516
+ schema: options.schema,
517
+ dryRun: Boolean(options.dryRun),
518
+ assumeYes: Boolean(options.yes),
519
+ json,
520
+ }, sink);
521
+ if (json) {
522
+ console.log(JSON.stringify({ app: name, ...result }, null, 2));
523
+ }
524
+ if (result.failed > 0)
525
+ process.exit(1);
526
+ }
527
+ function shareUrl(endpoint, slug) {
528
+ return `${endpoint.replace(/\/$/, "")}/functions/v1/${slug}`;
529
+ }
530
+ async function handleNew(name, options, program) {
531
+ const slug = options.slug ?? name;
532
+ validateSlug(slug);
533
+ const result = await scaffoldApp({
534
+ name,
535
+ slug,
536
+ template: options.template,
537
+ dir: options.dir,
538
+ force: options.force,
539
+ });
540
+ if (isJson(options, program)) {
541
+ console.log(JSON.stringify({
542
+ status: "scaffolded",
543
+ app: name,
544
+ slug,
545
+ appDir: result.appDir,
546
+ files: result.files,
547
+ template: options.template,
548
+ spec: "pxbaas-app-spec",
549
+ nextSteps: result.nextSteps,
550
+ note: "Follow the pxbaas-app-spec skill; `pxbaas app deploy` lints the code against it automatically.",
551
+ }, null, 2));
552
+ return;
553
+ }
554
+ console.log(formatSuccess(`Scaffolded webapp '${name}' (template: ${options.template}) at ${result.appDir}`));
555
+ console.log(` Files: ${result.files.join(", ")}`);
556
+ console.log(` Spec: follow the 'pxbaas-app-spec' skill — \`pxbaas app deploy\` lints against it automatically.`);
557
+ console.log(` Next: edit ${join(result.appDir, result.manifest.entrypoint)}, then \`pxbaas app deploy ${name}\`.`);
558
+ for (const step of result.nextSteps) {
559
+ console.log(` TODO: ${step}`);
560
+ }
561
+ }
562
+ async function handleCapabilities(options, program) {
563
+ const { appsDriver } = await getDriver();
564
+ const capabilities = await appsDriver.capabilities();
565
+ const exposure = appExposure(capabilities);
566
+ const output = { status: "ok", exposure, capabilities };
567
+ if (isJson(options, program)) {
568
+ console.log(JSON.stringify(output, null, 2));
569
+ return;
570
+ }
571
+ console.log(`App delivery: ${capabilities.schemaVersions.includes(2) ? "available" : "unavailable"}`);
572
+ console.log(` Exposure: ${exposure}`);
573
+ console.log(` Endpoint: ${capabilities.appEndpoint ?? "-"}`);
574
+ console.log(` Static: ${capabilities.publicStatic ? "enabled" : "disabled"}`);
575
+ console.log(` Deno: ${capabilities.nativeDeno ? "enabled" : "disabled"}`);
576
+ console.log(` Hono: ${capabilities.honoVersion ?? "disabled"}`);
577
+ console.log(` SSR libs: ${(capabilities.ssrLibs ?? []).join(", ") || "none"}`);
578
+ console.log(` Session: ${capabilities.sessionMode && capabilities.sessionMode !== "none" ? capabilities.sessionMode : "disabled"}`);
579
+ }
580
+ async function handleLifecycle(action, name, options, program) {
581
+ const manifest = await requireWebappManifest(options.dir, name);
582
+ const { appsDriver, endpoint, tenantId } = await getDriver();
583
+ const deployment = await resolveDeployment(appsDriver, manifest, options.dir, name, { endpoint, tenantId });
584
+ if (!deployment)
585
+ throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
586
+ const capabilities = await appsDriver.capabilities();
587
+ assertWebappCapabilities(capabilities, manifest.backend ?? null);
588
+ let mount;
589
+ if (action === "publish") {
590
+ if (!deployment.candidateFunctionSlug) {
591
+ throw new AppsApiError("APP_CANDIDATE_NOT_FOUND", `App '${name}' has no candidate to publish`);
592
+ }
593
+ mount = await appsDriver.publish(deployment.mountId, {
594
+ candidateFunctionSlug: deployment.candidateFunctionSlug,
595
+ expectedGeneration: deployment.generation,
596
+ });
597
+ }
598
+ else {
599
+ mount = await appsDriver[action](deployment.mountId, {
600
+ expectedGeneration: deployment.generation,
601
+ });
602
+ }
603
+ manifest.deployment = {
604
+ mountId: mount.mountId,
605
+ generation: mount.generation,
606
+ ...(action === "publish"
607
+ ? {}
608
+ : { candidateFunctionSlug: deployment.candidateFunctionSlug }),
609
+ };
610
+ await writeManifest(options.dir, name, manifest);
611
+ warnIfFailed(await recordLocalMount(options.dir, name, { mountId: mount.mountId, generation: mount.generation }, { endpoint, tenantId }));
612
+ emitWebappResult(action === "publish"
613
+ ? "published"
614
+ : action === "rollback"
615
+ ? "rolled-back"
616
+ : "unpublished", name, mount, capabilities, options, program);
617
+ }
618
+ async function handleDeploy(name, options, program) {
619
+ const json = isJson(options, program);
620
+ const manifest = await readManifest(options.dir, name);
621
+ if (manifest.mode === "webapp") {
622
+ await handleWebappDeploy(name, manifest, options, program);
623
+ return;
624
+ }
625
+ const slug = options.slug ?? manifest.slug ?? name;
626
+ validateSlug(slug);
627
+ const entrypoint = options.entrypoint ?? manifest.entrypoint ?? "index.ts";
628
+ const verifyJwt = options.verifyJwt ?? manifest.verifyJwt ?? false;
629
+ const appDir = join(options.dir, name);
630
+ const entryPath = join(appDir, entrypoint);
631
+ const entrypointExists = existsSync(entryPath);
632
+ const source = entrypointExists ? await readFile(entryPath, "utf8") : "";
633
+ // Deploy mode (spa|api|mpa). The proxy enables the static-asset branch only
634
+ // for mpa; app.json is uploaded so the proxy can read this back.
635
+ const mode = manifest.mode ?? manifest.template ?? "spa";
636
+ const files = await collectFiles(appDir);
637
+ const existingFiles = files.map((f) => f.relativePath);
638
+ // For mpa, the pages/assets are separate files — scan them too for root-absolute
639
+ // refs (the entrypoint alone no longer contains the markup).
640
+ const auxiliarySources = [];
641
+ if (mode === "mpa") {
642
+ for (const f of files) {
643
+ const rel = f.relativePath;
644
+ if (rel === entrypoint || rel === "app.json")
645
+ continue;
646
+ if (/\.(html?|css|js|mjs)$/i.test(rel))
647
+ auxiliarySources.push(await readFile(f.path, "utf8"));
648
+ }
649
+ }
650
+ const declaredFiles = mode === "mpa"
651
+ ? [...(manifest.pages ?? []), ...(manifest.assets ?? [])]
652
+ : [];
653
+ const { driver, endpoint, appsDriver } = await getDriver();
654
+ const functionsCount = await safeFunctionsCount(driver);
655
+ // Resolve the authoritative Edge Function quota: --quota-limit (flag) wins,
656
+ // else the instance-reported capabilities.maxFunctions (platform), else the
657
+ // client fallback (advisory only). Fixes the hardcoded-5 false block (弯路5).
658
+ const { quotaLimit, quotaLimitSource, flagWarning } = await resolveQuotaLimit(appsDriver, options.quotaLimit);
659
+ const pre = runPreflight({
660
+ source,
661
+ endpoint,
662
+ functionsCount,
663
+ quotaLimit,
664
+ quotaLimitSource,
665
+ verifyJwt,
666
+ entrypointExists,
667
+ });
668
+ if (flagWarning)
669
+ pre.warnings.unshift(flagWarning);
670
+ // Automatic pxbaas-app-spec compliance review (skip with --skip-spec-check).
671
+ const spec = options.skipSpecCheck
672
+ ? { skipped: true, passed: true, blockers: [], warnings: [] }
673
+ : {
674
+ skipped: false,
675
+ ...lintAppSource(source, {
676
+ verifyJwt,
677
+ mode,
678
+ declaredFiles,
679
+ existingFiles,
680
+ auxiliarySources,
681
+ }),
682
+ };
683
+ const allBlockers = [...pre.blockers, ...spec.blockers.map(formatFinding)];
684
+ const allWarnings = [...pre.warnings, ...spec.warnings.map(formatFinding)];
685
+ if (options.dryRun) {
686
+ const out = {
687
+ status: "dry-run",
688
+ app: name,
689
+ slug,
690
+ mode,
691
+ verifyJwt,
692
+ shareUrl: shareUrl(endpoint, slug),
693
+ preflight: pre,
694
+ specLint: spec,
695
+ };
696
+ if (json)
697
+ console.log(JSON.stringify(out, null, 2));
698
+ else {
699
+ console.log(formatWarning(`Dry run for '${name}' -> ${shareUrl(endpoint, slug)}`));
700
+ for (const b of allBlockers)
701
+ console.error(formatError(b));
702
+ for (const w of allWarnings)
703
+ console.log(formatWarning(w));
704
+ if (allBlockers.length === 0)
705
+ console.log(" pxbaas-app-spec review + preflight passed. Re-run without --dry-run to deploy.");
706
+ }
707
+ if (allBlockers.length > 0)
708
+ process.exit(1);
709
+ return;
710
+ }
711
+ if (allBlockers.length > 0) {
712
+ if (json) {
713
+ console.log(JSON.stringify({
714
+ status: "blocked",
715
+ app: name,
716
+ slug,
717
+ preflight: pre,
718
+ specLint: spec,
719
+ }, null, 2));
720
+ }
721
+ else {
722
+ console.error(formatError("pxbaas-app-spec compliance review failed — fix these before deploying:"));
723
+ for (const b of allBlockers)
724
+ console.error(formatError(b));
725
+ }
726
+ process.exit(1);
727
+ }
728
+ const info = await driver.deploy({
729
+ slug,
730
+ entrypointPath: entrypoint,
731
+ verifyJwt,
732
+ files,
733
+ });
734
+ const url = shareUrl(endpoint, slug);
735
+ // deploy is an upsert: version 1 means this call created the function; >1 means it updated an existing one.
736
+ const action = (info?.version ?? 1) === 1 ? "created" : "updated";
737
+ const out = {
738
+ status: "deployed",
739
+ action,
740
+ app: name,
741
+ slug,
742
+ mode,
743
+ verifyJwt,
744
+ shareUrl: url,
745
+ function: info,
746
+ preflight: pre,
747
+ specLint: spec,
748
+ };
749
+ if (json) {
750
+ console.log(JSON.stringify(out, null, 2));
751
+ return;
752
+ }
753
+ 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`));
754
+ for (const w of allWarnings)
755
+ console.log(formatWarning(w));
756
+ console.log(` Share URL: ${url}`);
757
+ if (mode === "mpa") {
758
+ for (const p of manifest.pages ?? [])
759
+ console.log(` Page: ${url.replace(/\/$/, "")}/${p}`);
760
+ }
761
+ console.log(` Verify: pxbaas app verify ${name}`);
762
+ }
763
+ async function handleWebappDeploy(name, manifestInput, options, program) {
764
+ const manifest = requireWebappManifestValue(manifestInput, name);
765
+ const frontend = manifest.frontend;
766
+ if (!frontend)
767
+ throw new AppsApiError("APP_MANIFEST_INVALID", "mode=webapp requires a frontend contract");
768
+ const framework = parseBackendFramework(options.backendFramework ?? manifest.backend?.framework);
769
+ let backend = manifest.backend
770
+ ? { ...manifest.backend }
771
+ : null;
772
+ if ((options.backend || options.backendFramework) && !backend) {
773
+ backend = {
774
+ entrypoint: "backend/index.ts",
775
+ framework: framework ?? "deno",
776
+ pathMode: "strip-app-api-prefix",
777
+ verifyJwt: options.verifyJwt ?? false,
778
+ secrets: [],
779
+ egressPolicy: "deny",
780
+ };
781
+ }
782
+ else if (backend) {
783
+ backend.framework = framework ?? backend.framework;
784
+ backend.verifyJwt = options.verifyJwt ?? backend.verifyJwt;
785
+ }
786
+ const { driver, appsDriver, endpoint, tenantId } = await getDriver();
787
+ const capabilities = await appsDriver.capabilities();
788
+ assertWebappCapabilities(capabilities, backend);
789
+ const appDir = join(options.dir, name);
790
+ // `frontend.root` is both the local artifact directory and the uploaded key
791
+ // prefix (the platform resolves a public path as join(root, asset)), so every
792
+ // template must ship its uploadable files under that same directory.
793
+ const defaultFrontendDir = join(appDir, frontend.root);
794
+ const frontendDir = options.frontend ?? defaultFrontendDir;
795
+ const frontendRaw = await collectStagingFiles(frontendDir, undefined, "Pass --frontend <prebuilt-dir>, or point app.json frontend.root at the directory that holds your build output.");
796
+ const frontendFiles = frontendRaw.map((file) => `${frontend.root}/${file.relativePath}`.replace(/\/+/g, "/"));
797
+ const frontendUpload = frontendRaw
798
+ .filter((file) => !file.relativePath.endsWith("/"))
799
+ .map((file) => ({
800
+ ...file,
801
+ relativePath: `${frontend.root}/${file.relativePath}`.replace(/\/+/g, "/"),
802
+ }));
803
+ const frontendSources = await readTextSources(frontendUpload);
804
+ const frontendProjectDir = join(appDir, "frontend");
805
+ const frontendProjectFiles = existsSync(join(frontendProjectDir, "src"))
806
+ ? await collectStagingFiles(join(frontendProjectDir, "src"))
807
+ : [];
808
+ const frontendProjectSources = (await readTextSources(frontendProjectFiles)).map((file) => file.source);
809
+ const viteConfigPath = join(frontendProjectDir, "vite.config.ts");
810
+ const frontendConfigSource = existsSync(viteConfigPath)
811
+ ? await readFile(viteConfigPath, "utf8")
812
+ : undefined;
813
+ let backendSource = "";
814
+ let backendUpload = [];
815
+ if (backend) {
816
+ const backendRoot = dirname(backend.entrypoint);
817
+ const backendDir = options.backend ?? join(appDir, backendRoot);
818
+ const backendRaw = await collectStagingFiles(backendDir);
819
+ backendUpload = backendRaw
820
+ .filter((file) => !file.relativePath.endsWith("/"))
821
+ .map((file) => ({
822
+ ...file,
823
+ relativePath: `${backendRoot}/${file.relativePath}`.replace(/\/+/g, "/"),
824
+ }));
825
+ const entryFile = backendUpload.find((file) => file.relativePath === backend.entrypoint);
826
+ if (!entryFile) {
827
+ throw new AppsApiError("APP_ARTIFACT_INVALID", `Backend entrypoint not found: ${backend.entrypoint}`);
828
+ }
829
+ backendSource = await readFile(entryFile.path, "utf8");
830
+ if (backend.framework === "hono") {
831
+ backendSource = backendSource.replaceAll(HONO_VERSION_PLACEHOLDER, capabilities.honoVersion);
832
+ entryFile.content = backendSource;
833
+ }
834
+ }
835
+ const artifactManifest = {
836
+ schemaVersion: 2,
837
+ mode: "webapp",
838
+ name,
839
+ frontend,
840
+ backend,
841
+ // Preserve the SSR publish-probe path in the uploaded candidate (R2-01);
842
+ // without it the platform loses the fallback=worker probe contract.
843
+ ...(manifest.ssrProbePath ? { ssrProbePath: manifest.ssrProbePath } : {}),
844
+ };
845
+ const appJson = JSON.stringify(artifactManifest, null, 2) + "\n";
846
+ const uploadFiles = [
847
+ ...frontendUpload,
848
+ ...backendUpload,
849
+ {
850
+ path: join(appDir, "app.json"),
851
+ relativePath: "app.json",
852
+ content: appJson,
853
+ },
854
+ ];
855
+ const spec = options.skipSpecCheck
856
+ ? {
857
+ skipped: true,
858
+ passed: true,
859
+ blockers: [],
860
+ warnings: [],
861
+ }
862
+ : {
863
+ skipped: false,
864
+ ...lintAppSource("", {
865
+ mode: "webapp",
866
+ verifyJwt: backend?.verifyJwt ?? false,
867
+ frontendFiles,
868
+ frontendSources,
869
+ frontendConfigSource,
870
+ frontendProjectSources,
871
+ requireHashRouter: manifest.template?.startsWith("react-") ?? false,
872
+ backendSource,
873
+ backendFramework: backend?.framework,
874
+ honoVersion: capabilities.honoVersion,
875
+ ssrLibs: capabilities.ssrLibs,
876
+ secrets: backend?.secrets,
877
+ egressPolicy: backend?.egressPolicy,
878
+ ssrFallback: manifest.frontend?.fallback === "worker",
879
+ ssrProbePath: manifest.ssrProbePath,
880
+ backendSession: backend?.session,
881
+ sessionMode: capabilities.sessionMode,
882
+ sessionHeaders: capabilities.sessionHeaders,
883
+ }),
884
+ };
885
+ // A dry-run reports every finding instead of aborting on the first one, so the
886
+ // spec blockers are only fatal on the real deploy path (order preserved there:
887
+ // spec review before the artifact size limits).
888
+ if (!spec.passed && !options.dryRun) {
889
+ const details = spec.blockers
890
+ .map((item) => `[${item.rule}] ${item.message}`)
891
+ .join("; ");
892
+ throw new AppsApiError("APP_ARTIFACT_INVALID", details);
893
+ }
894
+ await assertArtifactLimits(uploadFiles, capabilities);
895
+ // Q1: this path used to skip preflight entirely, so --quota-limit was silently
896
+ // ignored on every webapp deploy. Only the quota half of runPreflight is reused:
897
+ // a webapp has no single-file `source`/entrypoint, and inventing those fields
898
+ // just to call runPreflight would fake checks the artifact lint already owns.
899
+ const functionsCount = await safeFunctionsCount(driver);
900
+ const { quotaLimit, quotaLimitSource, flagWarning } = await resolveQuotaLimit(appsDriver, options.quotaLimit);
901
+ const quota = checkQuota(functionsCount, quotaLimit, quotaLimitSource);
902
+ const quotaReport = quotaFindings(quota);
903
+ const preflight = {
904
+ quota,
905
+ quotaLimitSource,
906
+ blockers: quotaReport.blockers,
907
+ warnings: [...(flagWarning ? [flagWarning] : []), ...quotaReport.warnings],
908
+ };
909
+ if (options.dryRun) {
910
+ const output = {
911
+ status: "dry-run",
912
+ app: name,
913
+ mode: "webapp",
914
+ capabilities,
915
+ preflight,
916
+ specLint: spec,
917
+ };
918
+ const allBlockers = [
919
+ ...preflight.blockers,
920
+ ...spec.blockers.map(formatFinding),
921
+ ];
922
+ const allWarnings = [
923
+ ...preflight.warnings,
924
+ ...spec.warnings.map(formatFinding),
925
+ ];
926
+ if (isJson(options, program))
927
+ console.log(JSON.stringify(output, null, 2));
928
+ else {
929
+ // Q2: the human-mode dry-run used to print a single success line and swallow
930
+ // every specLint/preflight warning the JSON payload carried.
931
+ console.log(formatWarning(`Dry run for webapp '${name}'`));
932
+ for (const b of allBlockers)
933
+ console.error(formatError(b));
934
+ for (const w of allWarnings)
935
+ console.log(formatWarning(w));
936
+ if (allBlockers.length === 0)
937
+ console.log(formatSuccess(`Webapp '${name}' passed capability and artifact checks. Re-run without --dry-run to deploy.`));
938
+ }
939
+ if (allBlockers.length > 0)
940
+ process.exit(1);
941
+ return;
942
+ }
943
+ // Q3: a real deploy consumes the quota result too. Only an authoritative limit
944
+ // (flag/platform) blocks; the client fallback stays a warning, so an instance
945
+ // that does not report maxFunctions is never gated by a client-side guess.
946
+ if (preflight.blockers.length > 0) {
947
+ if (isJson(options, program)) {
948
+ console.log(JSON.stringify({
949
+ status: "blocked",
950
+ app: name,
951
+ mode: "webapp",
952
+ preflight,
953
+ specLint: spec,
954
+ }, null, 2));
955
+ }
956
+ else {
957
+ console.error(formatError("Edge Function quota check failed — fix these before deploying:"));
958
+ for (const b of preflight.blockers)
959
+ console.error(formatError(b));
960
+ for (const w of preflight.warnings)
961
+ console.log(formatWarning(w));
962
+ }
963
+ process.exit(1);
964
+ }
965
+ const resolved = await resolveDeployment(appsDriver, manifest, options.dir, name, { endpoint, tenantId });
966
+ // Neither app.json nor the workspace index knows this app -> first deploy, so a
967
+ // fresh mount is created (a name clash surfaces as APP_MOUNT_CONFLICT).
968
+ const mount = resolved
969
+ ? (resolved.mount ?? (await appsDriver.get(resolved.mountId)))
970
+ : await appsDriver.create({ appName: name });
971
+ const candidateFunctionSlug = createCandidateSlug(mount.id);
972
+ const entrypoint = backend?.entrypoint ?? `${frontend.root}/${frontend.index}`;
973
+ await driver.deploy({
974
+ slug: candidateFunctionSlug,
975
+ entrypointPath: entrypoint,
976
+ verifyJwt: backend?.verifyJwt ?? false,
977
+ files: uploadFiles,
978
+ });
979
+ manifest.backend = backend;
980
+ manifest.deployment = {
981
+ mountId: mount.mountId,
982
+ candidateFunctionSlug,
983
+ generation: mount.generation,
984
+ };
985
+ await writeManifest(options.dir, name, manifest);
986
+ // A4: mirror the deployment state into the workspace index as soon as the
987
+ // candidate is uploaded, so a mount is recoverable even if the publish below
988
+ // fails and app.json is later lost.
989
+ warnIfFailed(await recordLocalMount(options.dir, name, { mountId: mount.mountId, generation: mount.generation }, { endpoint, tenantId }));
990
+ const published = await appsDriver.publish(mount.mountId, {
991
+ candidateFunctionSlug,
992
+ expectedGeneration: mount.generation,
993
+ });
994
+ manifest.deployment = {
995
+ mountId: published.mountId,
996
+ generation: published.generation,
997
+ };
998
+ await writeManifest(options.dir, name, manifest);
999
+ warnIfFailed(await recordLocalMount(options.dir, name, { mountId: published.mountId, generation: published.generation }, { endpoint, tenantId }));
1000
+ emitWebappResult("published", name, published, capabilities, options, program, { candidateFunctionSlug, specLint: spec, preflight });
1001
+ // The quota advisories (fallback tier, an unconsumed --quota-limit) must not be
1002
+ // JSON-only: a human-mode deploy that silently ignored the flag is the bug Q2
1003
+ // is about.
1004
+ if (!isJson(options, program))
1005
+ for (const w of preflight.warnings)
1006
+ console.log(formatWarning(w));
1007
+ }
1008
+ function parseBackendFramework(value) {
1009
+ if (value === undefined)
1010
+ return undefined;
1011
+ if (value !== "deno" && value !== "hono") {
1012
+ throw new AppsApiError("APP_MANIFEST_INVALID", "--backend-framework must be deno or hono");
1013
+ }
1014
+ return value;
1015
+ }
1016
+ function assertWebappCapabilities(capabilities, backend) {
1017
+ if (!capabilities.schemaVersions.includes(2) ||
1018
+ !capabilities.appEndpoint ||
1019
+ !capabilities.publicStatic) {
1020
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "Webapp static delivery is not enabled on this instance");
1021
+ }
1022
+ if (backend && !capabilities.nativeDeno) {
1023
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "Native Deno App backends are not enabled on this instance");
1024
+ }
1025
+ if (backend?.framework === "hono" &&
1026
+ (!capabilities.honoVersion || capabilities.honoOfflineReady !== true)) {
1027
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "Hono is not enabled with an offline-ready exact version on this instance");
1028
+ }
1029
+ if (backend &&
1030
+ (backend.secrets.length > 0 || backend.egressPolicy !== "deny")) {
1031
+ throw new AppsApiError("APP_CAPABILITY_UNSUPPORTED", "The first Webapp release requires no Secrets and egressPolicy=deny");
1032
+ }
1033
+ // 方案 C: session contract gate (mirrors Studio validateCandidate).
1034
+ if (backend?.session === "proxy-injected" &&
1035
+ capabilities.sessionMode !== "proxy-injected-header") {
1036
+ 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\".");
1037
+ }
1038
+ }
1039
+ async function collectStagingFiles(root, current = root, hint) {
1040
+ if (!existsSync(root))
1041
+ throw new AppsApiError("APP_ARTIFACT_INVALID", `Artifact directory not found: ${root}. ${hint ?? "Create it (build the frontend) before deploying."}`);
1042
+ const files = [];
1043
+ for (const entry of await readdir(current, { withFileTypes: true })) {
1044
+ const full = join(current, entry.name);
1045
+ const relativePath = relative(root, full).split("\\").join("/");
1046
+ if (entry.isDirectory()) {
1047
+ if (entry.name === "node_modules")
1048
+ files.push({ path: full, relativePath: `${relativePath}/` });
1049
+ else if (entry.name !== ".git")
1050
+ files.push(...(await collectStagingFiles(root, full, hint)));
1051
+ }
1052
+ else if (entry.isFile()) {
1053
+ files.push({ path: full, relativePath });
1054
+ }
1055
+ }
1056
+ return files;
1057
+ }
1058
+ async function readTextSources(files) {
1059
+ const textFiles = files.filter((file) => /\.(?:html?|css|js|mjs|cjs|jsx|ts|tsx|json|svg|txt)$/i.test(file.relativePath));
1060
+ return Promise.all(textFiles.map(async (file) => ({
1061
+ path: file.relativePath,
1062
+ source: await readFile(file.path, "utf8"),
1063
+ })));
1064
+ }
1065
+ async function assertArtifactLimits(files, capabilities) {
1066
+ if (files.length > capabilities.limits.maxFiles) {
1067
+ throw new AppsApiError("APP_LIMIT_EXCEEDED", `Artifact has ${files.length} files; maximum is ${capabilities.limits.maxFiles}`);
1068
+ }
1069
+ let total = 0;
1070
+ for (const file of files) {
1071
+ const bytes = file.content === undefined
1072
+ ? (await stat(file.path)).size
1073
+ : Buffer.byteLength(file.content);
1074
+ if (bytes > capabilities.limits.maxFileBytes) {
1075
+ throw new AppsApiError("APP_LIMIT_EXCEEDED", `${file.relativePath} exceeds ${capabilities.limits.maxFileBytes} bytes`);
1076
+ }
1077
+ total += bytes;
1078
+ }
1079
+ if (total > capabilities.limits.maxBundleBytes) {
1080
+ throw new AppsApiError("APP_LIMIT_EXCEEDED", `Artifact is ${total} bytes; maximum is ${capabilities.limits.maxBundleBytes}`);
1081
+ }
1082
+ }
1083
+ function createCandidateSlug(appId) {
1084
+ const deployId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1085
+ return `app_${appId}_r_${deployId}`;
1086
+ }
1087
+ function appExposure(capabilities) {
1088
+ return capabilities.originMode === "isolated" &&
1089
+ capabilities.hostEnforced === true &&
1090
+ capabilities.publicExposureAllowed === true
1091
+ ? "Public"
1092
+ : "Preview";
1093
+ }
1094
+ function emitWebappResult(status, name, mount, capabilities, options, program, extra = {}) {
1095
+ const exposure = appExposure(capabilities);
1096
+ const output = {
1097
+ status,
1098
+ app: name,
1099
+ mount,
1100
+ exposure,
1101
+ ...(exposure === "Public"
1102
+ ? { publicUrl: mount.url }
1103
+ : { previewUrl: mount.url }),
1104
+ ...extra,
1105
+ };
1106
+ if (isJson(options, program))
1107
+ console.log(JSON.stringify(output, null, 2));
1108
+ else {
1109
+ console.log(formatSuccess(`${status} Webapp '${name}' (generation ${mount.generation}).`));
1110
+ console.log(` ${exposure} URL: ${mount.url}`);
1111
+ }
1112
+ }
1113
+ async function requireWebappManifest(dir, name) {
1114
+ return requireWebappManifestValue(await readManifest(dir, name), name);
1115
+ }
1116
+ function requireWebappManifestValue(value, name) {
1117
+ if (value.mode !== "webapp" || value.schemaVersion !== 2 || !value.frontend) {
1118
+ throw new AppsApiError("APP_MANIFEST_INVALID", `App '${name}' is not a schemaVersion 2 webapp`);
1119
+ }
1120
+ // R2-09: fully validate a declared backend contract instead of trusting the
1121
+ // cast, so a backend missing framework/entrypoint cannot slip through and
1122
+ // silently skip the backend lint.
1123
+ const backend = value.backend;
1124
+ if (backend !== undefined && backend !== null) {
1125
+ const b = backend;
1126
+ if (typeof b.entrypoint !== "string" ||
1127
+ !b.entrypoint.startsWith("backend/") ||
1128
+ (b.framework !== "deno" && b.framework !== "hono") ||
1129
+ b.pathMode !== "strip-app-api-prefix" ||
1130
+ typeof b.verifyJwt !== "boolean" ||
1131
+ !Array.isArray(b.secrets) ||
1132
+ b.egressPolicy !== "deny") {
1133
+ 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"`);
1134
+ }
1135
+ }
1136
+ // R2-01 (defense in depth): fallback="worker" requires a valid ssrProbePath.
1137
+ if (value.frontend.fallback === "worker" &&
1138
+ (typeof value.ssrProbePath !== "string" ||
1139
+ !value.ssrProbePath.startsWith("/"))) {
1140
+ throw new AppsApiError("APP_MANIFEST_INVALID", `App '${name}' declares frontend.fallback="worker" but is missing a valid ssrProbePath`);
1141
+ }
1142
+ return value;
1143
+ }
1144
+ async function writeManifest(dir, name, manifest) {
1145
+ await writeFile(join(dir, name, "app.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
1146
+ }
1147
+ async function handleVerify(name, options, program) {
1148
+ const json = isJson(options, program);
1149
+ const manifest = await readManifest(options.dir, name);
1150
+ if (manifest.mode === "webapp") {
1151
+ await handleWebappVerify(name, requireWebappManifestValue(manifest, name), options, program);
1152
+ return;
1153
+ }
1154
+ const slug = options.slug ?? manifest.slug ?? name;
1155
+ const { endpoint, tenantId, anonKey } = await getDriver();
1156
+ const url = shareUrl(endpoint, slug);
1157
+ const timeoutSec = Number(options.timeout ?? "60");
1158
+ const deadline = Date.now() + timeoutSec * 1000;
1159
+ // Build auth headers for multi-tenant Kong gateway (key-auth is enforced
1160
+ // even when verify_jwt=false on the function itself).
1161
+ const verifyHeaders = {
1162
+ apikey: anonKey,
1163
+ Authorization: `Bearer ${anonKey}`,
1164
+ };
1165
+ if (tenantId) {
1166
+ verifyHeaders["X-Tenant-Id"] = tenantId;
1167
+ }
1168
+ let httpStatus = 0;
1169
+ let contentType = "";
1170
+ let attempts = 0;
1171
+ const startedAt = Date.now();
1172
+ while (Date.now() < deadline) {
1173
+ attempts++;
1174
+ try {
1175
+ const res = await axios.get(url, {
1176
+ headers: verifyHeaders,
1177
+ validateStatus: () => true,
1178
+ timeout: 10000,
1179
+ });
1180
+ httpStatus = res.status;
1181
+ contentType = String(res.headers["content-type"] ?? "");
1182
+ if (httpStatus === 200)
1183
+ break;
1184
+ }
1185
+ catch {
1186
+ httpStatus = 0;
1187
+ }
1188
+ if (!json)
1189
+ process.stderr.write(` attempt#${attempts} HTTP ${httpStatus || "---"} (cold start?), retrying in 5s\n`);
1190
+ await new Promise((r) => setTimeout(r, 5000));
1191
+ }
1192
+ const firstByteMs = Date.now() - startedAt;
1193
+ const coldStart = attempts > 1;
1194
+ // Ping the built-in API (spa uses ?action=time, api uses ?action=health).
1195
+ const apiAction = manifest.template === "api" ? "health" : "time";
1196
+ let apiStatus = 0;
1197
+ try {
1198
+ const r = await axios.get(`${url}?action=${apiAction}`, {
1199
+ headers: verifyHeaders,
1200
+ validateStatus: () => true,
1201
+ timeout: 10000,
1202
+ });
1203
+ apiStatus = r.status;
1204
+ }
1205
+ catch {
1206
+ apiStatus = 0;
1207
+ }
1208
+ // MPA: probe every declared page/asset — each must be 200 with a matching
1209
+ // content-type (served by the proxy static branch, not the worker).
1210
+ const mode = manifest.mode ?? manifest.template ?? "spa";
1211
+ const expectedCtype = (f) => {
1212
+ if (/\.html?$/i.test(f))
1213
+ return "text/html";
1214
+ if (/\.css$/i.test(f))
1215
+ return "text/css";
1216
+ if (/\.m?js$/i.test(f))
1217
+ return "javascript";
1218
+ if (/\.json$/i.test(f))
1219
+ return "application/json";
1220
+ if (/\.svg$/i.test(f))
1221
+ return "image/svg+xml";
1222
+ return "";
1223
+ };
1224
+ const assetChecks = [];
1225
+ if (mode === "mpa") {
1226
+ const base = url.replace(/\/$/, "");
1227
+ for (const f of [...(manifest.pages ?? []), ...(manifest.assets ?? [])]) {
1228
+ let s = 0;
1229
+ let ct = "";
1230
+ try {
1231
+ const r = await axios.get(`${base}/${f}`, {
1232
+ headers: verifyHeaders,
1233
+ validateStatus: () => true,
1234
+ timeout: 10000,
1235
+ });
1236
+ s = r.status;
1237
+ ct = String(r.headers["content-type"] ?? "");
1238
+ }
1239
+ catch {
1240
+ s = 0;
1241
+ }
1242
+ const want = expectedCtype(f);
1243
+ assetChecks.push({
1244
+ path: f,
1245
+ httpStatus: s,
1246
+ contentType: ct,
1247
+ ok: s === 200 && (!want || ct.includes(want)),
1248
+ });
1249
+ }
1250
+ }
1251
+ const assetsOk = assetChecks.every((a) => a.ok);
1252
+ const ok = httpStatus === 200 && assetsOk;
1253
+ const out = {
1254
+ status: ok ? "ok" : "failed",
1255
+ shareUrl: url,
1256
+ mode,
1257
+ httpStatus,
1258
+ contentType,
1259
+ attempts,
1260
+ coldStart,
1261
+ firstByteMs,
1262
+ apiPing: { action: apiAction, httpStatus: apiStatus },
1263
+ assets: assetChecks,
1264
+ };
1265
+ if (json) {
1266
+ console.log(JSON.stringify(out, null, 2));
1267
+ process.exit(ok ? 0 : 1);
1268
+ }
1269
+ if (ok)
1270
+ console.log(formatSuccess(`Webapp reachable: ${url} (HTTP 200, ${contentType}${coldStart ? ", cold start" : ""})`));
1271
+ else
1272
+ console.error(formatError(`Webapp not reachable after ${attempts} attempt(s): last HTTP ${httpStatus || "n/a"} — ${url}`));
1273
+ console.log(` API ping ?action=${apiAction}: HTTP ${apiStatus}`);
1274
+ for (const a of assetChecks) {
1275
+ const line = ` Asset ${a.path}: HTTP ${a.httpStatus || "---"}${a.contentType ? ", " + a.contentType : ""}`;
1276
+ if (a.ok)
1277
+ console.log(line);
1278
+ else
1279
+ console.error(formatError(line + " (expected 200 + matching content-type)"));
1280
+ }
1281
+ process.exit(ok ? 0 : 1);
1282
+ }
1283
+ async function handleWebappVerify(name, manifest, options, program) {
1284
+ if (!manifest.deployment)
1285
+ throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
1286
+ const { appsDriver } = await getDriver();
1287
+ const mount = await appsDriver.get(manifest.deployment.mountId);
1288
+ const timeoutSec = Number(options.timeout ?? "60");
1289
+ const deadline = Date.now() + timeoutSec * 1000;
1290
+ let httpStatus = 0;
1291
+ let contentType = "";
1292
+ let attempts = 0;
1293
+ while (Date.now() < deadline) {
1294
+ attempts++;
1295
+ try {
1296
+ const response = await axios.get(mount.url, {
1297
+ validateStatus: () => true,
1298
+ timeout: 10_000,
1299
+ });
1300
+ httpStatus = response.status;
1301
+ contentType = String(response.headers["content-type"] ?? "");
1302
+ if (httpStatus === 200)
1303
+ break;
1304
+ }
1305
+ catch {
1306
+ httpStatus = 0;
1307
+ }
1308
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
1309
+ }
1310
+ // Probe the backend only when app.json declares an explicit backend.healthPath
1311
+ // (P1-2): a valid backend is NOT required to expose a fixed /api/health route.
1312
+ // Without healthPath, verify checks only the mount homepage.
1313
+ let backendStatus = null;
1314
+ const healthPath = manifest.backend?.healthPath;
1315
+ if (manifest.backend && healthPath) {
1316
+ const probePath = healthPath.startsWith("/") ? healthPath : `/${healthPath}`;
1317
+ try {
1318
+ const response = await axios.get(`${mount.url.replace(/\/$/, "")}${probePath}`, { validateStatus: () => true, timeout: 10_000 });
1319
+ backendStatus = response.status;
1320
+ }
1321
+ catch {
1322
+ backendStatus = 0;
1323
+ }
1324
+ }
1325
+ const ok = httpStatus === 200 && (backendStatus === null || backendStatus === 200);
1326
+ const output = {
1327
+ status: ok ? "ok" : "failed",
1328
+ app: name,
1329
+ url: mount.url,
1330
+ httpStatus,
1331
+ contentType,
1332
+ attempts,
1333
+ backendStatus,
1334
+ };
1335
+ if (isJson(options, program))
1336
+ console.log(JSON.stringify(output, null, 2));
1337
+ else if (ok)
1338
+ console.log(formatSuccess(`Webapp reachable: ${mount.url}`));
1339
+ else
1340
+ console.error(formatError(`Webapp verification failed: ${mount.url}`));
1341
+ if (!ok)
1342
+ process.exit(1);
1343
+ }
1344
+ async function handleUrl(name, options, program) {
1345
+ const manifest = await readManifest(options.dir, name);
1346
+ // A2: --mount-id makes `app url` usable with no local state at all, which is
1347
+ // exactly when an operator needs to check whether the app is still live.
1348
+ if (options.mountId || manifest.mode === "webapp") {
1349
+ const webapp = manifest.mode === "webapp"
1350
+ ? requireWebappManifestValue(manifest, name)
1351
+ : null;
1352
+ const mountId = options.mountId ?? webapp?.deployment?.mountId;
1353
+ if (!mountId)
1354
+ throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
1355
+ const { appsDriver } = await getDriver();
1356
+ const [mount, capabilities] = await Promise.all([
1357
+ appsDriver.get(mountId),
1358
+ appsDriver.capabilities(),
1359
+ ]);
1360
+ emitWebappResult("url", name, mount, capabilities, options, program);
1361
+ return;
1362
+ }
1363
+ const slug = options.slug ?? manifest.slug ?? name;
1364
+ const { endpoint } = await getDriver();
1365
+ const url = shareUrl(endpoint, slug);
1366
+ const publicReachable = isPublicEndpoint(endpoint);
1367
+ if (isJson(options, program)) {
1368
+ console.log(JSON.stringify({ app: name, slug, shareUrl: url, publicReachable }, null, 2));
1369
+ return;
1370
+ }
1371
+ console.log(url);
1372
+ if (!publicReachable)
1373
+ console.log(formatWarning("Endpoint is not publicly reachable; this URL will not work cross-machine."));
1374
+ }
1375
+ /**
1376
+ * `pxbaas app adopt <name> --mount-id <m-...>` (缺陷5 A2) — write a mount that
1377
+ * already exists server-side back into the local `app.json` deployment block.
1378
+ *
1379
+ * This is the escape hatch for the deadlock where the app is live but this
1380
+ * working copy lost its mountId: deploy answers APP_MOUNT_CONFLICT and destroy
1381
+ * answers APP_DEPLOYMENT_NOT_FOUND, so nothing else can reach the mount. The
1382
+ * mount is verified with a GET before anything is written — adopting an unknown
1383
+ * mountId would only move the deadlock to the next lifecycle call.
1384
+ */
1385
+ async function handleAdopt(name, options, program) {
1386
+ const mountId = options.mountId?.trim();
1387
+ if (!mountId) {
1388
+ throw new AppsApiError("APP_MOUNT_ID_REQUIRED", `--mount-id is required: \`pxbaas app adopt ${name} --mount-id <m-...>\`. ` +
1389
+ "Find the id in the last successful deploy output or `pxbaas app url <name> --json`.");
1390
+ }
1391
+ let generation;
1392
+ if (options.generation != null) {
1393
+ if (!/^\d+$/.test(options.generation)) {
1394
+ throw new Error("--generation must be a non-negative integer.");
1395
+ }
1396
+ generation = Number(options.generation);
1397
+ }
1398
+ const manifestPath = join(options.dir, name, "app.json");
1399
+ if (!existsSync(manifestPath)) {
1400
+ throw new AppsApiError("APP_MANIFEST_NOT_FOUND", `No app.json for app '${name}' at ${manifestPath}. ` +
1401
+ `Scaffold it first (\`pxbaas app new ${name} --dir ${options.dir}\`) or fix --dir, ` +
1402
+ `then re-run \`pxbaas app adopt ${name} --mount-id ${mountId}\`.`);
1403
+ }
1404
+ const manifest = requireWebappManifestValue(await readManifest(options.dir, name), name);
1405
+ const { appsDriver, endpoint, tenantId } = await getDriver();
1406
+ const mount = await appsDriver.get(mountId);
1407
+ const warnings = [];
1408
+ if (mount.appName && mount.appName !== name) {
1409
+ warnings.push(`Server mount ${mount.mountId} is registered as appName '${mount.appName}', not '${name}'. ` +
1410
+ "Adopted anyway — double-check this is the right mount before you publish or destroy it.");
1411
+ }
1412
+ if (generation != null && generation !== mount.generation) {
1413
+ warnings.push(`Recording generation ${generation} while the server reports ${mount.generation}. ` +
1414
+ "publish/rollback/unpublish are CAS-guarded on this value and will fail if it is wrong.");
1415
+ }
1416
+ if (manifest.deployment && manifest.deployment.mountId !== mount.mountId) {
1417
+ warnings.push(`Replacing the previous local deployment state (mount ${manifest.deployment.mountId}, ` +
1418
+ `generation ${manifest.deployment.generation}).`);
1419
+ }
1420
+ manifest.deployment = {
1421
+ mountId: mount.mountId,
1422
+ generation: generation ?? mount.generation,
1423
+ };
1424
+ await writeManifest(options.dir, name, manifest);
1425
+ warnIfFailed(await recordLocalMount(options.dir, name, { mountId: mount.mountId, generation: manifest.deployment.generation }, { endpoint, tenantId }));
1426
+ const output = {
1427
+ status: "adopted",
1428
+ app: name,
1429
+ mount,
1430
+ deployment: manifest.deployment,
1431
+ warnings,
1432
+ };
1433
+ if (isJson(options, program)) {
1434
+ console.log(JSON.stringify(output, null, 2));
1435
+ return;
1436
+ }
1437
+ console.log(formatSuccess(`↻ Adopted existing mount ${mount.mountId} (generation ${manifest.deployment.generation}) from server for '${name}'.`));
1438
+ console.log(` Mount status: ${mount.status}`);
1439
+ console.log(` URL: ${mount.url}`);
1440
+ for (const w of warnings)
1441
+ console.log(formatWarning(w));
1442
+ console.log(` Next: pxbaas app url ${name} | pxbaas app deploy ${name} | pxbaas app destroy ${name} --confirm`);
1443
+ }
1444
+ async function handleList(options, program) {
1445
+ const { driver } = await getDriver();
1446
+ const functions = await driver.list();
1447
+ if (isJson(options, program)) {
1448
+ console.log(JSON.stringify({ count: functions.length, functions }, null, 2));
1449
+ return;
1450
+ }
1451
+ if (functions.length === 0) {
1452
+ console.log("No deployed functions.");
1453
+ return;
1454
+ }
1455
+ for (const f of functions) {
1456
+ console.log(`${f.slug}\t${f.status}\tv${f.version}\tverify_jwt=${f.verify_jwt}`);
1457
+ }
1458
+ }
1459
+ async function handleRemove(name, options, program) {
1460
+ const json = isJson(options, program);
1461
+ const manifest = await readManifest(options.dir, name);
1462
+ // D6: `app remove` deletes ONE legacy Edge Function by slug. A webapp owns
1463
+ // auto-generated `app_<id>_r_` candidates plus a mount record; the old path
1464
+ // took slug=name, missed the real candidate, and left the mount behind. Route
1465
+ // webapps to `app destroy` instead of silently doing the wrong thing.
1466
+ if (manifest.mode === "webapp") {
1467
+ 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).`);
1468
+ }
1469
+ const slug = options.slug ?? manifest.slug ?? name;
1470
+ if (!options.confirm) {
1471
+ const preview = {
1472
+ status: "dry-run",
1473
+ app: name,
1474
+ slug,
1475
+ note: "pass --confirm to delete the deployed function (irreversible)",
1476
+ };
1477
+ if (json)
1478
+ console.log(JSON.stringify(preview, null, 2));
1479
+ else
1480
+ console.log(formatWarning(`Preview: remove function '${slug}'. Re-run with --confirm to delete (irreversible).`));
1481
+ return;
1482
+ }
1483
+ const { driver } = await getDriver();
1484
+ await driver.delete(slug);
1485
+ if (json)
1486
+ console.log(JSON.stringify({ status: "removed", app: name, slug }, null, 2));
1487
+ else
1488
+ console.log(formatSuccess(`Removed function '${slug}'.`));
1489
+ }
1490
+ /**
1491
+ * `pxbaas app destroy <name>` — permanently tear down a schemaVersion 2 webapp:
1492
+ * delete its edge-func candidates and its mount-id record. Two-phase: without
1493
+ * --confirm it only previews what will be reclaimed. Idempotent server-side.
1494
+ *
1495
+ * DB business tables are kept by default (scope decision 2026-10-16). With
1496
+ * `--purge-db` the app's own tables + RLS registry rows are also removed via the
1497
+ * helpers below (mode-aware: single-tenant /pg/query, multi-tenant /api/tenant-ddl).
1498
+ */
1499
+ /**
1500
+ * Derive the DB tables an app owns, from its bundled db/schema.sql (CREATE TABLE
1501
+ * names) and db/rls.json (declared tables/policies). Used by `destroy --purge-db`.
1502
+ */
1503
+ async function deriveAppTables(appDir) {
1504
+ const set = new Set();
1505
+ const schemaFile = join(appDir, "db", "schema.sql");
1506
+ if (existsSync(schemaFile)) {
1507
+ for (const t of extractCreateTableNames(await readFile(schemaFile, "utf8"))) {
1508
+ set.add(t);
1509
+ }
1510
+ }
1511
+ const rlsFile = join(appDir, "db", "rls.json");
1512
+ if (existsSync(rlsFile)) {
1513
+ const rls = parseRlsJson(await readFile(rlsFile, "utf8"));
1514
+ for (const t of rls.tables)
1515
+ set.add(t.table_name);
1516
+ for (const p of rls.policies)
1517
+ set.add(p.table_name);
1518
+ }
1519
+ return [...set];
1520
+ }
1521
+ /**
1522
+ * Drop the app's tables + remove its RLS registry rows via the mode-appropriate
1523
+ * channel. RLS is purged before the DROP so policy cleanup still resolves.
1524
+ */
1525
+ async function purgeAppDatabase(appDir, schema) {
1526
+ const tables = await deriveAppTables(appDir);
1527
+ const sink = await getSchemaSink("service_role");
1528
+ const ctx = { schema };
1529
+ const dropped = [];
1530
+ const errors = [];
1531
+ let policiesDeleted = 0;
1532
+ let toggleRowsRemoved = true;
1533
+ for (const t of tables) {
1534
+ try {
1535
+ const r = await sink.purgeRlsForTable(t, ctx);
1536
+ policiesDeleted += r.policiesDeleted;
1537
+ if (!r.toggleRemoved)
1538
+ toggleRowsRemoved = false;
1539
+ await sink.dropTable(t, ctx);
1540
+ dropped.push(t);
1541
+ }
1542
+ catch (e) {
1543
+ errors.push(`${t}: ${e instanceof Error ? e.message : String(e)}`);
1544
+ }
1545
+ }
1546
+ return { tables, dropped, policiesDeleted, toggleRowsRemoved, errors };
1547
+ }
1548
+ async function handleDestroy(name, options, program) {
1549
+ const json = isJson(options, program);
1550
+ const manifest = await readManifest(options.dir, name);
1551
+ // D6: destroy is webapp-only; legacy spa/api/mpa keep using `app remove`.
1552
+ if (manifest.mode !== "webapp") {
1553
+ 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.`);
1554
+ }
1555
+ const webapp = requireWebappManifestValue(manifest, name);
1556
+ const deployment = webapp.deployment;
1557
+ const { appsDriver, endpoint, tenantId } = await getDriver();
1558
+ // A2: `--mount-id` is the escape hatch for a lost/stale local state, so an
1559
+ // explicit flag wins over app.json — but a disagreement is announced, never
1560
+ // silent, because destroy is irreversible.
1561
+ const flagMountId = options.mountId?.trim();
1562
+ let targetMountId = flagMountId ?? deployment?.mountId;
1563
+ let mountIdSource = flagMountId
1564
+ ? "flag"
1565
+ : "app.json";
1566
+ if (flagMountId && deployment && flagMountId !== deployment.mountId) {
1567
+ console.error(formatWarning(`--mount-id ${flagMountId} overrides the local deployment record (${deployment.mountId}); ` +
1568
+ "the local record is left untouched."));
1569
+ }
1570
+ if (!targetMountId) {
1571
+ // A4: last chance before declaring the mount unreachable.
1572
+ const found = await lookupLocalMount(options.dir, name, {
1573
+ endpoint,
1574
+ tenantId,
1575
+ });
1576
+ if (found.ignored)
1577
+ console.error(formatWarning(found.ignored));
1578
+ if (found.entry) {
1579
+ targetMountId = found.entry.mountId;
1580
+ mountIdSource = "workspace-index";
1581
+ console.error(formatWarning(`↻ Using mount ${targetMountId} recovered from ${found.path}; app.json has no deployment state.`));
1582
+ }
1583
+ }
1584
+ if (!targetMountId) {
1585
+ throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state; nothing to destroy`);
1586
+ }
1587
+ // Only a local record matching the mount being destroyed may supply the orphan
1588
+ // candidate slug and be cleared afterwards.
1589
+ const localMatchesTarget = deployment != null && deployment.mountId === targetMountId;
1590
+ const candidateFunctionSlug = localMatchesTarget
1591
+ ? deployment?.candidateFunctionSlug
1592
+ : undefined;
1593
+ // Phase 1: preview (no --confirm). Pull the live mount so the operator sees
1594
+ // exactly which functions + mount record will be reclaimed.
1595
+ if (!options.confirm) {
1596
+ let mount = null;
1597
+ try {
1598
+ mount = await appsDriver.get(targetMountId);
1599
+ }
1600
+ catch {
1601
+ mount = null; // already gone server-side; preview is still informative
1602
+ }
1603
+ const slugs = new Set();
1604
+ if (mount?.activeFunctionSlug)
1605
+ slugs.add(mount.activeFunctionSlug);
1606
+ if (mount?.previousFunctionSlug)
1607
+ slugs.add(mount.previousFunctionSlug);
1608
+ if (candidateFunctionSlug)
1609
+ slugs.add(candidateFunctionSlug);
1610
+ const purgeTables = options.purgeDb
1611
+ ? await deriveAppTables(join(options.dir, name))
1612
+ : [];
1613
+ const preview = {
1614
+ status: "dry-run",
1615
+ app: name,
1616
+ mountId: targetMountId,
1617
+ mountIdSource,
1618
+ mountStatus: mount?.status ?? "NOT_FOUND",
1619
+ mountGeneration: mount?.generation ?? null,
1620
+ functions: [...slugs],
1621
+ tablesUntouched: !options.purgeDb,
1622
+ ...(options.purgeDb ? { purgeDb: { tables: purgeTables } } : {}),
1623
+ note: options.purgeDb
1624
+ ? "pass --confirm to destroy: deletes the edge functions + mount record (irreversible) AND drops the listed DB tables + their RLS rows."
1625
+ : "pass --confirm to destroy: deletes the edge functions + mount record (irreversible). DB business tables are NOT dropped.",
1626
+ };
1627
+ if (json) {
1628
+ console.log(JSON.stringify(preview, null, 2));
1629
+ return;
1630
+ }
1631
+ console.log(formatWarning(`Preview: destroy webapp '${name}' (mount ${targetMountId} from ${mountIdSource}). Re-run with --confirm.`));
1632
+ console.log(` Functions to delete: ${[...slugs].join(", ") || "(none)"}`);
1633
+ console.log(` Mount record: ${targetMountId} (${preview.mountStatus}${mount?.generation != null ? `, generation ${mount.generation}` : ""})`);
1634
+ if (options.purgeDb) {
1635
+ console.log(formatWarning(` --purge-db will DROP tables: ${purgeTables.join(", ") || "(none detected)"}`));
1636
+ 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."));
1637
+ }
1638
+ else {
1639
+ console.log(formatWarning(" DB business tables are NOT dropped; clean them up yourself if needed."));
1640
+ }
1641
+ return;
1642
+ }
1643
+ // Phase 2: destroy. Pass the local orphan candidate (if any) so an uploaded
1644
+ // but never-published function is reclaimed too.
1645
+ //
1646
+ // A2: echo the live mount identity (status/generation) next to the result —
1647
+ // `--mount-id` bypasses every local safeguard, so the operator must be able to
1648
+ // see exactly which mount was reclaimed.
1649
+ let snapshot = null;
1650
+ try {
1651
+ snapshot = await appsDriver.get(targetMountId);
1652
+ }
1653
+ catch {
1654
+ snapshot = null; // already gone server-side; destroy is idempotent
1655
+ }
1656
+ const result = await appsDriver.destroy(targetMountId, {
1657
+ ...(candidateFunctionSlug ? { candidateFunctionSlug } : {}),
1658
+ });
1659
+ // D5: clear ONLY the local deployment state (and only when it points at the
1660
+ // mount that was actually destroyed); keep the source tree so the app can be
1661
+ // re-deployed later.
1662
+ if (localMatchesTarget) {
1663
+ delete webapp.deployment;
1664
+ await writeManifest(options.dir, name, webapp);
1665
+ }
1666
+ warnIfFailed(await forgetLocalMount(options.dir, name, targetMountId));
1667
+ // Optional DB purge (Q4): drop the app's own tables + RLS rows via the
1668
+ // mode-appropriate channel. Runs AFTER the mount/functions are reclaimed.
1669
+ const purge = options.purgeDb
1670
+ ? await purgeAppDatabase(join(options.dir, name), options.schema ?? "public")
1671
+ : null;
1672
+ const failed = result.deletedFunctions.filter((f) => !f.deleted);
1673
+ if (json) {
1674
+ console.log(JSON.stringify({
1675
+ status: "destroyed",
1676
+ app: name,
1677
+ mountId: result.mountId,
1678
+ mountIdSource,
1679
+ mountStatusBeforeDestroy: snapshot?.status ?? "NOT_FOUND",
1680
+ mountGenerationBeforeDestroy: snapshot?.generation ?? null,
1681
+ alreadyGone: result.alreadyGone ?? false,
1682
+ deletedFunctions: result.deletedFunctions,
1683
+ tablesUntouched: !purge,
1684
+ ...(purge ? { purgeDb: purge } : {}),
1685
+ }, null, 2));
1686
+ if (failed.length > 0 || (purge && purge.errors.length > 0))
1687
+ process.exit(1);
1688
+ return;
1689
+ }
1690
+ console.log(formatSuccess(`Destroyed webapp '${name}' (mount ${result.mountId}${result.alreadyGone ? ", already gone" : ""}).`));
1691
+ console.log(` Mount destroyed: ${targetMountId} (from ${mountIdSource}; was ${snapshot?.status ?? "NOT_FOUND"}${snapshot?.generation != null ? `, generation ${snapshot.generation}` : ""})`);
1692
+ for (const f of result.deletedFunctions) {
1693
+ if (f.deleted)
1694
+ console.log(` Deleted function: ${f.slug}`);
1695
+ else
1696
+ console.error(formatError(` Failed to delete function: ${f.slug} — ${f.error}`));
1697
+ }
1698
+ if (purge) {
1699
+ console.log(formatSuccess(` Purged DB: dropped ${purge.dropped.length}/${purge.tables.length} table(s), removed ${purge.policiesDeleted} RLS policy/policies.`));
1700
+ if (!purge.toggleRowsRemoved) {
1701
+ 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."));
1702
+ }
1703
+ for (const e of purge.errors)
1704
+ console.error(formatError(` Purge failed for ${e}`));
1705
+ }
1706
+ else {
1707
+ console.log(formatWarning(" DB business tables were NOT dropped; clean them up yourself if needed."));
1708
+ }
1709
+ if (failed.length > 0 || (purge && purge.errors.length > 0))
1710
+ process.exit(1);
1711
+ }
1712
+ function registerShareCommands(appCmd, program) {
1713
+ const shareCmd = appCmd
1714
+ .command("share")
1715
+ .description("Create, inspect, revoke, and verify secure MPA share links");
1716
+ shareCmd
1717
+ .command("create <slug>")
1718
+ .description("Create a secure MPA share link and print its one-time entry URL")
1719
+ .option("--expires-in <seconds>", "Lifetime in seconds", "86400")
1720
+ .option("--expires-at <iso8601>", "Absolute RFC3339 expiry (instead of --expires-in)")
1721
+ .option("--max-uses <count>", "Maximum ticket redemptions")
1722
+ .option("--tenant-id <tenant>", "Explicit target tenant (required for platform credentials)")
1723
+ .option("--json", "Output in JSON format")
1724
+ .action(async (slug, options) => {
1725
+ try {
1726
+ await handleShareCreate(slug, options, program);
1727
+ }
1728
+ catch (e) {
1729
+ fail(e, isJson(options, program));
1730
+ }
1731
+ });
1732
+ shareCmd
1733
+ .command("list <slug>")
1734
+ .description("List secure MPA share links without exposing tickets or sessions")
1735
+ .option("--tenant-id <tenant>", "Explicit target tenant (required for platform credentials)")
1736
+ .option("--json", "Output in JSON format")
1737
+ .action(async (slug, options) => {
1738
+ try {
1739
+ await handleShareList(slug, options, program);
1740
+ }
1741
+ catch (e) {
1742
+ fail(e, isJson(options, program));
1743
+ }
1744
+ });
1745
+ shareCmd
1746
+ .command("revoke <slug> <share-id>")
1747
+ .description("Revoke a secure MPA share link (requires --confirm)")
1748
+ .option("--tenant-id <tenant>", "Explicit target tenant (required for platform credentials)")
1749
+ .option("--confirm", "Actually revoke the share link")
1750
+ .option("--json", "Output in JSON format")
1751
+ .action(async (slug, shareId, options) => {
1752
+ try {
1753
+ await handleShareRevoke(slug, shareId, options, program);
1754
+ }
1755
+ catch (e) {
1756
+ fail(e, isJson(options, program));
1757
+ }
1758
+ });
1759
+ shareCmd
1760
+ .command("verify <slug> <share-id>")
1761
+ .description("Verify a share link status without revealing a ticket or cookie")
1762
+ .option("--tenant-id <tenant>", "Explicit target tenant (required for platform credentials)")
1763
+ .option("--json", "Output in JSON format")
1764
+ .action(async (slug, shareId, options) => {
1765
+ try {
1766
+ await handleShareVerify(slug, shareId, options, program);
1767
+ }
1768
+ catch (e) {
1769
+ fail(e, isJson(options, program));
1770
+ }
1771
+ });
1772
+ }
1773
+ function secureShareUrl(endpoint, mountId, ticket) {
1774
+ return `${endpoint.replace(/\/$/, "")}/share/v1/${encodeURIComponent(mountId)}/${encodeURIComponent(ticket)}`;
1775
+ }
1776
+ function parsePositiveInteger(value, optionName) {
1777
+ if (!/^[1-9]\d*$/.test(value)) {
1778
+ throw new Error(`${optionName} must be a positive integer.`);
1779
+ }
1780
+ const parsed = Number(value);
1781
+ if (!Number.isSafeInteger(parsed) || parsed > 4_294_967_295) {
1782
+ throw new Error(`${optionName} must be at most 4294967295.`);
1783
+ }
1784
+ return parsed;
1785
+ }
1786
+ /**
1787
+ * Precheck `capabilities.privateShare` before touching the share API (缺陷6 §7).
1788
+ *
1789
+ * On an instance with sharing disabled every `/share/v1/*` call answers a bare
1790
+ * `404 Secure MPA sharing is disabled`, which reads like a bad slug or a CLI bug
1791
+ * rather than an instance setting. Say so locally, and say that nothing was sent.
1792
+ *
1793
+ * A failing capabilities read must NOT block: the instance stays authoritative,
1794
+ * so an unreachable capability endpoint degrades to "let the share call speak".
1795
+ */
1796
+ async function assertPrivateShareEnabled(appsDriver, action) {
1797
+ let capabilities;
1798
+ try {
1799
+ capabilities = await appsDriver.capabilities();
1800
+ }
1801
+ catch {
1802
+ return;
1803
+ }
1804
+ if (capabilities?.privateShare === false) {
1805
+ throw new Error(`This instance has secure MPA sharing disabled (capabilities.privateShare=false), ` +
1806
+ `so \`pxbaas app share ${action}\` cannot work here. Enable private sharing on the ` +
1807
+ `platform side and re-run — no share API request was sent. ` +
1808
+ `Check the current setting with \`pxbaas app capabilities --json\`.`);
1809
+ }
1810
+ }
1811
+ async function handleShareCreate(slug, options, program) {
1812
+ validateSlug(slug);
1813
+ if (options.expiresAt) {
1814
+ const expiresAt = new Date(options.expiresAt);
1815
+ if (!Number.isFinite(expiresAt.getTime()) ||
1816
+ expiresAt.getTime() <= Date.now()) {
1817
+ throw new Error("--expires-at must be a future ISO-8601 timestamp.");
1818
+ }
1819
+ }
1820
+ const maxUses = options.maxUses
1821
+ ? parsePositiveInteger(options.maxUses, "--max-uses")
1822
+ : undefined;
1823
+ const expiresInSeconds = options.expiresAt
1824
+ ? undefined
1825
+ : parsePositiveInteger(options.expiresIn, "--expires-in");
1826
+ const { shareDriver, appsDriver, endpoint } = await getDriver();
1827
+ await assertPrivateShareEnabled(appsDriver, "create");
1828
+ const link = await shareDriver.create(slug, {
1829
+ expiresAt: options.expiresAt,
1830
+ expiresInSeconds,
1831
+ maxUses,
1832
+ tenantId: options.tenantId,
1833
+ });
1834
+ // Do not emit the raw ticket as a separate field or diagnostic. The entry URL
1835
+ // is the only one-time delivery channel; list/verify/revoke never contain it.
1836
+ const { ticket, ...share } = link;
1837
+ const entryUrl = secureShareUrl(endpoint, link.mount_id, ticket);
1838
+ const output = { status: "created", slug, entryUrl, share };
1839
+ if (isJson(options, program)) {
1840
+ console.log(JSON.stringify(output, null, 2));
1841
+ return;
1842
+ }
1843
+ console.log(formatSuccess(`Created secure MPA share for '${slug}'. Save this entry URL now; it cannot be recovered:`));
1844
+ console.log(` ${entryUrl}`);
1845
+ console.log(` Expires: ${share.expires_at}`);
1846
+ console.log(` Share ID: ${share.id}`);
1847
+ }
1848
+ async function handleShareList(slug, options, program) {
1849
+ validateSlug(slug);
1850
+ const { shareDriver } = await getDriver();
1851
+ const shares = await shareDriver.list(slug, options.tenantId);
1852
+ if (isJson(options, program)) {
1853
+ console.log(JSON.stringify({ slug, count: shares.length, shares }, null, 2));
1854
+ return;
1855
+ }
1856
+ if (shares.length === 0) {
1857
+ console.log("No secure MPA share links.");
1858
+ return;
1859
+ }
1860
+ for (const share of shares) {
1861
+ console.log(`${share.id}\t${share.status}\texpires=${share.expires_at}\tuses=${share.used_count}/${share.max_uses ?? "unlimited"}`);
1862
+ }
1863
+ }
1864
+ async function handleShareRevoke(slug, shareId, options, program) {
1865
+ validateSlug(slug);
1866
+ if (!options.confirm) {
1867
+ const preview = {
1868
+ status: "dry-run",
1869
+ slug,
1870
+ shareId,
1871
+ note: "pass --confirm to revoke this share link and invalidate all of its active sessions",
1872
+ };
1873
+ if (isJson(options, program))
1874
+ console.log(JSON.stringify(preview, null, 2));
1875
+ else
1876
+ console.log(formatWarning(`Preview: revoke share '${shareId}' for '${slug}'. Re-run with --confirm.`));
1877
+ return;
1878
+ }
1879
+ // The branch without --confirm stays a purely local preview (no profile, no
1880
+ // network); the capability precheck belongs to the branch that really calls the
1881
+ // share API.
1882
+ const { shareDriver, appsDriver } = await getDriver();
1883
+ await assertPrivateShareEnabled(appsDriver, "revoke");
1884
+ const share = await shareDriver.revoke(slug, shareId, options.tenantId);
1885
+ if (isJson(options, program)) {
1886
+ console.log(JSON.stringify({ status: "revoked", slug, share }, null, 2));
1887
+ return;
1888
+ }
1889
+ console.log(formatSuccess(`Revoked share '${share.id}' for '${slug}'.`));
1890
+ }
1891
+ async function handleShareVerify(slug, shareId, options, program) {
1892
+ validateSlug(slug);
1893
+ const { shareDriver, appsDriver } = await getDriver();
1894
+ await assertPrivateShareEnabled(appsDriver, "verify");
1895
+ const share = await shareDriver.get(slug, shareId, options.tenantId);
1896
+ const output = {
1897
+ status: share.status === "ACTIVE" ? "active" : "inactive",
1898
+ slug,
1899
+ share,
1900
+ };
1901
+ if (isJson(options, program)) {
1902
+ console.log(JSON.stringify(output, null, 2));
1903
+ return;
1904
+ }
1905
+ console.log(`${share.id}: ${share.status} (expires ${share.expires_at}, uses ${share.used_count}/${share.max_uses ?? "unlimited"})`);
1906
+ }
1907
+ //# sourceMappingURL=index.js.map