From 82085b44d38845f62a8df9e0b93675a7bd606e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Frankiewicz?= Date: Fri, 29 May 2026 19:03:29 +0200 Subject: [PATCH 1/2] gateway compat --- src/cli.js | 5 ++ src/commands/deploy.js | 143 +++++++++++++++++++++--------- src/lib/clouds/cf.js | 70 +++++++++++++++ src/lib/providers/cf/app.js | 25 ++++-- src/lib/providers/cf/bundle.js | 2 +- worker-template/package-lock.json | 21 +++++ worker-template/src/index.js | 9 ++ 7 files changed, 225 insertions(+), 50 deletions(-) create mode 100644 worker-template/package-lock.json diff --git a/src/cli.js b/src/cli.js index c120ec3..46aa4c7 100755 --- a/src/cli.js +++ b/src/cli.js @@ -85,6 +85,11 @@ program .option("--db ", "Database provider (for --backup-db)") .option("--pre-deploy ", "Run command in container before push (e.g. migrations)") .option("--backup-db", "Backup database before deploying") + .option("--gateway", "Deploy behind gateway (protected by shared secret)") + .option("--hostname ", "Gateway: hostname (e.g. app.example.com, *.internal.example.com)") + .option("--groups ", "Gateway: required access groups (comma-separated)") + .option("--match-mode ", "Gateway: group match mode (any|all)", "any") + .option("--path-prefix ", "Gateway: path prefix (default: /)", "/") .option("--json", "Output result as JSON") .option("-y, --yes", "Skip confirmation prompt") .action(deploy); diff --git a/src/commands/deploy.js b/src/commands/deploy.js index 8d26b3c..6b78b3e 100644 --- a/src/commands/deploy.js +++ b/src/commands/deploy.js @@ -10,6 +10,21 @@ import { PROVIDERS, tryGetProviderConfig } from "../lib/config.js"; import { dockerBuild, dockerTag, dockerPush, dockerLogin } from "../lib/docker.js"; import { getPortal, portalApi } from "../lib/portal.js"; +function buildGatewayMeta(options, linked) { + var isGateway = options.gateway || linked?.gateway; + if (!isGateway) return null; + var hostname = options.hostname || linked?.gateway?.hostname || null; + var groups = options.groups + ? options.groups.split(",").map((g) => g.trim()).filter(Boolean) + : linked?.gateway?.groups || []; + return { + hostname, + path_prefix: options.pathPrefix || linked?.gateway?.path_prefix || "/", + groups, + match_mode: options.matchMode || linked?.gateway?.match_mode || "any", + }; +} + function resolveDeployTarget(nameOrPath, path, mode) { var name; var dockerPath; @@ -131,12 +146,16 @@ async function ensurePortalApp(name, options) { var hasRegistry = !!names.registry || providerSupportsRegistry(computeType); var appConfig = buildPortalAppConfig(name, options, computeType, hasRegistry); + var linked = readLink(); + var gateway = buildGatewayMeta(options, linked); + status(`Creating app ${name} in portal...`); await portalApi("POST", "/apps", { name, cloudLabel: names.compute, registryLabel: names.registry || null, config: appConfig, + gateway, }); return names; @@ -358,16 +377,23 @@ export async function deploy(nameOrPath, path, options) { await runPreDeploy(preDeployCmd, localTag, appConfig, linked); } - if (!hasRegistry) { - // No registry: deploy extracts and uploads the image directly - phase("Deploying"); - await appProvider.deploy(cfg, name, localTag, { - appConfig, - isFirstDeploy, - newSecrets, - }); - } else { - // 2. Push to registry + var byocGateway = buildGatewayMeta(options, readLink()); + + // 2. Push image to registry + var deployImageTag; + if (providerType === "cf") { + // CF Containers only pulls from CF Registry / Docker Hub / ECR. + // Push directly to CF Registry regardless of --registry flag. + var { CF_REGISTRY, getRegistryCredentials } = await import("../lib/clouds/cf.js"); + var cfTag = `${CF_REGISTRY}/${cfg.accountId}/relight-${name}:${Date.now()}`; + phase("Pushing to CF Registry"); + var cfCreds = await getRegistryCredentials(cfg.accountId, cfg.apiToken); + dockerLogin(CF_REGISTRY, cfCreds.username, cfCreds.password); + dockerTag(localTag, cfTag); + status(`Pushing ${cfTag}...`); + dockerPush(cfTag); + deployImageTag = cfTag; + } else if (hasRegistry) { phase("Pushing to registry"); status("Authenticating..."); registryCreds = await registry.getCredentials(registryCfg); @@ -376,26 +402,32 @@ export async function deploy(nameOrPath, path, options) { status(`Pushing ${remoteTag}...`); dockerTag(localTag, remoteTag); dockerPush(remoteTag); - - // 3. Deploy via provider - phase("Deploying"); - await appProvider.deploy(cfg, name, remoteTag, { - appConfig, - isFirstDeploy, - newSecrets, - registryName, - registryCredentials: registryCreds, - }); + deployImageTag = remoteTag; + } else { + deployImageTag = localTag; } + // 3. Deploy + phase("Deploying"); + appConfig.image = deployImageTag; + await appProvider.deploy(cfg, name, deployImageTag, { + appConfig, + isFirstDeploy, + newSecrets, + registryName, + registryCredentials: registryCreds, + }); + // 4. Resolve URL and report var url = await appProvider.getAppUrl(cfg, name); + var gatewayUrl = byocGateway ? `https://${byocGateway.hostname}${byocGateway.path_prefix}` : null; if (options.json) { var result = { name, image: hasRegistry ? remoteTag : localTag, - url, + url: gatewayUrl || url, + gateway: !!byocGateway, regions: appConfig.regions, instances: appConfig.instances, firstDeploy: isFirstDeploy, @@ -406,10 +438,16 @@ export async function deploy(nameOrPath, path, options) { success(`App ${fmt.app(name)} deployed!`); process.stderr.write(` ${fmt.bold("Name:")} ${fmt.app(name)}\n`); process.stderr.write(` ${fmt.bold("Image:")} ${hasRegistry ? remoteTag : localTag}\n`); - process.stderr.write( - ` ${fmt.bold("URL:")} ${url ? fmt.url(url) : fmt.dim("(configure workers.dev subdomain to see URL)")}\n` - ); - hint("Next", `relight open ${name}`); + if (byocGateway) { + process.stderr.write(` ${fmt.bold("Access:")} ${fmt.dim("private (via gateway + secret)")}\n`); + process.stderr.write(` ${fmt.bold("Gateway:")} ${gatewayUrl ? fmt.url(gatewayUrl) : fmt.dim("(configure gateway domain)")}\n`); + process.stderr.write(` ${fmt.bold("Direct:")} ${url ? fmt.dim(url + " (blocked by secret)") : fmt.dim("n/a")}\n`); + } else { + process.stderr.write( + ` ${fmt.bold("URL:")} ${url ? fmt.url(url) : fmt.dim("(configure workers.dev subdomain to see URL)")}\n` + ); + } + hint("Next", byocDispatchNamespace ? `curl ${gatewayUrl}` : `relight open ${name}`); } // Link this directory to the app @@ -452,6 +490,8 @@ async function deployViaPortal(nameOrPath, path, options) { var remoteTag = `${portalHost}/${name}:${tag}`; // 2. Show summary + var linked2 = readLink(); + var gatewaySummary = buildGatewayMeta(options, linked2); process.stderr.write(`\n${fmt.bold("Deploy summary (portal mode)")}\n`); process.stderr.write(`${fmt.dim("-".repeat(40))}\n`); process.stderr.write(` ${fmt.bold("App:")} ${fmt.app(name)}${prep.isFirstDeploy ? fmt.dim(" (new)") : ""}\n`); @@ -461,6 +501,11 @@ async function deployViaPortal(nameOrPath, path, options) { if (prep.appConfig?.regions) { process.stderr.write(` ${fmt.bold("Regions:")} ${prep.appConfig.regions.join(", ")}\n`); } + if (gatewaySummary) { + process.stderr.write(` ${fmt.bold("Hostname:")} ${gatewaySummary.hostname}\n`); + process.stderr.write(` ${fmt.bold("Groups:")} ${gatewaySummary.groups.length ? gatewaySummary.groups.join(", ") : fmt.dim("(none)")}\n`); + process.stderr.write(` ${fmt.bold("Match:")} ${gatewaySummary.match_mode}\n`); + } process.stderr.write(`${fmt.dim("-".repeat(40))}\n`); if (!options.yes) { @@ -480,32 +525,36 @@ async function deployViaPortal(nameOrPath, path, options) { status(`${localTag} for linux/amd64`); dockerBuild(dockerPath, localTag, { platform: "linux/amd64" }); - // 4. Docker login to portal + push (portal proxies to real registry) - // Docker handles layer caching - only pushes layers that are missing. - phase("Pushing image via portal"); - status("Authenticating with portal registry..."); - dockerLogin(portalHost, "user", portal.token); - - status(`Pushing ${remoteTag}...`); - dockerTag(localTag, remoteTag); - dockerPush(remoteTag); - - // 5. Get the real image tag from portal (mapped to destination registry) - // The manifest push returns the real imageTag via the prepare endpoint + // 4. Push image to registry var imageTag; - try { - var tagInfo = await portalApi("POST", `/deploy/${name}/prepare`); - // Use the tag we pushed - portal knows the mapping - imageTag = remoteTag; - } catch { + if (prep.cfRegistry) { + // CF compute: push directly to CF Registry (skip external registry — CF can't pull from GHCR) + phase("Pushing to CF Registry"); + var cfr = prep.cfRegistry; + var cfTag = cfr.imageTag.replace(/:latest$/, `:${tag}`); + dockerLogin(cfr.registry, cfr.username, cfr.password); + dockerTag(localTag, cfTag); + status(`Pushing ${cfTag}...`); + dockerPush(cfTag); + imageTag = cfTag; + } else { + // Non-CF compute: push via portal OCI proxy to configured registry + phase("Pushing image via portal"); + status("Authenticating with portal registry..."); + dockerLogin(portalHost, "user", portal.token); + status(`Pushing ${remoteTag}...`); + dockerTag(localTag, remoteTag); + dockerPush(remoteTag); imageTag = remoteTag; } - // 6. Tell portal to deploy + // 6. Tell portal to deploy (include gateway routing if specified) phase("Deploying via portal"); + var linked = readLink(); + var gateway = buildGatewayMeta(options, linked); var result; try { - result = await portalApi("POST", `/deploy/${name}`, { imageTag, tag }); + result = await portalApi("POST", `/deploy/${name}`, { imageTag, tag, gateway }); } catch (err) { fatal(`Portal deploy failed: ${err.message}`); } @@ -528,6 +577,12 @@ async function deployViaPortal(nameOrPath, path, options) { undefined, providerNames.registry ); + + // Persist gateway config in .relight.yaml for future deploys + if (gateway) { + var { updateLink } = await import("../lib/link.js"); + updateLink({ gateway }); + } } // --- Pre-deploy: run command inside built image with production env vars --- diff --git a/src/lib/clouds/cf.js b/src/lib/clouds/cf.js index 8398a4c..81a7df6 100644 --- a/src/lib/clouds/cf.js +++ b/src/lib/clouds/cf.js @@ -12,6 +12,7 @@ export var TOKEN_URL = { key: "containers", type: "edit" }, { key: "zone", type: "read" }, { key: "dns", type: "edit" }, + { key: "workers_routes", type: "edit" }, ]) ) + "&name=relight-cli"; @@ -126,6 +127,35 @@ export async function uploadWorker(accountId, apiToken, scriptName, code, metada return res.json(); } +export async function uploadWorkerToDispatchNamespace(accountId, apiToken, namespace, scriptName, code, metadata) { + var form = new FormData(); + form.append( + "metadata", + new Blob([JSON.stringify(metadata)], { type: "application/json" }) + ); + form.append( + "index.js", + new Blob([code], { type: "application/javascript+module" }), + "index.js" + ); + + var res = await fetch( + `${CF_API}/accounts/${accountId}/workers/dispatch/namespaces/${namespace}/scripts/${scriptName}`, + { + method: "PUT", + headers: { Authorization: `Bearer ${apiToken}` }, + body: form, + } + ); + + if (!res.ok) { + var text = await res.text(); + throw new Error(`Dispatch namespace worker upload failed: ${res.status} ${text}`); + } + + return res.json(); +} + export async function deleteWorker(accountId, apiToken, scriptName) { return cfApi( "DELETE", @@ -135,6 +165,15 @@ export async function deleteWorker(accountId, apiToken, scriptName) { ); } +export async function deleteWorkerFromDispatchNamespace(accountId, apiToken, namespace, scriptName) { + return cfApi( + "DELETE", + `/accounts/${accountId}/workers/dispatch/namespaces/${namespace}/scripts/${scriptName}`, + null, + apiToken + ); +} + export async function patchWorkerSettings(accountId, apiToken, scriptName, settings) { var form = new FormData(); form.append( @@ -338,6 +377,37 @@ export async function updateDnsRecord(accountId, apiToken, zoneId, recordId, rec ); } +// --- Zone-level Worker Routes --- + +export async function listWorkerRoutes(accountId, apiToken, zoneId) { + var res = await cfApi( + "GET", + `/zones/${zoneId}/workers/routes`, + null, + apiToken + ); + return res.result || []; +} + +export async function createWorkerRoute(accountId, apiToken, zoneId, pattern, scriptName) { + var res = await cfApi( + "POST", + `/zones/${zoneId}/workers/routes`, + { pattern, script: scriptName }, + apiToken + ); + return res.result; +} + +export async function deleteWorkerRoute(accountId, apiToken, zoneId, routeId) { + return cfApi( + "DELETE", + `/zones/${zoneId}/workers/routes/${routeId}`, + null, + apiToken + ); +} + // --- Workers custom domains --- export async function addWorkerDomain(accountId, apiToken, scriptName, hostname, zoneId) { diff --git a/src/lib/providers/cf/app.js b/src/lib/providers/cf/app.js index cc15b76..dc2eac9 100644 --- a/src/lib/providers/cf/app.js +++ b/src/lib/providers/cf/app.js @@ -108,6 +108,15 @@ export function buildWorkerMetadata(appConfig, { firstDeploy = false, newSecrets } } + // System-level secrets injected by portal (not in appConfig.secretKeys) + if (newSecrets) { + for (var sysKey of ["GATEWAY_SECRET"]) { + if (newSecrets[sysKey] !== undefined && !secretKeys.includes(sysKey)) { + bindings.push({ type: "secret_text", name: sysKey, text: newSecrets[sysKey] }); + } + } + } + var metadata = { main_module: "index.js", compatibility_date: "2025-10-08", @@ -164,7 +173,6 @@ export async function deploy(cfg, appName, imageTag, opts) { var isFirstDeploy = opts.isFirstDeploy; var newSecrets = opts.newSecrets || {}; - // Upload worker var currentHash = templateHash(); var needsWorkerUpload = isFirstDeploy || appConfig.templateHash !== currentHash; @@ -172,7 +180,16 @@ export async function deploy(cfg, appName, imageTag, opts) { var bundledCode = getWorkerBundle(); appConfig.templateHash = currentHash; var metadata = buildWorkerMetadata(appConfig, { firstDeploy: isFirstDeploy, newSecrets }); - await uploadWorker(cfg.accountId, cfg.apiToken, scriptName, bundledCode, metadata); + try { + await uploadWorker(cfg.accountId, cfg.apiToken, scriptName, bundledCode, metadata); + } catch (err) { + if (isFirstDeploy && err.message && err.message.includes("10079")) { + metadata = buildWorkerMetadata(appConfig, { firstDeploy: false, newSecrets }); + await uploadWorker(cfg.accountId, cfg.apiToken, scriptName, bundledCode, metadata); + } else { + throw err; + } + } } else { await pushAppConfig(cfg, appName, appConfig, { newSecrets }); } @@ -212,7 +229,6 @@ export async function deploy(cfg, appName, imageTag, opts) { }); } - // Enable workers.dev route try { await enableWorkerSubdomain(cfg.accountId, cfg.apiToken, scriptName); } catch {} @@ -245,7 +261,7 @@ export async function getAppInfo(cfg, appName) { // --- Destroy --- -export async function destroyApp(cfg, appName) { +export async function destroyApp(cfg, appName, opts) { var scriptName = `relight-${appName}`; // Delete D1 database if attached @@ -266,7 +282,6 @@ export async function destroyApp(cfg, appName) { } } catch {} - // Delete worker await deleteWorker(cfg.accountId, cfg.apiToken, scriptName); } diff --git a/src/lib/providers/cf/bundle.js b/src/lib/providers/cf/bundle.js index 469ba3d..0b036e3 100644 --- a/src/lib/providers/cf/bundle.js +++ b/src/lib/providers/cf/bundle.js @@ -13,7 +13,7 @@ import { homedir } from "os"; import { fileURLToPath } from "url"; var __dirname = dirname(fileURLToPath(import.meta.url)); -var templateDir = join(__dirname, "..", "..", "..", "worker-template"); +var templateDir = join(__dirname, "..", "..", "..", "..", "worker-template"); var cacheDir = join(homedir(), ".relight"); var bundlePath = join(cacheDir, "worker-bundle.js"); var hashPath = join(cacheDir, "worker-bundle.hash"); diff --git a/worker-template/package-lock.json b/worker-template/package-lock.json new file mode 100644 index 0000000..c002e2e --- /dev/null +++ b/worker-template/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "@relight/worker-template", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@relight/worker-template", + "version": "0.1.0", + "dependencies": { + "@cloudflare/containers": "^0.0.30" + } + }, + "node_modules/@cloudflare/containers": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.0.30.tgz", + "integrity": "sha512-i148xBgmyn/pje82ZIyuTr/Ae0BT/YWwa1/GTJcw6DxEjUHAzZLaBCiX446U9OeuJ2rBh/L/9FIzxX5iYNt1AQ==", + "license": "ISC" + } + } +} diff --git a/worker-template/src/index.js b/worker-template/src/index.js index 970d327..39fc53f 100644 --- a/worker-template/src/index.js +++ b/worker-template/src/index.js @@ -31,6 +31,15 @@ export default { async fetch(request, env) { var appConfig = JSON.parse(env.RELIGHT_APP_CONFIG); + // Gateway secret: when set, reject requests without the correct header. + // This makes the worker unreachable directly even though it has a workers.dev URL. + if (env.GATEWAY_SECRET) { + var gatewayHeader = request.headers.get("x-gateway-secret"); + if (gatewayHeader !== env.GATEWAY_SECRET) { + return new Response("Forbidden", { status: 403 }); + } + } + // Hrana protocol handler - only active when D1 binding exists if (env.DB) { var url = new URL(request.url); From 395d6dca6ecaff2ae322cab889d0d17cce5e9ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Frankiewicz?= Date: Fri, 24 Jul 2026 21:52:25 +0200 Subject: [PATCH 2/2] gateway and portal related fixes --- src/cli.js | 161 ++++++++++-- src/commands/apps.js | 269 ++++++++++++++++--- src/commands/clouds.js | 495 ----------------------------------- src/commands/db.js | 93 ++++++- src/commands/deploy.js | 183 +++---------- src/commands/portals.js | 47 ++++ src/commands/providers.js | 44 ++++ src/commands/scale.js | 156 ++++++++--- src/commands/secrets.js | 152 +++++++++++ src/commands/service.js | 279 -------------------- src/commands/ssh-keys.js | 69 +++++ src/lib/clouds/cf.js | 28 ++ src/lib/clouds/do.js | 87 ++++++ src/lib/config.js | 4 +- src/lib/docker.js | 6 +- src/lib/portal.js | 9 + src/lib/providers/cf/app.js | 36 ++- src/lib/providers/do/db.js | 142 ++++++++++ src/lib/providers/resolve.js | 29 +- worker-template/src/index.js | 88 ++++++- 20 files changed, 1346 insertions(+), 1031 deletions(-) delete mode 100644 src/commands/clouds.js create mode 100644 src/commands/secrets.js delete mode 100644 src/commands/service.js create mode 100644 src/commands/ssh-keys.js create mode 100644 src/lib/clouds/do.js create mode 100644 src/lib/providers/do/db.js diff --git a/src/cli.js b/src/cli.js index 46aa4c7..c053713 100755 --- a/src/cli.js +++ b/src/cli.js @@ -4,7 +4,7 @@ import { Command } from "commander"; import { providersList, providersAdd, providersRemove, providersDefaultCmd } from "./commands/providers.js"; import { doctor } from "./commands/doctor.js"; import { deploy } from "./commands/deploy.js"; -import { appsList, appsInfo, appsDestroy } from "./commands/apps.js"; +import { appsList, appsInfo, appsCreate, appsDestroy } from "./commands/apps.js"; import { configShow, configSet, @@ -64,34 +64,34 @@ providersCmd program .command("deploy [name] [path]") - .description("Deploy an app from a Dockerfile (name auto-generated if omitted)") - .option("--compute ", "Provider for compute") - .option("--registry ", "Provider for container registry") - .option("-t, --tag ", "Image tag (default: deploy-)") - .option("-e, --env ", "Set env vars (KEY=VALUE)") - .option( - "--regions ", - "Comma-separated location hints (wnam,enam,sam,weur,eeur,apac,oc,afr,me)" - ) - .option("-i, --instances ", "Instances per region", parseInt) - .option("--port ", "Container port", parseInt) - .option("--sleep ", "Sleep after idle (e.g. 5m, 30s, never)", "30s") - .option("--instance-type ", "Instance type (lite, base, standard, large)") - .option("--vcpu ", "vCPU allocation (e.g. 0.0625, 0.5, 1, 2)", parseFloat) - .option("--memory ", "Memory in MiB (e.g. 256, 512, 1024)", parseInt) - .option("--disk ", "Disk in MB (e.g. 2000, 5000)", parseInt) - .option("--dns ", "Provider for DNS records") - .option("--no-observability", "Disable Workers observability/logs") - .option("--db ", "Database provider (for --backup-db)") - .option("--pre-deploy ", "Run command in container before push (e.g. migrations)") - .option("--backup-db", "Backup database before deploying") - .option("--gateway", "Deploy behind gateway (protected by shared secret)") - .option("--hostname ", "Gateway: hostname (e.g. app.example.com, *.internal.example.com)") - .option("--groups ", "Gateway: required access groups (comma-separated)") - .option("--match-mode ", "Gateway: group match mode (any|all)", "any") - .option("--path-prefix ", "Gateway: path prefix (default: /)", "/") + .description("Build and deploy an app (portal: app must exist — create with apps create)") + .option("-t, --tag ", "Image tag (default: timestamp)") + .option("--dockerfile ", "Path to Dockerfile (relative to build context)") + .option("--build-secret ", "Docker BuildKit secrets passed to docker build (e.g. id=github_token,env=MY_TOKEN)") .option("--json", "Output result as JSON") .option("-y, --yes", "Skip confirmation prompt") + // BYOC-only options (ignored in portal mode): + .option("--compute ", "BYOC: compute provider") + .option("--registry ", "BYOC: container registry provider") + .option("-e, --env ", "BYOC: set env vars (KEY=VALUE)") + .option("--regions ", "BYOC: location hints (wnam,enam,sam,weur,eeur,apac,oc,afr,me)") + .option("-i, --instances ", "BYOC: instances per region", parseInt) + .option("--port ", "BYOC: container port", parseInt) + .option("--sleep ", "BYOC: sleep after idle (e.g. 5m, 30s, never)") + .option("--instance-type ", "BYOC: instance type (lite, basic, standard, standard-2..4, dev)") + .option("--vcpu ", "BYOC: vCPU allocation", parseFloat) + .option("--memory ", "BYOC: memory in MiB", parseInt) + .option("--disk ", "BYOC: disk in MB", parseInt) + .option("--dns ", "BYOC: DNS provider") + .option("--no-observability", "BYOC: disable Workers observability/logs") + .option("--db ", "BYOC: database provider (for --backup-db)") + .option("--pre-deploy ", "BYOC: run command in container before push") + .option("--backup-db", "BYOC: backup database before deploying") + .option("--gateway", "BYOC: deploy behind gateway") + .option("--hostname ", "BYOC: gateway hostname") + .option("--groups ", "BYOC: gateway access groups (comma-separated)") + .option("--match-mode ", "BYOC: gateway group match mode (any|all)", "any") + .option("--path-prefix ", "BYOC: gateway path prefix", "/") .action(deploy); // --- Apps (topic root = list) --- @@ -105,6 +105,32 @@ apps .option("--json", "Output as JSON") .action(appsList); +apps + .command("create ") + .description("Register a new app with its configuration (portal mode)") + .option("--compute