@polardbx/pxbaas 0.1.0-beta.3 → 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.
Files changed (103) hide show
  1. package/README.md +40 -8
  2. package/dist/app/local-index.d.ts +70 -0
  3. package/dist/app/local-index.d.ts.map +1 -0
  4. package/dist/app/local-index.js +140 -0
  5. package/dist/app/local-index.js.map +1 -0
  6. package/dist/app/preflight.d.ts +12 -0
  7. package/dist/app/preflight.d.ts.map +1 -1
  8. package/dist/app/preflight.js +30 -17
  9. package/dist/app/preflight.js.map +1 -1
  10. package/dist/cli/root.d.ts.map +1 -1
  11. package/dist/cli/root.js +3 -0
  12. package/dist/cli/root.js.map +1 -1
  13. package/dist/commands/app/index.d.ts.map +1 -1
  14. package/dist/commands/app/index.js +398 -46
  15. package/dist/commands/app/index.js.map +1 -1
  16. package/dist/commands/completion/index.d.ts.map +1 -1
  17. package/dist/commands/completion/index.js +5 -1
  18. package/dist/commands/completion/index.js.map +1 -1
  19. package/dist/commands/data/index.d.ts +52 -0
  20. package/dist/commands/data/index.d.ts.map +1 -1
  21. package/dist/commands/data/index.js +201 -39
  22. package/dist/commands/data/index.js.map +1 -1
  23. package/dist/commands/db/index.d.ts +14 -0
  24. package/dist/commands/db/index.d.ts.map +1 -1
  25. package/dist/commands/db/index.js +126 -28
  26. package/dist/commands/db/index.js.map +1 -1
  27. package/dist/commands/functions/new.d.ts +4 -0
  28. package/dist/commands/functions/new.d.ts.map +1 -1
  29. package/dist/commands/functions/new.js +24 -9
  30. package/dist/commands/functions/new.js.map +1 -1
  31. package/dist/commands/login/index.d.ts.map +1 -1
  32. package/dist/commands/login/index.js +15 -30
  33. package/dist/commands/login/index.js.map +1 -1
  34. package/dist/commands/profile/import-env.d.ts +7 -2
  35. package/dist/commands/profile/import-env.d.ts.map +1 -1
  36. package/dist/commands/profile/import-env.js +141 -16
  37. package/dist/commands/profile/import-env.js.map +1 -1
  38. package/dist/commands/profile/index.d.ts.map +1 -1
  39. package/dist/commands/profile/index.js +13 -1
  40. package/dist/commands/profile/index.js.map +1 -1
  41. package/dist/commands/storage/index.d.ts.map +1 -1
  42. package/dist/commands/storage/index.js +209 -7
  43. package/dist/commands/storage/index.js.map +1 -1
  44. package/dist/commands/tenant/index.d.ts +19 -0
  45. package/dist/commands/tenant/index.d.ts.map +1 -0
  46. package/dist/commands/tenant/index.js +662 -0
  47. package/dist/commands/tenant/index.js.map +1 -0
  48. package/dist/commands/whoami/index.js +19 -0
  49. package/dist/commands/whoami/index.js.map +1 -1
  50. package/dist/credentials/credential-store.d.ts +30 -1
  51. package/dist/credentials/credential-store.d.ts.map +1 -1
  52. package/dist/credentials/credential-store.js +114 -12
  53. package/dist/credentials/credential-store.js.map +1 -1
  54. package/dist/credentials/env-file.d.ts +48 -7
  55. package/dist/credentials/env-file.d.ts.map +1 -1
  56. package/dist/credentials/env-file.js +91 -16
  57. package/dist/credentials/env-file.js.map +1 -1
  58. package/dist/credentials/jwt-introspect.d.ts +7 -0
  59. package/dist/credentials/jwt-introspect.d.ts.map +1 -1
  60. package/dist/credentials/jwt-introspect.js.map +1 -1
  61. package/dist/credentials/masker.d.ts +3 -0
  62. package/dist/credentials/masker.d.ts.map +1 -1
  63. package/dist/credentials/masker.js +3 -0
  64. package/dist/credentials/masker.js.map +1 -1
  65. package/dist/credentials/profile.d.ts +12 -0
  66. package/dist/credentials/profile.d.ts.map +1 -1
  67. package/dist/credentials/profile.js +23 -0
  68. package/dist/credentials/profile.js.map +1 -1
  69. package/dist/credentials/tty-guard.d.ts +10 -0
  70. package/dist/credentials/tty-guard.d.ts.map +1 -1
  71. package/dist/credentials/tty-guard.js +32 -0
  72. package/dist/credentials/tty-guard.js.map +1 -1
  73. package/dist/main.js +21 -2
  74. package/dist/main.js.map +1 -1
  75. package/dist/tenant/bundle.d.ts +66 -0
  76. package/dist/tenant/bundle.d.ts.map +1 -0
  77. package/dist/tenant/bundle.js +132 -0
  78. package/dist/tenant/bundle.js.map +1 -0
  79. package/dist/tenant/guide.d.ts +50 -0
  80. package/dist/tenant/guide.d.ts.map +1 -0
  81. package/dist/tenant/guide.js +152 -0
  82. package/dist/tenant/guide.js.map +1 -0
  83. package/dist/tenant/probe.d.ts +65 -0
  84. package/dist/tenant/probe.d.ts.map +1 -0
  85. package/dist/tenant/probe.js +159 -0
  86. package/dist/tenant/probe.js.map +1 -0
  87. package/dist/tenant/verify.d.ts +37 -0
  88. package/dist/tenant/verify.d.ts.map +1 -0
  89. package/dist/tenant/verify.js +132 -0
  90. package/dist/tenant/verify.js.map +1 -0
  91. package/dist/version.d.ts +29 -0
  92. package/dist/version.d.ts.map +1 -0
  93. package/dist/version.js +65 -0
  94. package/dist/version.js.map +1 -0
  95. package/package.json +2 -2
  96. package/skills/pxbaas-app-spec/references/runtime-contract.md +1 -1
  97. package/skills/pxbaas-cli/SKILL.md +14 -5
  98. package/skills/pxbaas-cli/references/auth-and-credentials.md +19 -3
  99. package/skills/pxbaas-cli/references/data-postgrest.md +4 -3
  100. package/skills/pxbaas-cli/references/db-and-meta.md +5 -0
  101. package/skills/pxbaas-cli/references/safety-and-two-phase.md +42 -13
  102. package/skills/pxbaas-cli/references/tenant-admin.md +122 -0
  103. package/skills/pxbaas-cli/references/troubleshooting.md +4 -1
@@ -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 { runPreflight, isPublicEndpoint, } from "../../app/preflight.js";
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";
@@ -112,6 +113,7 @@ export function registerAppCommands(program) {
112
113
  .description("Print the shareable URL for a deployed webapp")
113
114
  .option("--dir <directory>", "Parent directory for the app", "./apps")
114
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)")
115
117
  .option("--json", "Output in JSON format")
116
118
  .action(async (name, options) => {
117
119
  try {
@@ -121,6 +123,21 @@ export function registerAppCommands(program) {
121
123
  fail(e, options.json);
122
124
  }
123
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
+ });
124
141
  appCmd
125
142
  .command("list")
126
143
  .description("List deployed edge functions (apps)")
@@ -153,6 +170,7 @@ export function registerAppCommands(program) {
153
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.")
154
171
  .option("--dir <directory>", "Parent directory for the app", "./apps")
155
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")
156
174
  .option("--purge-db", "Also drop the app's DB tables and remove its RLS registry rows (destructive)")
157
175
  .option("--schema <schema>", "Single-tenant schema the app's tables live in (used with --purge-db)", "public")
158
176
  .option("--json", "Output in JSON format")
@@ -202,6 +220,37 @@ export function registerAppCommands(program) {
202
220
  function isJson(options, program) {
203
221
  return Boolean(options.json || program.opts().json);
204
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
+ };
205
254
  function fail(error, json) {
206
255
  const message = error instanceof Error ? error.message : String(error);
207
256
  const code = error instanceof AppsApiError ? error.code : "CLI_ERROR";
@@ -209,8 +258,19 @@ function fail(error, json) {
209
258
  console.log(JSON.stringify({ code, message }, null, 2));
210
259
  else
211
260
  console.error(formatError(`${code}: ${message}`));
261
+ const hint = RECOVERY_HINTS[code];
262
+ if (hint)
263
+ console.error(hint);
212
264
  process.exit(1);
213
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
+ }
214
274
  function validateSlug(slug) {
215
275
  if (!SLUG_RE.test(slug) || slug.length > 128) {
216
276
  throw new Error(`Invalid slug '${slug}'. Must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ and be <=128 chars.`);
@@ -296,17 +356,84 @@ async function resolveQuotaLimit(appsDriver, flagValue) {
296
356
  if (Number.isFinite(parsed) && parsed > 0) {
297
357
  return { quotaLimit: Math.floor(parsed), quotaLimitSource: "flag" };
298
358
  }
359
+ let quotaLimit;
360
+ let quotaLimitSource = "fallback";
299
361
  try {
300
362
  const caps = await appsDriver.capabilities();
301
363
  const max = caps?.maxFunctions;
302
364
  if (typeof max === "number" && Number.isFinite(max) && max > 0) {
303
- return { quotaLimit: max, quotaLimitSource: "platform" };
365
+ quotaLimit = max;
366
+ quotaLimitSource = "platform";
304
367
  }
305
368
  }
306
369
  catch {
307
370
  // fall through to the advisory client fallback
308
371
  }
309
- return { quotaLimit: undefined, quotaLimitSource: "fallback" };
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
+ }
310
437
  }
311
438
  /**
312
439
  * `pxbaas app db-init <name>` — apply the app's bundled db/schema.sql (plus an
@@ -406,10 +533,10 @@ async function handleCapabilities(options, program) {
406
533
  }
407
534
  async function handleLifecycle(action, name, options, program) {
408
535
  const manifest = await requireWebappManifest(options.dir, name);
409
- const deployment = manifest.deployment;
536
+ const { appsDriver, endpoint, tenantId } = await getDriver();
537
+ const deployment = await resolveDeployment(appsDriver, manifest, options.dir, name, { endpoint, tenantId });
410
538
  if (!deployment)
411
539
  throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
412
- const { appsDriver } = await getDriver();
413
540
  const capabilities = await appsDriver.capabilities();
414
541
  assertWebappCapabilities(capabilities, manifest.backend ?? null);
415
542
  let mount;
@@ -435,6 +562,7 @@ async function handleLifecycle(action, name, options, program) {
435
562
  : { candidateFunctionSlug: deployment.candidateFunctionSlug }),
436
563
  };
437
564
  await writeManifest(options.dir, name, manifest);
565
+ warnIfFailed(await recordLocalMount(options.dir, name, { mountId: mount.mountId, generation: mount.generation }, { endpoint, tenantId }));
438
566
  emitWebappResult(action === "publish"
439
567
  ? "published"
440
568
  : action === "rollback"
@@ -477,17 +605,11 @@ async function handleDeploy(name, options, program) {
477
605
  ? [...(manifest.pages ?? []), ...(manifest.assets ?? [])]
478
606
  : [];
479
607
  const { driver, endpoint, appsDriver } = await getDriver();
480
- let functionsCount = 0;
481
- try {
482
- functionsCount = (await driver.list()).length;
483
- }
484
- catch {
485
- functionsCount = 0; // preflight should not hard-fail if list is unavailable
486
- }
608
+ const functionsCount = await safeFunctionsCount(driver);
487
609
  // Resolve the authoritative Edge Function quota: --quota-limit (flag) wins,
488
610
  // else the instance-reported capabilities.maxFunctions (platform), else the
489
611
  // client fallback (advisory only). Fixes the hardcoded-5 false block (弯路5).
490
- const { quotaLimit, quotaLimitSource } = await resolveQuotaLimit(appsDriver, options.quotaLimit);
612
+ const { quotaLimit, quotaLimitSource, flagWarning } = await resolveQuotaLimit(appsDriver, options.quotaLimit);
491
613
  const pre = runPreflight({
492
614
  source,
493
615
  endpoint,
@@ -497,6 +619,8 @@ async function handleDeploy(name, options, program) {
497
619
  verifyJwt,
498
620
  entrypointExists,
499
621
  });
622
+ if (flagWarning)
623
+ pre.warnings.unshift(flagWarning);
500
624
  // Automatic pxbaas-app-spec compliance review (skip with --skip-spec-check).
501
625
  const spec = options.skipSpecCheck
502
626
  ? { skipped: true, passed: true, blockers: [], warnings: [] }
@@ -510,9 +634,8 @@ async function handleDeploy(name, options, program) {
510
634
  auxiliarySources,
511
635
  }),
512
636
  };
513
- const fmt = (f) => `[${f.rule}] ${f.message} (see ${f.specRef})`;
514
- const allBlockers = [...pre.blockers, ...spec.blockers.map(fmt)];
515
- 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)];
516
639
  if (options.dryRun) {
517
640
  const out = {
518
641
  status: "dry-run",
@@ -614,7 +737,7 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
614
737
  backend.framework = framework ?? backend.framework;
615
738
  backend.verifyJwt = options.verifyJwt ?? backend.verifyJwt;
616
739
  }
617
- const { driver, appsDriver } = await getDriver();
740
+ const { driver, appsDriver, endpoint, tenantId } = await getDriver();
618
741
  const capabilities = await appsDriver.capabilities();
619
742
  assertWebappCapabilities(capabilities, backend);
620
743
  const appDir = join(options.dir, name);
@@ -712,29 +835,91 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
712
835
  sessionHeaders: capabilities.sessionHeaders,
713
836
  }),
714
837
  };
715
- if (!spec.passed) {
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) {
716
842
  const details = spec.blockers
717
843
  .map((item) => `[${item.rule}] ${item.message}`)
718
844
  .join("; ");
719
845
  throw new AppsApiError("APP_ARTIFACT_INVALID", details);
720
846
  }
721
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
+ };
722
862
  if (options.dryRun) {
723
863
  const output = {
724
864
  status: "dry-run",
725
865
  app: name,
726
866
  mode: "webapp",
727
867
  capabilities,
868
+ preflight,
728
869
  specLint: spec,
729
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
+ ];
730
879
  if (isJson(options, program))
731
880
  console.log(JSON.stringify(output, null, 2));
732
- else
733
- console.log(formatSuccess(`Webapp '${name}' passed capability and artifact checks.`));
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);
734
894
  return;
735
895
  }
736
- const mount = manifest.deployment
737
- ? await appsDriver.get(manifest.deployment.mountId)
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)))
738
923
  : await appsDriver.create({ appName: name });
739
924
  const candidateFunctionSlug = createCandidateSlug(mount.id);
740
925
  const entrypoint = backend?.entrypoint ?? `${frontend.root}/${frontend.index}`;
@@ -751,6 +936,10 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
751
936
  generation: mount.generation,
752
937
  };
753
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 }));
754
943
  const published = await appsDriver.publish(mount.mountId, {
755
944
  candidateFunctionSlug,
756
945
  expectedGeneration: mount.generation,
@@ -760,7 +949,14 @@ async function handleWebappDeploy(name, manifestInput, options, program) {
760
949
  generation: published.generation,
761
950
  };
762
951
  await writeManifest(options.dir, name, manifest);
763
- emitWebappResult("published", name, published, capabilities, options, program, { candidateFunctionSlug, specLint: spec });
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));
764
960
  }
765
961
  function parseBackendFramework(value) {
766
962
  if (value === undefined)
@@ -1100,13 +1296,18 @@ async function handleWebappVerify(name, manifest, options, program) {
1100
1296
  }
1101
1297
  async function handleUrl(name, options, program) {
1102
1298
  const manifest = await readManifest(options.dir, name);
1103
- if (manifest.mode === "webapp") {
1104
- const webapp = requireWebappManifestValue(manifest, name);
1105
- if (!webapp.deployment)
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)
1106
1307
  throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state`);
1107
1308
  const { appsDriver } = await getDriver();
1108
1309
  const [mount, capabilities] = await Promise.all([
1109
- appsDriver.get(webapp.deployment.mountId),
1310
+ appsDriver.get(mountId),
1110
1311
  appsDriver.capabilities(),
1111
1312
  ]);
1112
1313
  emitWebappResult("url", name, mount, capabilities, options, program);
@@ -1124,6 +1325,75 @@ async function handleUrl(name, options, program) {
1124
1325
  if (!publicReachable)
1125
1326
  console.log(formatWarning("Endpoint is not publicly reachable; this URL will not work cross-machine."));
1126
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
+ }
1127
1397
  async function handleList(options, program) {
1128
1398
  const { driver } = await getDriver();
1129
1399
  const functions = await driver.list();
@@ -1237,16 +1507,48 @@ async function handleDestroy(name, options, program) {
1237
1507
  }
1238
1508
  const webapp = requireWebappManifestValue(manifest, name);
1239
1509
  const deployment = webapp.deployment;
1240
- if (!deployment) {
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) {
1241
1538
  throw new AppsApiError("APP_DEPLOYMENT_NOT_FOUND", `App '${name}' has no local deployment state; nothing to destroy`);
1242
1539
  }
1243
- const { appsDriver } = await getDriver();
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;
1244
1546
  // Phase 1: preview (no --confirm). Pull the live mount so the operator sees
1245
1547
  // exactly which functions + mount record will be reclaimed.
1246
1548
  if (!options.confirm) {
1247
1549
  let mount = null;
1248
1550
  try {
1249
- mount = await appsDriver.get(deployment.mountId);
1551
+ mount = await appsDriver.get(targetMountId);
1250
1552
  }
1251
1553
  catch {
1252
1554
  mount = null; // already gone server-side; preview is still informative
@@ -1256,16 +1558,18 @@ async function handleDestroy(name, options, program) {
1256
1558
  slugs.add(mount.activeFunctionSlug);
1257
1559
  if (mount?.previousFunctionSlug)
1258
1560
  slugs.add(mount.previousFunctionSlug);
1259
- if (deployment.candidateFunctionSlug)
1260
- slugs.add(deployment.candidateFunctionSlug);
1561
+ if (candidateFunctionSlug)
1562
+ slugs.add(candidateFunctionSlug);
1261
1563
  const purgeTables = options.purgeDb
1262
1564
  ? await deriveAppTables(join(options.dir, name))
1263
1565
  : [];
1264
1566
  const preview = {
1265
1567
  status: "dry-run",
1266
1568
  app: name,
1267
- mountId: deployment.mountId,
1569
+ mountId: targetMountId,
1570
+ mountIdSource,
1268
1571
  mountStatus: mount?.status ?? "NOT_FOUND",
1572
+ mountGeneration: mount?.generation ?? null,
1269
1573
  functions: [...slugs],
1270
1574
  tablesUntouched: !options.purgeDb,
1271
1575
  ...(options.purgeDb ? { purgeDb: { tables: purgeTables } } : {}),
@@ -1277,9 +1581,9 @@ async function handleDestroy(name, options, program) {
1277
1581
  console.log(JSON.stringify(preview, null, 2));
1278
1582
  return;
1279
1583
  }
1280
- console.log(formatWarning(`Preview: destroy webapp '${name}' (mount ${deployment.mountId}). Re-run with --confirm.`));
1584
+ console.log(formatWarning(`Preview: destroy webapp '${name}' (mount ${targetMountId} from ${mountIdSource}). Re-run with --confirm.`));
1281
1585
  console.log(` Functions to delete: ${[...slugs].join(", ") || "(none)"}`);
1282
- console.log(` Mount record: ${deployment.mountId} (${preview.mountStatus})`);
1586
+ console.log(` Mount record: ${targetMountId} (${preview.mountStatus}${mount?.generation != null ? `, generation ${mount.generation}` : ""})`);
1283
1587
  if (options.purgeDb) {
1284
1588
  console.log(formatWarning(` --purge-db will DROP tables: ${purgeTables.join(", ") || "(none detected)"}`));
1285
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."));
@@ -1291,15 +1595,28 @@ async function handleDestroy(name, options, program) {
1291
1595
  }
1292
1596
  // Phase 2: destroy. Pass the local orphan candidate (if any) so an uploaded
1293
1597
  // but never-published function is reclaimed too.
1294
- const result = await appsDriver.destroy(deployment.mountId, {
1295
- ...(deployment.candidateFunctionSlug
1296
- ? { candidateFunctionSlug: deployment.candidateFunctionSlug }
1297
- : {}),
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 } : {}),
1298
1611
  });
1299
- // D5: clear ONLY the local deployment state; keep the source tree so the app
1300
- // can be re-deployed later.
1301
- delete webapp.deployment;
1302
- await writeManifest(options.dir, name, webapp);
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));
1303
1620
  // Optional DB purge (Q4): drop the app's own tables + RLS rows via the
1304
1621
  // mode-appropriate channel. Runs AFTER the mount/functions are reclaimed.
1305
1622
  const purge = options.purgeDb
@@ -1311,6 +1628,9 @@ async function handleDestroy(name, options, program) {
1311
1628
  status: "destroyed",
1312
1629
  app: name,
1313
1630
  mountId: result.mountId,
1631
+ mountIdSource,
1632
+ mountStatusBeforeDestroy: snapshot?.status ?? "NOT_FOUND",
1633
+ mountGenerationBeforeDestroy: snapshot?.generation ?? null,
1314
1634
  alreadyGone: result.alreadyGone ?? false,
1315
1635
  deletedFunctions: result.deletedFunctions,
1316
1636
  tablesUntouched: !purge,
@@ -1321,6 +1641,7 @@ async function handleDestroy(name, options, program) {
1321
1641
  return;
1322
1642
  }
1323
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}` : ""})`);
1324
1645
  for (const f of result.deletedFunctions) {
1325
1646
  if (f.deleted)
1326
1647
  console.log(` Deleted function: ${f.slug}`);
@@ -1415,6 +1736,31 @@ function parsePositiveInteger(value, optionName) {
1415
1736
  }
1416
1737
  return parsed;
1417
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
+ }
1418
1764
  async function handleShareCreate(slug, options, program) {
1419
1765
  validateSlug(slug);
1420
1766
  if (options.expiresAt) {
@@ -1430,7 +1776,8 @@ async function handleShareCreate(slug, options, program) {
1430
1776
  const expiresInSeconds = options.expiresAt
1431
1777
  ? undefined
1432
1778
  : parsePositiveInteger(options.expiresIn, "--expires-in");
1433
- const { shareDriver, endpoint } = await getDriver();
1779
+ const { shareDriver, appsDriver, endpoint } = await getDriver();
1780
+ await assertPrivateShareEnabled(appsDriver, "create");
1434
1781
  const link = await shareDriver.create(slug, {
1435
1782
  expiresAt: options.expiresAt,
1436
1783
  expiresInSeconds,
@@ -1482,7 +1829,11 @@ async function handleShareRevoke(slug, shareId, options, program) {
1482
1829
  console.log(formatWarning(`Preview: revoke share '${shareId}' for '${slug}'. Re-run with --confirm.`));
1483
1830
  return;
1484
1831
  }
1485
- const { shareDriver } = await getDriver();
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");
1486
1837
  const share = await shareDriver.revoke(slug, shareId, options.tenantId);
1487
1838
  if (isJson(options, program)) {
1488
1839
  console.log(JSON.stringify({ status: "revoked", slug, share }, null, 2));
@@ -1492,7 +1843,8 @@ async function handleShareRevoke(slug, shareId, options, program) {
1492
1843
  }
1493
1844
  async function handleShareVerify(slug, shareId, options, program) {
1494
1845
  validateSlug(slug);
1495
- const { shareDriver } = await getDriver();
1846
+ const { shareDriver, appsDriver } = await getDriver();
1847
+ await assertPrivateShareEnabled(appsDriver, "verify");
1496
1848
  const share = await shareDriver.get(slug, shareId, options.tenantId);
1497
1849
  const output = {
1498
1850
  status: share.status === "ACTIVE" ? "active" : "inactive",