@polardbx/pxbaas 0.1.0-beta.1 → 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.
- package/README.md +224 -0
- package/dist/app/local-index.d.ts +70 -0
- package/dist/app/local-index.d.ts.map +1 -0
- package/dist/app/local-index.js +140 -0
- package/dist/app/local-index.js.map +1 -0
- package/dist/app/preflight.d.ts +24 -1
- package/dist/app/preflight.d.ts.map +1 -1
- package/dist/app/preflight.js +47 -11
- package/dist/app/preflight.js.map +1 -1
- package/dist/app/spec-lint.d.ts.map +1 -1
- package/dist/app/spec-lint.js +16 -0
- package/dist/app/spec-lint.js.map +1 -1
- package/dist/app/templates.d.ts +24 -0
- package/dist/app/templates.d.ts.map +1 -1
- package/dist/app/templates.js.map +1 -1
- package/dist/cli/help-guides.d.ts +17 -0
- package/dist/cli/help-guides.d.ts.map +1 -0
- package/dist/cli/help-guides.js +132 -0
- package/dist/cli/help-guides.js.map +1 -0
- package/dist/cli/root.d.ts.map +1 -1
- package/dist/cli/root.js +6 -0
- package/dist/cli/root.js.map +1 -1
- package/dist/commands/app/index.d.ts.map +1 -1
- package/dist/commands/app/index.js +687 -47
- package/dist/commands/app/index.js.map +1 -1
- package/dist/commands/completion/index.d.ts.map +1 -1
- package/dist/commands/completion/index.js +5 -1
- package/dist/commands/completion/index.js.map +1 -1
- package/dist/commands/data/index.d.ts +52 -0
- package/dist/commands/data/index.d.ts.map +1 -1
- package/dist/commands/data/index.js +201 -39
- package/dist/commands/data/index.js.map +1 -1
- package/dist/commands/db/index.d.ts +15 -0
- package/dist/commands/db/index.d.ts.map +1 -1
- package/dist/commands/db/index.js +200 -55
- package/dist/commands/db/index.js.map +1 -1
- package/dist/commands/functions/new.d.ts +4 -0
- package/dist/commands/functions/new.d.ts.map +1 -1
- package/dist/commands/functions/new.js +24 -9
- package/dist/commands/functions/new.js.map +1 -1
- package/dist/commands/gen/index.d.ts.map +1 -1
- package/dist/commands/gen/index.js +13 -10
- package/dist/commands/gen/index.js.map +1 -1
- package/dist/commands/guide/index.d.ts +8 -0
- package/dist/commands/guide/index.d.ts.map +1 -0
- package/dist/commands/guide/index.js +20 -0
- package/dist/commands/guide/index.js.map +1 -0
- package/dist/commands/instance/index.d.ts.map +1 -1
- package/dist/commands/instance/index.js +120 -0
- package/dist/commands/instance/index.js.map +1 -1
- package/dist/commands/login/index.d.ts.map +1 -1
- package/dist/commands/login/index.js +23 -63
- package/dist/commands/login/index.js.map +1 -1
- package/dist/commands/profile/import-env.d.ts +7 -2
- package/dist/commands/profile/import-env.d.ts.map +1 -1
- package/dist/commands/profile/import-env.js +141 -16
- package/dist/commands/profile/import-env.js.map +1 -1
- package/dist/commands/profile/index.d.ts.map +1 -1
- package/dist/commands/profile/index.js +16 -1
- package/dist/commands/profile/index.js.map +1 -1
- package/dist/commands/skill/index.d.ts.map +1 -1
- package/dist/commands/skill/index.js +89 -14
- package/dist/commands/skill/index.js.map +1 -1
- package/dist/commands/storage/index.d.ts.map +1 -1
- package/dist/commands/storage/index.js +209 -7
- package/dist/commands/storage/index.js.map +1 -1
- package/dist/commands/tenant/index.d.ts +19 -0
- package/dist/commands/tenant/index.d.ts.map +1 -0
- package/dist/commands/tenant/index.js +667 -0
- package/dist/commands/tenant/index.js.map +1 -0
- package/dist/commands/whoami/index.js +19 -0
- package/dist/commands/whoami/index.js.map +1 -1
- package/dist/credentials/credential-store.d.ts +30 -1
- package/dist/credentials/credential-store.d.ts.map +1 -1
- package/dist/credentials/credential-store.js +114 -12
- package/dist/credentials/credential-store.js.map +1 -1
- package/dist/credentials/env-file.d.ts +48 -7
- package/dist/credentials/env-file.d.ts.map +1 -1
- package/dist/credentials/env-file.js +91 -16
- package/dist/credentials/env-file.js.map +1 -1
- package/dist/credentials/index.d.ts +2 -0
- package/dist/credentials/index.d.ts.map +1 -1
- package/dist/credentials/index.js +2 -0
- package/dist/credentials/index.js.map +1 -1
- package/dist/credentials/jwt-introspect.d.ts +7 -0
- package/dist/credentials/jwt-introspect.d.ts.map +1 -1
- package/dist/credentials/jwt-introspect.js.map +1 -1
- package/dist/credentials/masker.d.ts +3 -0
- package/dist/credentials/masker.d.ts.map +1 -1
- package/dist/credentials/masker.js +3 -0
- package/dist/credentials/masker.js.map +1 -1
- package/dist/credentials/profile.d.ts +12 -0
- package/dist/credentials/profile.d.ts.map +1 -1
- package/dist/credentials/profile.js +23 -0
- package/dist/credentials/profile.js.map +1 -1
- package/dist/credentials/tty-guard.d.ts +10 -0
- package/dist/credentials/tty-guard.d.ts.map +1 -1
- package/dist/credentials/tty-guard.js +32 -0
- package/dist/credentials/tty-guard.js.map +1 -1
- package/dist/db/apply-schema.d.ts +98 -11
- package/dist/db/apply-schema.d.ts.map +1 -1
- package/dist/db/apply-schema.js +247 -80
- package/dist/db/apply-schema.js.map +1 -1
- package/dist/db/introspect.d.ts +54 -0
- package/dist/db/introspect.d.ts.map +1 -0
- package/dist/db/introspect.js +140 -0
- package/dist/db/introspect.js.map +1 -0
- package/dist/db/sql-file.d.ts +55 -0
- package/dist/db/sql-file.d.ts.map +1 -1
- package/dist/db/sql-file.js +417 -0
- package/dist/db/sql-file.js.map +1 -1
- package/dist/instance/zero-whitelist.d.ts +39 -0
- package/dist/instance/zero-whitelist.d.ts.map +1 -0
- package/dist/instance/zero-whitelist.js +67 -0
- package/dist/instance/zero-whitelist.js.map +1 -0
- package/dist/main.js +26 -3
- package/dist/main.js.map +1 -1
- package/dist/skill/agents-md.d.ts +25 -0
- package/dist/skill/agents-md.d.ts.map +1 -0
- package/dist/skill/agents-md.js +62 -0
- package/dist/skill/agents-md.js.map +1 -0
- package/dist/skill/hosts.d.ts +31 -7
- package/dist/skill/hosts.d.ts.map +1 -1
- package/dist/skill/hosts.js +65 -7
- package/dist/skill/hosts.js.map +1 -1
- package/dist/skill/installer.d.ts +15 -3
- package/dist/skill/installer.d.ts.map +1 -1
- package/dist/skill/installer.js +62 -24
- package/dist/skill/installer.js.map +1 -1
- package/dist/tenant/bundle.d.ts +66 -0
- package/dist/tenant/bundle.d.ts.map +1 -0
- package/dist/tenant/bundle.js +132 -0
- package/dist/tenant/bundle.js.map +1 -0
- package/dist/tenant/guide.d.ts +50 -0
- package/dist/tenant/guide.d.ts.map +1 -0
- package/dist/tenant/guide.js +152 -0
- package/dist/tenant/guide.js.map +1 -0
- package/dist/tenant/probe.d.ts +65 -0
- package/dist/tenant/probe.d.ts.map +1 -0
- package/dist/tenant/probe.js +159 -0
- package/dist/tenant/probe.js.map +1 -0
- package/dist/tenant/verify.d.ts +37 -0
- package/dist/tenant/verify.d.ts.map +1 -0
- package/dist/tenant/verify.js +132 -0
- package/dist/tenant/verify.js.map +1 -0
- package/dist/version.d.ts +29 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +65 -0
- package/dist/version.js.map +1 -0
- package/package.json +3 -3
- package/skills/pxbaas-app-spec/SKILL.md +4 -2
- package/skills/pxbaas-app-spec/references/deploy-options.md +3 -2
- package/skills/pxbaas-app-spec/references/runtime-contract.md +2 -2
- package/skills/pxbaas-app-spec/references/templates-examples.md +7 -4
- package/skills/pxbaas-cli/SKILL.md +26 -8
- package/skills/pxbaas-cli/references/app-deploy.md +46 -3
- package/skills/pxbaas-cli/references/auth-and-credentials.md +30 -2
- package/skills/pxbaas-cli/references/data-postgrest.md +19 -3
- package/skills/pxbaas-cli/references/db-and-meta.md +17 -0
- package/skills/pxbaas-cli/references/multi-tenant-apps.md +35 -4
- package/skills/pxbaas-cli/references/provision.md +16 -1
- package/skills/pxbaas-cli/references/safety-and-two-phase.md +42 -13
- package/skills/pxbaas-cli/references/tenant-admin.md +122 -0
- package/skills/pxbaas-cli/references/troubleshooting.md +4 -1
- package/templates/webapp/csr-bff/db/rls.json +13 -0
- package/templates/webapp/csr-bff/db/schema.sql +8 -28
- package/templates/webapp/manifest.json +131 -8
- package/templates/webapp/react-vite-hono/backend/index.ts +21 -4
- package/templates/webapp/ssr-blog/db/rls.json +13 -0
- package/templates/webapp/ssr-blog/db/schema.sql +8 -28
- package/templates/webapp/ssr-hono/backend/index.ts +50 -31
- package/templates/webapp/ssr-shop/db/rls.json +16 -0
- package/templates/webapp/ssr-shop/db/schema.sql +12 -42
- package/dist/commands/login/login.d.ts +0 -3
- package/dist/commands/login/login.d.ts.map +0 -1
- package/dist/commands/login/login.js +0 -185
- package/dist/commands/login/login.js.map +0 -1
- /package/templates/webapp/vanilla-static/frontend/{app.js → dist/app.js} +0 -0
- /package/templates/webapp/vanilla-static/frontend/{index.html → dist/index.html} +0 -0
- /package/templates/webapp/vanilla-static/frontend/{style.css → dist/style.css} +0 -0
|
@@ -7,9 +7,11 @@ import { ProfileManager } from "../../credentials/profile.js";
|
|
|
7
7
|
import { CredentialStore } from "../../credentials/credential-store.js";
|
|
8
8
|
import { formatError, formatSuccess, formatWarning, } from "../../credentials/index.js";
|
|
9
9
|
import { HONO_VERSION_PLACEHOLDER, scaffoldApp, listTemplates, listTemplateIdsSync, } from "../../app/templates.js";
|
|
10
|
-
import {
|
|
10
|
+
import { checkQuota, FALLBACK_QUOTA_LIMIT, isPublicEndpoint, quotaFindings, runPreflight, } from "../../app/preflight.js";
|
|
11
|
+
import { forgetLocalMount, lookupLocalMount, recordLocalMount, } from "../../app/local-index.js";
|
|
11
12
|
import { lintAppSource } from "../../app/spec-lint.js";
|
|
12
|
-
import {
|
|
13
|
+
import { applySchema, getSchemaSink, } from "../../db/apply-schema.js";
|
|
14
|
+
import { parseRlsJson, extractCreateTableNames } from "../../db/sql-file.js";
|
|
13
15
|
const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
14
16
|
/**
|
|
15
17
|
* `pxbaas app` — scaffold, deploy, publish, verify, and share legacy Edge Function
|
|
@@ -54,6 +56,7 @@ export function registerAppCommands(program) {
|
|
|
54
56
|
.option("--verify-jwt", "Require JWT (safer, but the URL is no longer publicly openable)")
|
|
55
57
|
.option("--dry-run", "Run preflight + spec review and show the plan without deploying")
|
|
56
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.")
|
|
57
60
|
.option("--json", "Output in JSON format")
|
|
58
61
|
.action(async (name, options) => {
|
|
59
62
|
try {
|
|
@@ -110,6 +113,7 @@ export function registerAppCommands(program) {
|
|
|
110
113
|
.description("Print the shareable URL for a deployed webapp")
|
|
111
114
|
.option("--dir <directory>", "Parent directory for the app", "./apps")
|
|
112
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)")
|
|
113
117
|
.option("--json", "Output in JSON format")
|
|
114
118
|
.action(async (name, options) => {
|
|
115
119
|
try {
|
|
@@ -119,6 +123,21 @@ export function registerAppCommands(program) {
|
|
|
119
123
|
fail(e, options.json);
|
|
120
124
|
}
|
|
121
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
|
+
});
|
|
122
141
|
appCmd
|
|
123
142
|
.command("list")
|
|
124
143
|
.description("List deployed edge functions (apps)")
|
|
@@ -146,6 +165,23 @@ export function registerAppCommands(program) {
|
|
|
146
165
|
fail(e, options.json);
|
|
147
166
|
}
|
|
148
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
|
+
});
|
|
149
185
|
appCmd
|
|
150
186
|
.command("templates")
|
|
151
187
|
.description("List available webapp templates")
|
|
@@ -156,8 +192,7 @@ export function registerAppCommands(program) {
|
|
|
156
192
|
if (isJson(options, program))
|
|
157
193
|
console.log(JSON.stringify({ templates }, null, 2));
|
|
158
194
|
else
|
|
159
|
-
|
|
160
|
-
console.log(`${t.id}\t${t.description}`);
|
|
195
|
+
printGroupedTemplates(templates);
|
|
161
196
|
}
|
|
162
197
|
catch (e) {
|
|
163
198
|
fail(e, options.json);
|
|
@@ -165,9 +200,9 @@ export function registerAppCommands(program) {
|
|
|
165
200
|
});
|
|
166
201
|
appCmd
|
|
167
202
|
.command("db-init <name>")
|
|
168
|
-
.description("Apply the app's db/schema.sql to the
|
|
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.")
|
|
169
204
|
.option("--dir <directory>", "Parent directory for the app", "./apps")
|
|
170
|
-
.option("--schema <schema>", "Target schema for the __SCHEMA__ placeholder", "public")
|
|
205
|
+
.option("--schema <schema>", "Target schema for the __SCHEMA__ placeholder (single-tenant only)", "public")
|
|
171
206
|
.option("--dry-run", "Show the statements without executing them")
|
|
172
207
|
.option("--yes", "Authorize non-interactively (skip the confirm prompt)")
|
|
173
208
|
.option("--json", "Output in JSON format")
|
|
@@ -184,6 +219,37 @@ export function registerAppCommands(program) {
|
|
|
184
219
|
function isJson(options, program) {
|
|
185
220
|
return Boolean(options.json || program.opts().json);
|
|
186
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
|
+
};
|
|
187
253
|
function fail(error, json) {
|
|
188
254
|
const message = error instanceof Error ? error.message : String(error);
|
|
189
255
|
const code = error instanceof AppsApiError ? error.code : "CLI_ERROR";
|
|
@@ -191,8 +257,66 @@ function fail(error, json) {
|
|
|
191
257
|
console.log(JSON.stringify({ code, message }, null, 2));
|
|
192
258
|
else
|
|
193
259
|
console.error(formatError(`${code}: ${message}`));
|
|
260
|
+
const hint = RECOVERY_HINTS[code];
|
|
261
|
+
if (hint)
|
|
262
|
+
console.error(hint);
|
|
194
263
|
process.exit(1);
|
|
195
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
|
+
}
|
|
196
320
|
function validateSlug(slug) {
|
|
197
321
|
if (!SLUG_RE.test(slug) || slug.length > 128) {
|
|
198
322
|
throw new Error(`Invalid slug '${slug}'. Must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ and be <=128 chars.`);
|
|
@@ -266,10 +390,105 @@ async function getDriver() {
|
|
|
266
390
|
};
|
|
267
391
|
}
|
|
268
392
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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).
|
|
271
490
|
* Delegates to the shared apply engine (show plan -> authorize -> run -> stop on
|
|
272
|
-
* first error, no rollback).
|
|
491
|
+
* first error, no rollback).
|
|
273
492
|
*/
|
|
274
493
|
async function handleDbInit(name, options, program) {
|
|
275
494
|
const appDir = join(options.dir, name);
|
|
@@ -280,14 +499,25 @@ async function handleDbInit(name, options, program) {
|
|
|
280
499
|
}
|
|
281
500
|
const raw = await readFile(schemaFile, "utf8");
|
|
282
501
|
const json = isJson(options, program);
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
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,
|
|
286
516
|
schema: options.schema,
|
|
287
517
|
dryRun: Boolean(options.dryRun),
|
|
288
518
|
assumeYes: Boolean(options.yes),
|
|
289
519
|
json,
|
|
290
|
-
},
|
|
520
|
+
}, sink);
|
|
291
521
|
if (json) {
|
|
292
522
|
console.log(JSON.stringify({ app: name, ...result }, null, 2));
|
|
293
523
|
}
|
|
@@ -349,10 +579,10 @@ async function handleCapabilities(options, program) {
|
|
|
349
579
|
}
|
|
350
580
|
async function handleLifecycle(action, name, options, program) {
|
|
351
581
|
const manifest = await requireWebappManifest(options.dir, name);
|
|
352
|
-
const
|
|
582
|
+
const { appsDriver, endpoint, tenantId } = await getDriver();
|
|
583
|
+
const deployment = await resolveDeployment(appsDriver, manifest, options.dir, name, { endpoint, tenantId });
|
|
353
584
|
if (!deployment)
|
|
354
585
|
throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
|
|
355
|
-
const { appsDriver } = await getDriver();
|
|
356
586
|
const capabilities = await appsDriver.capabilities();
|
|
357
587
|
assertWebappCapabilities(capabilities, manifest.backend ?? null);
|
|
358
588
|
let mount;
|
|
@@ -378,6 +608,7 @@ async function handleLifecycle(action, name, options, program) {
|
|
|
378
608
|
: { candidateFunctionSlug: deployment.candidateFunctionSlug }),
|
|
379
609
|
};
|
|
380
610
|
await writeManifest(options.dir, name, manifest);
|
|
611
|
+
warnIfFailed(await recordLocalMount(options.dir, name, { mountId: mount.mountId, generation: mount.generation }, { endpoint, tenantId }));
|
|
381
612
|
emitWebappResult(action === "publish"
|
|
382
613
|
? "published"
|
|
383
614
|
: action === "rollback"
|
|
@@ -419,21 +650,23 @@ async function handleDeploy(name, options, program) {
|
|
|
419
650
|
const declaredFiles = mode === "mpa"
|
|
420
651
|
? [...(manifest.pages ?? []), ...(manifest.assets ?? [])]
|
|
421
652
|
: [];
|
|
422
|
-
const { driver, endpoint } = await getDriver();
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
functionsCount = 0; // preflight should not hard-fail if list is unavailable
|
|
429
|
-
}
|
|
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);
|
|
430
659
|
const pre = runPreflight({
|
|
431
660
|
source,
|
|
432
661
|
endpoint,
|
|
433
662
|
functionsCount,
|
|
663
|
+
quotaLimit,
|
|
664
|
+
quotaLimitSource,
|
|
434
665
|
verifyJwt,
|
|
435
666
|
entrypointExists,
|
|
436
667
|
});
|
|
668
|
+
if (flagWarning)
|
|
669
|
+
pre.warnings.unshift(flagWarning);
|
|
437
670
|
// Automatic pxbaas-app-spec compliance review (skip with --skip-spec-check).
|
|
438
671
|
const spec = options.skipSpecCheck
|
|
439
672
|
? { skipped: true, passed: true, blockers: [], warnings: [] }
|
|
@@ -447,9 +680,8 @@ async function handleDeploy(name, options, program) {
|
|
|
447
680
|
auxiliarySources,
|
|
448
681
|
}),
|
|
449
682
|
};
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
const allWarnings = [...pre.warnings, ...spec.warnings.map(fmt)];
|
|
683
|
+
const allBlockers = [...pre.blockers, ...spec.blockers.map(formatFinding)];
|
|
684
|
+
const allWarnings = [...pre.warnings, ...spec.warnings.map(formatFinding)];
|
|
453
685
|
if (options.dryRun) {
|
|
454
686
|
const out = {
|
|
455
687
|
status: "dry-run",
|
|
@@ -551,15 +783,16 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
|
|
|
551
783
|
backend.framework = framework ?? backend.framework;
|
|
552
784
|
backend.verifyJwt = options.verifyJwt ?? backend.verifyJwt;
|
|
553
785
|
}
|
|
554
|
-
const { driver, appsDriver } = await getDriver();
|
|
786
|
+
const { driver, appsDriver, endpoint, tenantId } = await getDriver();
|
|
555
787
|
const capabilities = await appsDriver.capabilities();
|
|
556
788
|
assertWebappCapabilities(capabilities, backend);
|
|
557
789
|
const appDir = join(options.dir, name);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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);
|
|
561
794
|
const frontendDir = options.frontend ?? defaultFrontendDir;
|
|
562
|
-
const frontendRaw = await collectStagingFiles(frontendDir);
|
|
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.");
|
|
563
796
|
const frontendFiles = frontendRaw.map((file) => `${frontend.root}/${file.relativePath}`.replace(/\/+/g, "/"));
|
|
564
797
|
const frontendUpload = frontendRaw
|
|
565
798
|
.filter((file) => !file.relativePath.endsWith("/"))
|
|
@@ -649,29 +882,91 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
|
|
|
649
882
|
sessionHeaders: capabilities.sessionHeaders,
|
|
650
883
|
}),
|
|
651
884
|
};
|
|
652
|
-
|
|
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) {
|
|
653
889
|
const details = spec.blockers
|
|
654
890
|
.map((item) => `[${item.rule}] ${item.message}`)
|
|
655
891
|
.join("; ");
|
|
656
892
|
throw new AppsApiError("APP_ARTIFACT_INVALID", details);
|
|
657
893
|
}
|
|
658
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
|
+
};
|
|
659
909
|
if (options.dryRun) {
|
|
660
910
|
const output = {
|
|
661
911
|
status: "dry-run",
|
|
662
912
|
app: name,
|
|
663
913
|
mode: "webapp",
|
|
664
914
|
capabilities,
|
|
915
|
+
preflight,
|
|
665
916
|
specLint: spec,
|
|
666
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
|
+
];
|
|
667
926
|
if (isJson(options, program))
|
|
668
927
|
console.log(JSON.stringify(output, null, 2));
|
|
669
|
-
else
|
|
670
|
-
|
|
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);
|
|
671
941
|
return;
|
|
672
942
|
}
|
|
673
|
-
|
|
674
|
-
|
|
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)))
|
|
675
970
|
: await appsDriver.create({ appName: name });
|
|
676
971
|
const candidateFunctionSlug = createCandidateSlug(mount.id);
|
|
677
972
|
const entrypoint = backend?.entrypoint ?? `${frontend.root}/${frontend.index}`;
|
|
@@ -688,6 +983,10 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
|
|
|
688
983
|
generation: mount.generation,
|
|
689
984
|
};
|
|
690
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 }));
|
|
691
990
|
const published = await appsDriver.publish(mount.mountId, {
|
|
692
991
|
candidateFunctionSlug,
|
|
693
992
|
expectedGeneration: mount.generation,
|
|
@@ -697,7 +996,14 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
|
|
|
697
996
|
generation: published.generation,
|
|
698
997
|
};
|
|
699
998
|
await writeManifest(options.dir, name, manifest);
|
|
700
|
-
|
|
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));
|
|
701
1007
|
}
|
|
702
1008
|
function parseBackendFramework(value) {
|
|
703
1009
|
if (value === undefined)
|
|
@@ -730,9 +1036,9 @@ function assertWebappCapabilities(capabilities, backend) {
|
|
|
730
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\".");
|
|
731
1037
|
}
|
|
732
1038
|
}
|
|
733
|
-
async function collectStagingFiles(root, current = root) {
|
|
1039
|
+
async function collectStagingFiles(root, current = root, hint) {
|
|
734
1040
|
if (!existsSync(root))
|
|
735
|
-
throw new AppsApiError("APP_ARTIFACT_INVALID", `Artifact directory not found: ${root}`);
|
|
1041
|
+
throw new AppsApiError("APP_ARTIFACT_INVALID", `Artifact directory not found: ${root}. ${hint ?? "Create it (build the frontend) before deploying."}`);
|
|
736
1042
|
const files = [];
|
|
737
1043
|
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
738
1044
|
const full = join(current, entry.name);
|
|
@@ -741,7 +1047,7 @@ async function collectStagingFiles(root, current = root) {
|
|
|
741
1047
|
if (entry.name === "node_modules")
|
|
742
1048
|
files.push({ path: full, relativePath: `${relativePath}/` });
|
|
743
1049
|
else if (entry.name !== ".git")
|
|
744
|
-
files.push(...(await collectStagingFiles(root, full)));
|
|
1050
|
+
files.push(...(await collectStagingFiles(root, full, hint)));
|
|
745
1051
|
}
|
|
746
1052
|
else if (entry.isFile()) {
|
|
747
1053
|
files.push({ path: full, relativePath });
|
|
@@ -1037,13 +1343,18 @@ async function handleWebappVerify(name, manifest, options, program) {
|
|
|
1037
1343
|
}
|
|
1038
1344
|
async function handleUrl(name, options, program) {
|
|
1039
1345
|
const manifest = await readManifest(options.dir, name);
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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)
|
|
1043
1354
|
throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
|
|
1044
1355
|
const { appsDriver } = await getDriver();
|
|
1045
1356
|
const [mount, capabilities] = await Promise.all([
|
|
1046
|
-
appsDriver.get(
|
|
1357
|
+
appsDriver.get(mountId),
|
|
1047
1358
|
appsDriver.capabilities(),
|
|
1048
1359
|
]);
|
|
1049
1360
|
emitWebappResult("url", name, mount, capabilities, options, program);
|
|
@@ -1061,6 +1372,75 @@ async function handleUrl(name, options, program) {
|
|
|
1061
1372
|
if (!publicReachable)
|
|
1062
1373
|
console.log(formatWarning("Endpoint is not publicly reachable; this URL will not work cross-machine."));
|
|
1063
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
|
+
}
|
|
1064
1444
|
async function handleList(options, program) {
|
|
1065
1445
|
const { driver } = await getDriver();
|
|
1066
1446
|
const functions = await driver.list();
|
|
@@ -1079,6 +1459,13 @@ async function handleList(options, program) {
|
|
|
1079
1459
|
async function handleRemove(name, options, program) {
|
|
1080
1460
|
const json = isJson(options, program);
|
|
1081
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
|
+
}
|
|
1082
1469
|
const slug = options.slug ?? manifest.slug ?? name;
|
|
1083
1470
|
if (!options.confirm) {
|
|
1084
1471
|
const preview = {
|
|
@@ -1100,6 +1487,228 @@ async function handleRemove(name, options, program) {
|
|
|
1100
1487
|
else
|
|
1101
1488
|
console.log(formatSuccess(`Removed function '${slug}'.`));
|
|
1102
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
|
+
}
|
|
1103
1712
|
function registerShareCommands(appCmd, program) {
|
|
1104
1713
|
const shareCmd = appCmd
|
|
1105
1714
|
.command("share")
|
|
@@ -1174,6 +1783,31 @@ function parsePositiveInteger(value, optionName) {
|
|
|
1174
1783
|
}
|
|
1175
1784
|
return parsed;
|
|
1176
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
|
+
}
|
|
1177
1811
|
async function handleShareCreate(slug, options, program) {
|
|
1178
1812
|
validateSlug(slug);
|
|
1179
1813
|
if (options.expiresAt) {
|
|
@@ -1189,7 +1823,8 @@ async function handleShareCreate(slug, options, program) {
|
|
|
1189
1823
|
const expiresInSeconds = options.expiresAt
|
|
1190
1824
|
? undefined
|
|
1191
1825
|
: parsePositiveInteger(options.expiresIn, "--expires-in");
|
|
1192
|
-
const { shareDriver, endpoint } = await getDriver();
|
|
1826
|
+
const { shareDriver, appsDriver, endpoint } = await getDriver();
|
|
1827
|
+
await assertPrivateShareEnabled(appsDriver, "create");
|
|
1193
1828
|
const link = await shareDriver.create(slug, {
|
|
1194
1829
|
expiresAt: options.expiresAt,
|
|
1195
1830
|
expiresInSeconds,
|
|
@@ -1241,7 +1876,11 @@ async function handleShareRevoke(slug, shareId, options, program) {
|
|
|
1241
1876
|
console.log(formatWarning(`Preview: revoke share '${shareId}' for '${slug}'. Re-run with --confirm.`));
|
|
1242
1877
|
return;
|
|
1243
1878
|
}
|
|
1244
|
-
|
|
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");
|
|
1245
1884
|
const share = await shareDriver.revoke(slug, shareId, options.tenantId);
|
|
1246
1885
|
if (isJson(options, program)) {
|
|
1247
1886
|
console.log(JSON.stringify({ status: "revoked", slug, share }, null, 2));
|
|
@@ -1251,7 +1890,8 @@ async function handleShareRevoke(slug, shareId, options, program) {
|
|
|
1251
1890
|
}
|
|
1252
1891
|
async function handleShareVerify(slug, shareId, options, program) {
|
|
1253
1892
|
validateSlug(slug);
|
|
1254
|
-
const { shareDriver } = await getDriver();
|
|
1893
|
+
const { shareDriver, appsDriver } = await getDriver();
|
|
1894
|
+
await assertPrivateShareEnabled(appsDriver, "verify");
|
|
1255
1895
|
const share = await shareDriver.get(slug, shareId, options.tenantId);
|
|
1256
1896
|
const output = {
|
|
1257
1897
|
status: share.status === "ACTIVE" ? "active" : "inactive",
|