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