@polardbx/pxbaas 0.1.0-beta.2 → 0.1.0-beta.4
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 +116 -13
- 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/cli/root.d.ts.map +1 -1
- package/dist/cli/root.js +3 -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 +428 -44
- 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 +14 -0
- package/dist/commands/db/index.d.ts.map +1 -1
- package/dist/commands/db/index.js +151 -40
- 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/login/index.d.ts.map +1 -1
- package/dist/commands/login/index.js +15 -30
- 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 +13 -1
- package/dist/commands/profile/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 +662 -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/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/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/main.js +21 -2
- package/dist/main.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 +2 -2
- package/skills/pxbaas-app-spec/references/runtime-contract.md +2 -2
- package/skills/pxbaas-cli/SKILL.md +14 -5
- package/skills/pxbaas-cli/references/auth-and-credentials.md +19 -3
- package/skills/pxbaas-cli/references/data-postgrest.md +19 -3
- package/skills/pxbaas-cli/references/db-and-meta.md +5 -0
- 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/react-vite-hono/backend/index.ts +21 -4
- package/templates/webapp/ssr-hono/backend/index.ts +50 -31
|
@@ -7,7 +7,8 @@ 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
13
|
import { applySchema, getSchemaSink, } from "../../db/apply-schema.js";
|
|
13
14
|
import { parseRlsJson, extractCreateTableNames } from "../../db/sql-file.js";
|
|
@@ -55,6 +56,7 @@ export function registerAppCommands(program) {
|
|
|
55
56
|
.option("--verify-jwt", "Require JWT (safer, but the URL is no longer publicly openable)")
|
|
56
57
|
.option("--dry-run", "Run preflight + spec review and show the plan without deploying")
|
|
57
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.")
|
|
58
60
|
.option("--json", "Output in JSON format")
|
|
59
61
|
.action(async (name, options) => {
|
|
60
62
|
try {
|
|
@@ -111,6 +113,7 @@ export function registerAppCommands(program) {
|
|
|
111
113
|
.description("Print the shareable URL for a deployed webapp")
|
|
112
114
|
.option("--dir <directory>", "Parent directory for the app", "./apps")
|
|
113
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)")
|
|
114
117
|
.option("--json", "Output in JSON format")
|
|
115
118
|
.action(async (name, options) => {
|
|
116
119
|
try {
|
|
@@ -120,6 +123,21 @@ export function registerAppCommands(program) {
|
|
|
120
123
|
fail(e, options.json);
|
|
121
124
|
}
|
|
122
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
|
+
});
|
|
123
141
|
appCmd
|
|
124
142
|
.command("list")
|
|
125
143
|
.description("List deployed edge functions (apps)")
|
|
@@ -152,6 +170,7 @@ export function registerAppCommands(program) {
|
|
|
152
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.")
|
|
153
171
|
.option("--dir <directory>", "Parent directory for the app", "./apps")
|
|
154
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")
|
|
155
174
|
.option("--purge-db", "Also drop the app's DB tables and remove its RLS registry rows (destructive)")
|
|
156
175
|
.option("--schema <schema>", "Single-tenant schema the app's tables live in (used with --purge-db)", "public")
|
|
157
176
|
.option("--json", "Output in JSON format")
|
|
@@ -201,6 +220,37 @@ export function registerAppCommands(program) {
|
|
|
201
220
|
function isJson(options, program) {
|
|
202
221
|
return Boolean(options.json || program.opts().json);
|
|
203
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Recovery guidance for the local-state/server-state desync errors.
|
|
225
|
+
*
|
|
226
|
+
* These three used to dead-end the operator: the app is still live on the
|
|
227
|
+
* instance, but with no usable mountId the CLI can neither publish nor destroy
|
|
228
|
+
* it, and the bare server message ("App mount not found") gives no way out. The
|
|
229
|
+
* hints are printed separately instead of being folded into `message` so the
|
|
230
|
+
* server-owned `{code,message}` payload stays untouched for `--json` consumers.
|
|
231
|
+
*/
|
|
232
|
+
const RECOVERY_HINTS = {
|
|
233
|
+
APP_MOUNT_NOT_FOUND: [
|
|
234
|
+
" 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.",
|
|
235
|
+
" Get the real mountId from the last successful deploy output, `pxbaas app url <name> --json`, or `.pxbaas/apps.json`, then:",
|
|
236
|
+
" pxbaas app url <name> --mount-id <m-...> # is the app still live?",
|
|
237
|
+
" pxbaas app adopt <name> --mount-id <m-...> [--generation <n>] # take it over; deploy/publish/rollback/destroy work again",
|
|
238
|
+
" pxbaas app destroy <name> --confirm --mount-id <m-...> # or reclaim it",
|
|
239
|
+
" Only when the server truly has no such App: delete the `deployment` block from app.json and re-run `pxbaas app deploy <name>`.",
|
|
240
|
+
].join("\n"),
|
|
241
|
+
APP_MOUNT_CONFLICT: [
|
|
242
|
+
" 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.",
|
|
243
|
+
" Get the real mountId from the last successful deploy output, `pxbaas app url <name> --json`, or `.pxbaas/apps.json`, then either:",
|
|
244
|
+
" pxbaas app adopt <name> --mount-id <m-...> # take the existing mount over, then deploy again",
|
|
245
|
+
" pxbaas app destroy <name> --confirm --mount-id <m-...> # reclaim it; the next deploy creates a fresh mount",
|
|
246
|
+
].join("\n"),
|
|
247
|
+
APP_DEPLOYMENT_NOT_FOUND: [
|
|
248
|
+
" 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):",
|
|
249
|
+
" pxbaas app destroy <name> --confirm --mount-id <m-...> # destroy without any local state",
|
|
250
|
+
" pxbaas app adopt <name> --mount-id <m-...> # write the server mount back into app.json",
|
|
251
|
+
" pxbaas app url <name> --mount-id <m-...> # just check whether it is still live",
|
|
252
|
+
].join("\n"),
|
|
253
|
+
};
|
|
204
254
|
function fail(error, json) {
|
|
205
255
|
const message = error instanceof Error ? error.message : String(error);
|
|
206
256
|
const code = error instanceof AppsApiError ? error.code : "CLI_ERROR";
|
|
@@ -208,8 +258,19 @@ function fail(error, json) {
|
|
|
208
258
|
console.log(JSON.stringify({ code, message }, null, 2));
|
|
209
259
|
else
|
|
210
260
|
console.error(formatError(`${code}: ${message}`));
|
|
261
|
+
const hint = RECOVERY_HINTS[code];
|
|
262
|
+
if (hint)
|
|
263
|
+
console.error(hint);
|
|
211
264
|
process.exit(1);
|
|
212
265
|
}
|
|
266
|
+
/** Surface a best-effort side effect (the workspace index) without failing. */
|
|
267
|
+
function warnIfFailed(message) {
|
|
268
|
+
if (message)
|
|
269
|
+
console.error(formatWarning(message));
|
|
270
|
+
}
|
|
271
|
+
function formatFinding(f) {
|
|
272
|
+
return `[${f.rule}] ${f.message} (see ${f.specRef})`;
|
|
273
|
+
}
|
|
213
274
|
function validateSlug(slug) {
|
|
214
275
|
if (!SLUG_RE.test(slug) || slug.length > 128) {
|
|
215
276
|
throw new Error(`Invalid slug '${slug}'. Must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ and be <=128 chars.`);
|
|
@@ -282,6 +343,98 @@ async function getDriver() {
|
|
|
282
343
|
anonKey: credentials.anonKey,
|
|
283
344
|
};
|
|
284
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Resolve the effective Edge Function quota limit and its source for preflight.
|
|
348
|
+
* Precedence: --quota-limit (flag) > instance capabilities.maxFunctions
|
|
349
|
+
* (platform) > client fallback (advisory, non-blocking). Never throws — an
|
|
350
|
+
* unavailable capabilities read degrades to the fallback so a quota lookup can
|
|
351
|
+
* never block a deploy; the server-side DB trigger remains the authoritative
|
|
352
|
+
* gate. (gomoku retrospective 弯路5)
|
|
353
|
+
*/
|
|
354
|
+
async function resolveQuotaLimit(appsDriver, flagValue) {
|
|
355
|
+
const parsed = flagValue != null ? Number(flagValue) : NaN;
|
|
356
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
357
|
+
return { quotaLimit: Math.floor(parsed), quotaLimitSource: "flag" };
|
|
358
|
+
}
|
|
359
|
+
let quotaLimit;
|
|
360
|
+
let quotaLimitSource = "fallback";
|
|
361
|
+
try {
|
|
362
|
+
const caps = await appsDriver.capabilities();
|
|
363
|
+
const max = caps?.maxFunctions;
|
|
364
|
+
if (typeof max === "number" && Number.isFinite(max) && max > 0) {
|
|
365
|
+
quotaLimit = max;
|
|
366
|
+
quotaLimitSource = "platform";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// fall through to the advisory client fallback
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
quotaLimit,
|
|
374
|
+
quotaLimitSource,
|
|
375
|
+
// The flag is documented as authoritative, so a value the CLI cannot honor
|
|
376
|
+
// must never be dropped without a word (缺陷3 Q2).
|
|
377
|
+
...(flagValue != null
|
|
378
|
+
? {
|
|
379
|
+
flagWarning: `--quota-limit '${flagValue}' was NOT consumed: it must be a positive integer. ` +
|
|
380
|
+
`This run fell back to the ${quotaLimitSource} limit (${quotaLimit ?? FALLBACK_QUOTA_LIMIT}). ` +
|
|
381
|
+
`Re-run with e.g. --quota-limit 500 to make the quota check authoritative.`,
|
|
382
|
+
}
|
|
383
|
+
: {}),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Edge Function count for the quota gate. A failing `list` degrades to 0 rather
|
|
388
|
+
* than hard-failing: preflight must never be the reason a deploy breaks, and an
|
|
389
|
+
* unreadable count only weakens an advisory check (弯路5).
|
|
390
|
+
*/
|
|
391
|
+
async function safeFunctionsCount(driver) {
|
|
392
|
+
try {
|
|
393
|
+
return (await driver.list()).length;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Resolve the deployment state a lifecycle command should act on: `app.json`
|
|
401
|
+
* first, then the workspace index (缺陷5 A4).
|
|
402
|
+
*
|
|
403
|
+
* Without the index fallback, a manifest that lost its `deployment` block leaves
|
|
404
|
+
* a live mount unreachable — deploy answers APP_MOUNT_CONFLICT and destroy
|
|
405
|
+
* answers APP_DEPLOYMENT_NOT_FOUND. An index entry whose mount is gone
|
|
406
|
+
* server-side is dropped on the spot so the caller can create a fresh one.
|
|
407
|
+
* Returns undefined when neither source knows the app.
|
|
408
|
+
*/
|
|
409
|
+
async function resolveDeployment(appsDriver, manifest, dir, name, scope) {
|
|
410
|
+
if (manifest.deployment) {
|
|
411
|
+
return { ...manifest.deployment, source: "manifest" };
|
|
412
|
+
}
|
|
413
|
+
const found = await lookupLocalMount(dir, name, scope);
|
|
414
|
+
if (found.ignored)
|
|
415
|
+
console.error(formatWarning(found.ignored));
|
|
416
|
+
if (!found.entry)
|
|
417
|
+
return undefined;
|
|
418
|
+
try {
|
|
419
|
+
const mount = await appsDriver.get(found.entry.mountId);
|
|
420
|
+
console.error(formatWarning(`↻ Recovered the deployment state for '${name}' from ${found.path} ` +
|
|
421
|
+
`(mount ${mount.mountId}, generation ${mount.generation}); app.json has none. ` +
|
|
422
|
+
`Run \`pxbaas app adopt ${name} --mount-id ${mount.mountId}\` to write it back permanently.`));
|
|
423
|
+
return {
|
|
424
|
+
mountId: mount.mountId,
|
|
425
|
+
generation: mount.generation,
|
|
426
|
+
source: "index",
|
|
427
|
+
mount,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
if (!(e instanceof AppsApiError) || e.code !== "APP_MOUNT_NOT_FOUND")
|
|
432
|
+
throw e;
|
|
433
|
+
warnIfFailed(await forgetLocalMount(dir, name, found.entry.mountId));
|
|
434
|
+
console.error(formatWarning(`Dropping the ${found.path} entry for '${name}': mount ${found.entry.mountId} no longer exists server-side.`));
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
285
438
|
/**
|
|
286
439
|
* `pxbaas app db-init <name>` — apply the app's bundled db/schema.sql (plus an
|
|
287
440
|
* optional declarative db/rls.json) so a freshly published app has its tables +
|
|
@@ -380,10 +533,10 @@ async function handleCapabilities(options, program) {
|
|
|
380
533
|
}
|
|
381
534
|
async function handleLifecycle(action, name, options, program) {
|
|
382
535
|
const manifest = await requireWebappManifest(options.dir, name);
|
|
383
|
-
const
|
|
536
|
+
const { appsDriver, endpoint, tenantId } = await getDriver();
|
|
537
|
+
const deployment = await resolveDeployment(appsDriver, manifest, options.dir, name, { endpoint, tenantId });
|
|
384
538
|
if (!deployment)
|
|
385
539
|
throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
|
|
386
|
-
const { appsDriver } = await getDriver();
|
|
387
540
|
const capabilities = await appsDriver.capabilities();
|
|
388
541
|
assertWebappCapabilities(capabilities, manifest.backend ?? null);
|
|
389
542
|
let mount;
|
|
@@ -409,6 +562,7 @@ async function handleLifecycle(action, name, options, program) {
|
|
|
409
562
|
: { candidateFunctionSlug: deployment.candidateFunctionSlug }),
|
|
410
563
|
};
|
|
411
564
|
await writeManifest(options.dir, name, manifest);
|
|
565
|
+
warnIfFailed(await recordLocalMount(options.dir, name, { mountId: mount.mountId, generation: mount.generation }, { endpoint, tenantId }));
|
|
412
566
|
emitWebappResult(action === "publish"
|
|
413
567
|
? "published"
|
|
414
568
|
: action === "rollback"
|
|
@@ -450,21 +604,23 @@ async function handleDeploy(name, options, program) {
|
|
|
450
604
|
const declaredFiles = mode === "mpa"
|
|
451
605
|
? [...(manifest.pages ?? []), ...(manifest.assets ?? [])]
|
|
452
606
|
: [];
|
|
453
|
-
const { driver, endpoint } = await getDriver();
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
functionsCount = 0; // preflight should not hard-fail if list is unavailable
|
|
460
|
-
}
|
|
607
|
+
const { driver, endpoint, appsDriver } = await getDriver();
|
|
608
|
+
const functionsCount = await safeFunctionsCount(driver);
|
|
609
|
+
// Resolve the authoritative Edge Function quota: --quota-limit (flag) wins,
|
|
610
|
+
// else the instance-reported capabilities.maxFunctions (platform), else the
|
|
611
|
+
// client fallback (advisory only). Fixes the hardcoded-5 false block (弯路5).
|
|
612
|
+
const { quotaLimit, quotaLimitSource, flagWarning } = await resolveQuotaLimit(appsDriver, options.quotaLimit);
|
|
461
613
|
const pre = runPreflight({
|
|
462
614
|
source,
|
|
463
615
|
endpoint,
|
|
464
616
|
functionsCount,
|
|
617
|
+
quotaLimit,
|
|
618
|
+
quotaLimitSource,
|
|
465
619
|
verifyJwt,
|
|
466
620
|
entrypointExists,
|
|
467
621
|
});
|
|
622
|
+
if (flagWarning)
|
|
623
|
+
pre.warnings.unshift(flagWarning);
|
|
468
624
|
// Automatic pxbaas-app-spec compliance review (skip with --skip-spec-check).
|
|
469
625
|
const spec = options.skipSpecCheck
|
|
470
626
|
? { skipped: true, passed: true, blockers: [], warnings: [] }
|
|
@@ -478,9 +634,8 @@ async function handleDeploy(name, options, program) {
|
|
|
478
634
|
auxiliarySources,
|
|
479
635
|
}),
|
|
480
636
|
};
|
|
481
|
-
const
|
|
482
|
-
const
|
|
483
|
-
const allWarnings = [...pre.warnings, ...spec.warnings.map(fmt)];
|
|
637
|
+
const allBlockers = [...pre.blockers, ...spec.blockers.map(formatFinding)];
|
|
638
|
+
const allWarnings = [...pre.warnings, ...spec.warnings.map(formatFinding)];
|
|
484
639
|
if (options.dryRun) {
|
|
485
640
|
const out = {
|
|
486
641
|
status: "dry-run",
|
|
@@ -582,7 +737,7 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
|
|
|
582
737
|
backend.framework = framework ?? backend.framework;
|
|
583
738
|
backend.verifyJwt = options.verifyJwt ?? backend.verifyJwt;
|
|
584
739
|
}
|
|
585
|
-
const { driver, appsDriver } = await getDriver();
|
|
740
|
+
const { driver, appsDriver, endpoint, tenantId } = await getDriver();
|
|
586
741
|
const capabilities = await appsDriver.capabilities();
|
|
587
742
|
assertWebappCapabilities(capabilities, backend);
|
|
588
743
|
const appDir = join(options.dir, name);
|
|
@@ -680,29 +835,91 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
|
|
|
680
835
|
sessionHeaders: capabilities.sessionHeaders,
|
|
681
836
|
}),
|
|
682
837
|
};
|
|
683
|
-
|
|
838
|
+
// A dry-run reports every finding instead of aborting on the first one, so the
|
|
839
|
+
// spec blockers are only fatal on the real deploy path (order preserved there:
|
|
840
|
+
// spec review before the artifact size limits).
|
|
841
|
+
if (!spec.passed && !options.dryRun) {
|
|
684
842
|
const details = spec.blockers
|
|
685
843
|
.map((item) => `[${item.rule}] ${item.message}`)
|
|
686
844
|
.join("; ");
|
|
687
845
|
throw new AppsApiError("APP_ARTIFACT_INVALID", details);
|
|
688
846
|
}
|
|
689
847
|
await assertArtifactLimits(uploadFiles, capabilities);
|
|
848
|
+
// Q1: this path used to skip preflight entirely, so --quota-limit was silently
|
|
849
|
+
// ignored on every webapp deploy. Only the quota half of runPreflight is reused:
|
|
850
|
+
// a webapp has no single-file `source`/entrypoint, and inventing those fields
|
|
851
|
+
// just to call runPreflight would fake checks the artifact lint already owns.
|
|
852
|
+
const functionsCount = await safeFunctionsCount(driver);
|
|
853
|
+
const { quotaLimit, quotaLimitSource, flagWarning } = await resolveQuotaLimit(appsDriver, options.quotaLimit);
|
|
854
|
+
const quota = checkQuota(functionsCount, quotaLimit, quotaLimitSource);
|
|
855
|
+
const quotaReport = quotaFindings(quota);
|
|
856
|
+
const preflight = {
|
|
857
|
+
quota,
|
|
858
|
+
quotaLimitSource,
|
|
859
|
+
blockers: quotaReport.blockers,
|
|
860
|
+
warnings: [...(flagWarning ? [flagWarning] : []), ...quotaReport.warnings],
|
|
861
|
+
};
|
|
690
862
|
if (options.dryRun) {
|
|
691
863
|
const output = {
|
|
692
864
|
status: "dry-run",
|
|
693
865
|
app: name,
|
|
694
866
|
mode: "webapp",
|
|
695
867
|
capabilities,
|
|
868
|
+
preflight,
|
|
696
869
|
specLint: spec,
|
|
697
870
|
};
|
|
871
|
+
const allBlockers = [
|
|
872
|
+
...preflight.blockers,
|
|
873
|
+
...spec.blockers.map(formatFinding),
|
|
874
|
+
];
|
|
875
|
+
const allWarnings = [
|
|
876
|
+
...preflight.warnings,
|
|
877
|
+
...spec.warnings.map(formatFinding),
|
|
878
|
+
];
|
|
698
879
|
if (isJson(options, program))
|
|
699
880
|
console.log(JSON.stringify(output, null, 2));
|
|
700
|
-
else
|
|
701
|
-
|
|
881
|
+
else {
|
|
882
|
+
// Q2: the human-mode dry-run used to print a single success line and swallow
|
|
883
|
+
// every specLint/preflight warning the JSON payload carried.
|
|
884
|
+
console.log(formatWarning(`Dry run for webapp '${name}'`));
|
|
885
|
+
for (const b of allBlockers)
|
|
886
|
+
console.error(formatError(b));
|
|
887
|
+
for (const w of allWarnings)
|
|
888
|
+
console.log(formatWarning(w));
|
|
889
|
+
if (allBlockers.length === 0)
|
|
890
|
+
console.log(formatSuccess(`Webapp '${name}' passed capability and artifact checks. Re-run without --dry-run to deploy.`));
|
|
891
|
+
}
|
|
892
|
+
if (allBlockers.length > 0)
|
|
893
|
+
process.exit(1);
|
|
702
894
|
return;
|
|
703
895
|
}
|
|
704
|
-
|
|
705
|
-
|
|
896
|
+
// Q3: a real deploy consumes the quota result too. Only an authoritative limit
|
|
897
|
+
// (flag/platform) blocks; the client fallback stays a warning, so an instance
|
|
898
|
+
// that does not report maxFunctions is never gated by a client-side guess.
|
|
899
|
+
if (preflight.blockers.length > 0) {
|
|
900
|
+
if (isJson(options, program)) {
|
|
901
|
+
console.log(JSON.stringify({
|
|
902
|
+
status: "blocked",
|
|
903
|
+
app: name,
|
|
904
|
+
mode: "webapp",
|
|
905
|
+
preflight,
|
|
906
|
+
specLint: spec,
|
|
907
|
+
}, null, 2));
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
console.error(formatError("Edge Function quota check failed — fix these before deploying:"));
|
|
911
|
+
for (const b of preflight.blockers)
|
|
912
|
+
console.error(formatError(b));
|
|
913
|
+
for (const w of preflight.warnings)
|
|
914
|
+
console.log(formatWarning(w));
|
|
915
|
+
}
|
|
916
|
+
process.exit(1);
|
|
917
|
+
}
|
|
918
|
+
const resolved = await resolveDeployment(appsDriver, manifest, options.dir, name, { endpoint, tenantId });
|
|
919
|
+
// Neither app.json nor the workspace index knows this app -> first deploy, so a
|
|
920
|
+
// fresh mount is created (a name clash surfaces as APP_MOUNT_CONFLICT).
|
|
921
|
+
const mount = resolved
|
|
922
|
+
? (resolved.mount ?? (await appsDriver.get(resolved.mountId)))
|
|
706
923
|
: await appsDriver.create({ appName: name });
|
|
707
924
|
const candidateFunctionSlug = createCandidateSlug(mount.id);
|
|
708
925
|
const entrypoint = backend?.entrypoint ?? `${frontend.root}/${frontend.index}`;
|
|
@@ -719,6 +936,10 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
|
|
|
719
936
|
generation: mount.generation,
|
|
720
937
|
};
|
|
721
938
|
await writeManifest(options.dir, name, manifest);
|
|
939
|
+
// A4: mirror the deployment state into the workspace index as soon as the
|
|
940
|
+
// candidate is uploaded, so a mount is recoverable even if the publish below
|
|
941
|
+
// fails and app.json is later lost.
|
|
942
|
+
warnIfFailed(await recordLocalMount(options.dir, name, { mountId: mount.mountId, generation: mount.generation }, { endpoint, tenantId }));
|
|
722
943
|
const published = await appsDriver.publish(mount.mountId, {
|
|
723
944
|
candidateFunctionSlug,
|
|
724
945
|
expectedGeneration: mount.generation,
|
|
@@ -728,7 +949,14 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
|
|
|
728
949
|
generation: published.generation,
|
|
729
950
|
};
|
|
730
951
|
await writeManifest(options.dir, name, manifest);
|
|
731
|
-
|
|
952
|
+
warnIfFailed(await recordLocalMount(options.dir, name, { mountId: published.mountId, generation: published.generation }, { endpoint, tenantId }));
|
|
953
|
+
emitWebappResult("published", name, published, capabilities, options, program, { candidateFunctionSlug, specLint: spec, preflight });
|
|
954
|
+
// The quota advisories (fallback tier, an unconsumed --quota-limit) must not be
|
|
955
|
+
// JSON-only: a human-mode deploy that silently ignored the flag is the bug Q2
|
|
956
|
+
// is about.
|
|
957
|
+
if (!isJson(options, program))
|
|
958
|
+
for (const w of preflight.warnings)
|
|
959
|
+
console.log(formatWarning(w));
|
|
732
960
|
}
|
|
733
961
|
function parseBackendFramework(value) {
|
|
734
962
|
if (value === undefined)
|
|
@@ -1068,13 +1296,18 @@ async function handleWebappVerify(name, manifest, options, program) {
|
|
|
1068
1296
|
}
|
|
1069
1297
|
async function handleUrl(name, options, program) {
|
|
1070
1298
|
const manifest = await readManifest(options.dir, name);
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1299
|
+
// A2: --mount-id makes `app url` usable with no local state at all, which is
|
|
1300
|
+
// exactly when an operator needs to check whether the app is still live.
|
|
1301
|
+
if (options.mountId || manifest.mode === "webapp") {
|
|
1302
|
+
const webapp = manifest.mode === "webapp"
|
|
1303
|
+
? requireWebappManifestValue(manifest, name)
|
|
1304
|
+
: null;
|
|
1305
|
+
const mountId = options.mountId ?? webapp?.deployment?.mountId;
|
|
1306
|
+
if (!mountId)
|
|
1074
1307
|
throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
|
|
1075
1308
|
const { appsDriver } = await getDriver();
|
|
1076
1309
|
const [mount, capabilities] = await Promise.all([
|
|
1077
|
-
appsDriver.get(
|
|
1310
|
+
appsDriver.get(mountId),
|
|
1078
1311
|
appsDriver.capabilities(),
|
|
1079
1312
|
]);
|
|
1080
1313
|
emitWebappResult("url", name, mount, capabilities, options, program);
|
|
@@ -1092,6 +1325,75 @@ async function handleUrl(name, options, program) {
|
|
|
1092
1325
|
if (!publicReachable)
|
|
1093
1326
|
console.log(formatWarning("Endpoint is not publicly reachable; this URL will not work cross-machine."));
|
|
1094
1327
|
}
|
|
1328
|
+
/**
|
|
1329
|
+
* `pxbaas app adopt <name> --mount-id <m-...>` (缺陷5 A2) — write a mount that
|
|
1330
|
+
* already exists server-side back into the local `app.json` deployment block.
|
|
1331
|
+
*
|
|
1332
|
+
* This is the escape hatch for the deadlock where the app is live but this
|
|
1333
|
+
* working copy lost its mountId: deploy answers APP_MOUNT_CONFLICT and destroy
|
|
1334
|
+
* answers APP_DEPLOYMENT_NOT_FOUND, so nothing else can reach the mount. The
|
|
1335
|
+
* mount is verified with a GET before anything is written — adopting an unknown
|
|
1336
|
+
* mountId would only move the deadlock to the next lifecycle call.
|
|
1337
|
+
*/
|
|
1338
|
+
async function handleAdopt(name, options, program) {
|
|
1339
|
+
const mountId = options.mountId?.trim();
|
|
1340
|
+
if (!mountId) {
|
|
1341
|
+
throw new AppsApiError("APP_MOUNT_ID_REQUIRED", `--mount-id is required: \`pxbaas app adopt ${name} --mount-id <m-...>\`. ` +
|
|
1342
|
+
"Find the id in the last successful deploy output or `pxbaas app url <name> --json`.");
|
|
1343
|
+
}
|
|
1344
|
+
let generation;
|
|
1345
|
+
if (options.generation != null) {
|
|
1346
|
+
if (!/^\d+$/.test(options.generation)) {
|
|
1347
|
+
throw new Error("--generation must be a non-negative integer.");
|
|
1348
|
+
}
|
|
1349
|
+
generation = Number(options.generation);
|
|
1350
|
+
}
|
|
1351
|
+
const manifestPath = join(options.dir, name, "app.json");
|
|
1352
|
+
if (!existsSync(manifestPath)) {
|
|
1353
|
+
throw new AppsApiError("APP_MANIFEST_NOT_FOUND", `No app.json for app '${name}' at ${manifestPath}. ` +
|
|
1354
|
+
`Scaffold it first (\`pxbaas app new ${name} --dir ${options.dir}\`) or fix --dir, ` +
|
|
1355
|
+
`then re-run \`pxbaas app adopt ${name} --mount-id ${mountId}\`.`);
|
|
1356
|
+
}
|
|
1357
|
+
const manifest = requireWebappManifestValue(await readManifest(options.dir, name), name);
|
|
1358
|
+
const { appsDriver, endpoint, tenantId } = await getDriver();
|
|
1359
|
+
const mount = await appsDriver.get(mountId);
|
|
1360
|
+
const warnings = [];
|
|
1361
|
+
if (mount.appName && mount.appName !== name) {
|
|
1362
|
+
warnings.push(`Server mount ${mount.mountId} is registered as appName '${mount.appName}', not '${name}'. ` +
|
|
1363
|
+
"Adopted anyway — double-check this is the right mount before you publish or destroy it.");
|
|
1364
|
+
}
|
|
1365
|
+
if (generation != null && generation !== mount.generation) {
|
|
1366
|
+
warnings.push(`Recording generation ${generation} while the server reports ${mount.generation}. ` +
|
|
1367
|
+
"publish/rollback/unpublish are CAS-guarded on this value and will fail if it is wrong.");
|
|
1368
|
+
}
|
|
1369
|
+
if (manifest.deployment && manifest.deployment.mountId !== mount.mountId) {
|
|
1370
|
+
warnings.push(`Replacing the previous local deployment state (mount ${manifest.deployment.mountId}, ` +
|
|
1371
|
+
`generation ${manifest.deployment.generation}).`);
|
|
1372
|
+
}
|
|
1373
|
+
manifest.deployment = {
|
|
1374
|
+
mountId: mount.mountId,
|
|
1375
|
+
generation: generation ?? mount.generation,
|
|
1376
|
+
};
|
|
1377
|
+
await writeManifest(options.dir, name, manifest);
|
|
1378
|
+
warnIfFailed(await recordLocalMount(options.dir, name, { mountId: mount.mountId, generation: manifest.deployment.generation }, { endpoint, tenantId }));
|
|
1379
|
+
const output = {
|
|
1380
|
+
status: "adopted",
|
|
1381
|
+
app: name,
|
|
1382
|
+
mount,
|
|
1383
|
+
deployment: manifest.deployment,
|
|
1384
|
+
warnings,
|
|
1385
|
+
};
|
|
1386
|
+
if (isJson(options, program)) {
|
|
1387
|
+
console.log(JSON.stringify(output, null, 2));
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
console.log(formatSuccess(`↻ Adopted existing mount ${mount.mountId} (generation ${manifest.deployment.generation}) from server for '${name}'.`));
|
|
1391
|
+
console.log(` Mount status: ${mount.status}`);
|
|
1392
|
+
console.log(` URL: ${mount.url}`);
|
|
1393
|
+
for (const w of warnings)
|
|
1394
|
+
console.log(formatWarning(w));
|
|
1395
|
+
console.log(` Next: pxbaas app url ${name} | pxbaas app deploy ${name} | pxbaas app destroy ${name} --confirm`);
|
|
1396
|
+
}
|
|
1095
1397
|
async function handleList(options, program) {
|
|
1096
1398
|
const { driver } = await getDriver();
|
|
1097
1399
|
const functions = await driver.list();
|
|
@@ -1205,16 +1507,48 @@ async function handleDestroy(name, options, program) {
|
|
|
1205
1507
|
}
|
|
1206
1508
|
const webapp = requireWebappManifestValue(manifest, name);
|
|
1207
1509
|
const deployment = webapp.deployment;
|
|
1208
|
-
|
|
1510
|
+
const { appsDriver, endpoint, tenantId } = await getDriver();
|
|
1511
|
+
// A2: `--mount-id` is the escape hatch for a lost/stale local state, so an
|
|
1512
|
+
// explicit flag wins over app.json — but a disagreement is announced, never
|
|
1513
|
+
// silent, because destroy is irreversible.
|
|
1514
|
+
const flagMountId = options.mountId?.trim();
|
|
1515
|
+
let targetMountId = flagMountId ?? deployment?.mountId;
|
|
1516
|
+
let mountIdSource = flagMountId
|
|
1517
|
+
? "flag"
|
|
1518
|
+
: "app.json";
|
|
1519
|
+
if (flagMountId && deployment && flagMountId !== deployment.mountId) {
|
|
1520
|
+
console.error(formatWarning(`--mount-id ${flagMountId} overrides the local deployment record (${deployment.mountId}); ` +
|
|
1521
|
+
"the local record is left untouched."));
|
|
1522
|
+
}
|
|
1523
|
+
if (!targetMountId) {
|
|
1524
|
+
// A4: last chance before declaring the mount unreachable.
|
|
1525
|
+
const found = await lookupLocalMount(options.dir, name, {
|
|
1526
|
+
endpoint,
|
|
1527
|
+
tenantId,
|
|
1528
|
+
});
|
|
1529
|
+
if (found.ignored)
|
|
1530
|
+
console.error(formatWarning(found.ignored));
|
|
1531
|
+
if (found.entry) {
|
|
1532
|
+
targetMountId = found.entry.mountId;
|
|
1533
|
+
mountIdSource = "workspace-index";
|
|
1534
|
+
console.error(formatWarning(`↻ Using mount ${targetMountId} recovered from ${found.path}; app.json has no deployment state.`));
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
if (!targetMountId) {
|
|
1209
1538
|
throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state; nothing to destroy`);
|
|
1210
1539
|
}
|
|
1211
|
-
|
|
1540
|
+
// Only a local record matching the mount being destroyed may supply the orphan
|
|
1541
|
+
// candidate slug and be cleared afterwards.
|
|
1542
|
+
const localMatchesTarget = deployment != null && deployment.mountId === targetMountId;
|
|
1543
|
+
const candidateFunctionSlug = localMatchesTarget
|
|
1544
|
+
? deployment?.candidateFunctionSlug
|
|
1545
|
+
: undefined;
|
|
1212
1546
|
// Phase 1: preview (no --confirm). Pull the live mount so the operator sees
|
|
1213
1547
|
// exactly which functions + mount record will be reclaimed.
|
|
1214
1548
|
if (!options.confirm) {
|
|
1215
1549
|
let mount = null;
|
|
1216
1550
|
try {
|
|
1217
|
-
mount = await appsDriver.get(
|
|
1551
|
+
mount = await appsDriver.get(targetMountId);
|
|
1218
1552
|
}
|
|
1219
1553
|
catch {
|
|
1220
1554
|
mount = null; // already gone server-side; preview is still informative
|
|
@@ -1224,16 +1558,18 @@ async function handleDestroy(name, options, program) {
|
|
|
1224
1558
|
slugs.add(mount.activeFunctionSlug);
|
|
1225
1559
|
if (mount?.previousFunctionSlug)
|
|
1226
1560
|
slugs.add(mount.previousFunctionSlug);
|
|
1227
|
-
if (
|
|
1228
|
-
slugs.add(
|
|
1561
|
+
if (candidateFunctionSlug)
|
|
1562
|
+
slugs.add(candidateFunctionSlug);
|
|
1229
1563
|
const purgeTables = options.purgeDb
|
|
1230
1564
|
? await deriveAppTables(join(options.dir, name))
|
|
1231
1565
|
: [];
|
|
1232
1566
|
const preview = {
|
|
1233
1567
|
status: "dry-run",
|
|
1234
1568
|
app: name,
|
|
1235
|
-
mountId:
|
|
1569
|
+
mountId: targetMountId,
|
|
1570
|
+
mountIdSource,
|
|
1236
1571
|
mountStatus: mount?.status ?? "NOT_FOUND",
|
|
1572
|
+
mountGeneration: mount?.generation ?? null,
|
|
1237
1573
|
functions: [...slugs],
|
|
1238
1574
|
tablesUntouched: !options.purgeDb,
|
|
1239
1575
|
...(options.purgeDb ? { purgeDb: { tables: purgeTables } } : {}),
|
|
@@ -1245,9 +1581,9 @@ async function handleDestroy(name, options, program) {
|
|
|
1245
1581
|
console.log(JSON.stringify(preview, null, 2));
|
|
1246
1582
|
return;
|
|
1247
1583
|
}
|
|
1248
|
-
console.log(formatWarning(`Preview: destroy webapp '${name}' (mount ${
|
|
1584
|
+
console.log(formatWarning(`Preview: destroy webapp '${name}' (mount ${targetMountId} from ${mountIdSource}). Re-run with --confirm.`));
|
|
1249
1585
|
console.log(` Functions to delete: ${[...slugs].join(", ") || "(none)"}`);
|
|
1250
|
-
console.log(` Mount record: ${
|
|
1586
|
+
console.log(` Mount record: ${targetMountId} (${preview.mountStatus}${mount?.generation != null ? `, generation ${mount.generation}` : ""})`);
|
|
1251
1587
|
if (options.purgeDb) {
|
|
1252
1588
|
console.log(formatWarning(` --purge-db will DROP tables: ${purgeTables.join(", ") || "(none detected)"}`));
|
|
1253
1589
|
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."));
|
|
@@ -1259,15 +1595,28 @@ async function handleDestroy(name, options, program) {
|
|
|
1259
1595
|
}
|
|
1260
1596
|
// Phase 2: destroy. Pass the local orphan candidate (if any) so an uploaded
|
|
1261
1597
|
// but never-published function is reclaimed too.
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1598
|
+
//
|
|
1599
|
+
// A2: echo the live mount identity (status/generation) next to the result —
|
|
1600
|
+
// `--mount-id` bypasses every local safeguard, so the operator must be able to
|
|
1601
|
+
// see exactly which mount was reclaimed.
|
|
1602
|
+
let snapshot = null;
|
|
1603
|
+
try {
|
|
1604
|
+
snapshot = await appsDriver.get(targetMountId);
|
|
1605
|
+
}
|
|
1606
|
+
catch {
|
|
1607
|
+
snapshot = null; // already gone server-side; destroy is idempotent
|
|
1608
|
+
}
|
|
1609
|
+
const result = await appsDriver.destroy(targetMountId, {
|
|
1610
|
+
...(candidateFunctionSlug ? { candidateFunctionSlug } : {}),
|
|
1266
1611
|
});
|
|
1267
|
-
// D5: clear ONLY the local deployment state
|
|
1268
|
-
// can be
|
|
1269
|
-
|
|
1270
|
-
|
|
1612
|
+
// D5: clear ONLY the local deployment state (and only when it points at the
|
|
1613
|
+
// mount that was actually destroyed); keep the source tree so the app can be
|
|
1614
|
+
// re-deployed later.
|
|
1615
|
+
if (localMatchesTarget) {
|
|
1616
|
+
delete webapp.deployment;
|
|
1617
|
+
await writeManifest(options.dir, name, webapp);
|
|
1618
|
+
}
|
|
1619
|
+
warnIfFailed(await forgetLocalMount(options.dir, name, targetMountId));
|
|
1271
1620
|
// Optional DB purge (Q4): drop the app's own tables + RLS rows via the
|
|
1272
1621
|
// mode-appropriate channel. Runs AFTER the mount/functions are reclaimed.
|
|
1273
1622
|
const purge = options.purgeDb
|
|
@@ -1279,6 +1628,9 @@ async function handleDestroy(name, options, program) {
|
|
|
1279
1628
|
status: "destroyed",
|
|
1280
1629
|
app: name,
|
|
1281
1630
|
mountId: result.mountId,
|
|
1631
|
+
mountIdSource,
|
|
1632
|
+
mountStatusBeforeDestroy: snapshot?.status ?? "NOT_FOUND",
|
|
1633
|
+
mountGenerationBeforeDestroy: snapshot?.generation ?? null,
|
|
1282
1634
|
alreadyGone: result.alreadyGone ?? false,
|
|
1283
1635
|
deletedFunctions: result.deletedFunctions,
|
|
1284
1636
|
tablesUntouched: !purge,
|
|
@@ -1289,6 +1641,7 @@ async function handleDestroy(name, options, program) {
|
|
|
1289
1641
|
return;
|
|
1290
1642
|
}
|
|
1291
1643
|
console.log(formatSuccess(`Destroyed webapp '${name}' (mount ${result.mountId}${result.alreadyGone ? ", already gone" : ""}).`));
|
|
1644
|
+
console.log(` Mount destroyed: ${targetMountId} (from ${mountIdSource}; was ${snapshot?.status ?? "NOT_FOUND"}${snapshot?.generation != null ? `, generation ${snapshot.generation}` : ""})`);
|
|
1292
1645
|
for (const f of result.deletedFunctions) {
|
|
1293
1646
|
if (f.deleted)
|
|
1294
1647
|
console.log(` Deleted function: ${f.slug}`);
|
|
@@ -1383,6 +1736,31 @@ function parsePositiveInteger(value, optionName) {
|
|
|
1383
1736
|
}
|
|
1384
1737
|
return parsed;
|
|
1385
1738
|
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Precheck `capabilities.privateShare` before touching the share API (缺陷6 §7).
|
|
1741
|
+
*
|
|
1742
|
+
* On an instance with sharing disabled every `/share/v1/*` call answers a bare
|
|
1743
|
+
* `404 Secure MPA sharing is disabled`, which reads like a bad slug or a CLI bug
|
|
1744
|
+
* rather than an instance setting. Say so locally, and say that nothing was sent.
|
|
1745
|
+
*
|
|
1746
|
+
* A failing capabilities read must NOT block: the instance stays authoritative,
|
|
1747
|
+
* so an unreachable capability endpoint degrades to "let the share call speak".
|
|
1748
|
+
*/
|
|
1749
|
+
async function assertPrivateShareEnabled(appsDriver, action) {
|
|
1750
|
+
let capabilities;
|
|
1751
|
+
try {
|
|
1752
|
+
capabilities = await appsDriver.capabilities();
|
|
1753
|
+
}
|
|
1754
|
+
catch {
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (capabilities?.privateShare === false) {
|
|
1758
|
+
throw new Error(`This instance has secure MPA sharing disabled (capabilities.privateShare=false), ` +
|
|
1759
|
+
`so \`pxbaas app share ${action}\` cannot work here. Enable private sharing on the ` +
|
|
1760
|
+
`platform side and re-run — no share API request was sent. ` +
|
|
1761
|
+
`Check the current setting with \`pxbaas app capabilities --json\`.`);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1386
1764
|
async function handleShareCreate(slug, options, program) {
|
|
1387
1765
|
validateSlug(slug);
|
|
1388
1766
|
if (options.expiresAt) {
|
|
@@ -1398,7 +1776,8 @@ async function handleShareCreate(slug, options, program) {
|
|
|
1398
1776
|
const expiresInSeconds = options.expiresAt
|
|
1399
1777
|
? undefined
|
|
1400
1778
|
: parsePositiveInteger(options.expiresIn, "--expires-in");
|
|
1401
|
-
const { shareDriver, endpoint } = await getDriver();
|
|
1779
|
+
const { shareDriver, appsDriver, endpoint } = await getDriver();
|
|
1780
|
+
await assertPrivateShareEnabled(appsDriver, "create");
|
|
1402
1781
|
const link = await shareDriver.create(slug, {
|
|
1403
1782
|
expiresAt: options.expiresAt,
|
|
1404
1783
|
expiresInSeconds,
|
|
@@ -1450,7 +1829,11 @@ async function handleShareRevoke(slug, shareId, options, program) {
|
|
|
1450
1829
|
console.log(formatWarning(`Preview: revoke share '${shareId}' for '${slug}'. Re-run with --confirm.`));
|
|
1451
1830
|
return;
|
|
1452
1831
|
}
|
|
1453
|
-
|
|
1832
|
+
// The branch without --confirm stays a purely local preview (no profile, no
|
|
1833
|
+
// network); the capability precheck belongs to the branch that really calls the
|
|
1834
|
+
// share API.
|
|
1835
|
+
const { shareDriver, appsDriver } = await getDriver();
|
|
1836
|
+
await assertPrivateShareEnabled(appsDriver, "revoke");
|
|
1454
1837
|
const share = await shareDriver.revoke(slug, shareId, options.tenantId);
|
|
1455
1838
|
if (isJson(options, program)) {
|
|
1456
1839
|
console.log(JSON.stringify({ status: "revoked", slug, share }, null, 2));
|
|
@@ -1460,7 +1843,8 @@ async function handleShareRevoke(slug, shareId, options, program) {
|
|
|
1460
1843
|
}
|
|
1461
1844
|
async function handleShareVerify(slug, shareId, options, program) {
|
|
1462
1845
|
validateSlug(slug);
|
|
1463
|
-
const { shareDriver } = await getDriver();
|
|
1846
|
+
const { shareDriver, appsDriver } = await getDriver();
|
|
1847
|
+
await assertPrivateShareEnabled(appsDriver, "verify");
|
|
1464
1848
|
const share = await shareDriver.get(slug, shareId, options.tenantId);
|
|
1465
1849
|
const output = {
|
|
1466
1850
|
status: share.status === "ACTIVE" ? "active" : "inactive",
|