From c451d71a97c477f208506617e382838b3fbea042 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 29 Apr 2026 01:59:09 +0000 Subject: [PATCH] feat: add appstore install CLI --- ts/cli.ts | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 192 insertions(+), 13 deletions(-) diff --git a/ts/cli.ts b/ts/cli.ts index 0f7b2eb..e2257c4 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -8,16 +8,17 @@ import { getErrorMessage } from './utils/error.ts'; import { Onebox } from './classes/onebox.ts'; import { OneboxDaemon } from './classes/daemon.ts'; import { OneboxSystemd } from './classes/systemd.ts'; +import type { IAppVersionConfig } from './classes/appstore-types.ts'; export async function runCli(): Promise { const args = Deno.args; - if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + if (args.length === 0 || (args.length === 1 && (args[0] === '--help' || args[0] === '-h'))) { printHelp(); return; } - if (args.includes('--version') || args.includes('-v')) { + if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) { console.log(`${projectInfo.name} v${projectInfo.version}`); return; } @@ -70,6 +71,11 @@ export async function runCli(): Promise { await handleSslCommand(onebox, subcommand, args.slice(2)); break; + case 'appstore': + await handleAppStoreCommand(onebox, subcommand, args.slice(2)); + break; + + case 'proxy': case 'nginx': await handleNginxCommand(onebox, subcommand, args.slice(2)); break; @@ -104,12 +110,11 @@ async function handleServiceCommand(onebox: Onebox, subcommand: string, args: st const image = getArg(args, '--image'); const domain = getArg(args, '--domain'); const port = parseInt(getArg(args, '--port') || '80', 10); - const envArgs = args.filter((a) => a.startsWith('--env=')).map((a) => a.slice(6)); - const envVars: Record = {}; - for (const env of envArgs) { - const [key, value] = env.split('='); - envVars[key] = value; - } + const envVars = parseEnvArgs(args); + + requireValue(name, 'service name'); + requireValue(image, '--image'); + assertValidPort(port, '--port'); await onebox.services.deployService({ name, image, port, domain, envVars }); break; @@ -158,6 +163,7 @@ async function handleRegistryCommand(onebox: Onebox, subcommand: string, args: s const url = getArg(args, '--url'); const username = getArg(args, '--username'); const password = getArg(args, '--password'); + requireValue(url, '--url'); await onebox.registries.addRegistry(url, username, password); break; } @@ -180,6 +186,76 @@ async function handleRegistryCommand(onebox: Onebox, subcommand: string, args: s } } +// App Store commands +async function handleAppStoreCommand(onebox: Onebox, subcommand: string, args: string[]) { + switch (subcommand) { + case 'list': { + const apps = await onebox.appStore.getApps(); + logger.table( + ['ID', 'Name', 'Category', 'Latest'], + apps.map((app) => [app.id, app.name, app.category, app.latestVersion]) + ); + break; + } + + case 'config': { + const appId = args[0]; + requireValue(appId, 'app id'); + const appMeta = await onebox.appStore.getAppMeta(appId); + const version = getArg(args, '--version') || appMeta.latestVersion; + const config = await onebox.appStore.getAppVersionConfig(appId, version); + console.log(JSON.stringify({ appMeta, version, config }, null, 2)); + break; + } + + case 'install': { + const appId = args[0]; + requireValue(appId, 'app id'); + + const appMeta = await onebox.appStore.getAppMeta(appId); + const version = getArg(args, '--version') || appMeta.latestVersion; + const config = await onebox.appStore.getAppVersionConfig(appId, version); + const serviceName = getArg(args, '--name') || appId; + const domain = getArg(args, '--domain'); + const port = parseInt(getArg(args, '--port') || String(config.port), 10); + const envVars = getAppStoreEnvVars(config, parseEnvArgs(args)); + const autoDNS = getBooleanArg(args, '--auto-dns', true); + + requireValue(serviceName, '--name'); + assertValidPort(port, '--port'); + if (requiresTemplateValue(envVars, 'SERVICE_DOMAIN')) { + requireValue(domain, '--domain'); + } + + const service = await onebox.services.deployService({ + name: serviceName, + image: config.image, + port, + domain, + autoDNS, + envVars, + enableMongoDB: Boolean(config.platformRequirements?.mongodb), + enableS3: Boolean(config.platformRequirements?.s3), + enableClickHouse: Boolean(config.platformRequirements?.clickhouse), + enableRedis: Boolean(config.platformRequirements?.redis), + enableMariaDB: Boolean(config.platformRequirements?.mariadb), + appTemplateId: appId, + appTemplateVersion: version, + }); + + logger.success(`Installed ${appMeta.name} ${version} as ${service.name}`); + if (service.domain) { + logger.info(`Route: https://${service.domain}`); + } + break; + } + + default: + logger.error(`Unknown appstore subcommand: ${subcommand}`); + logger.info('Available: list, config, install'); + } +} + // DNS commands async function handleDnsCommand(onebox: Onebox, subcommand: string, args: string[]) { switch (subcommand) { @@ -494,8 +570,106 @@ async function handleUpgradeCommand(): Promise { // Helpers function getArg(args: string[], flag: string): string { - const arg = args.find((a) => a.startsWith(`${flag}=`)); - return arg ? arg.split('=')[1] : ''; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith(`${flag}=`)) { + return arg.slice(flag.length + 1); + } + if (arg === flag) { + const value = args[i + 1]; + return value && !value.startsWith('--') ? value : ''; + } + } + return ''; +} + +function getRepeatedArgs(args: string[], flag: string): string[] { + const values: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith(`${flag}=`)) { + values.push(arg.slice(flag.length + 1)); + continue; + } + if (arg === flag) { + const value = args[i + 1]; + if (value && !value.startsWith('--')) { + values.push(value); + i++; + } + } + } + return values; +} + +function getBooleanArg(args: string[], flag: string, defaultValue: boolean): boolean { + if (args.includes(`--no-${flag.slice(2)}`)) { + return false; + } + const value = getArg(args, flag); + if (!value) { + return args.includes(flag) ? true : defaultValue; + } + return !['0', 'false', 'no', 'off'].includes(value.toLowerCase()); +} + +function parseEnvArgs(args: string[]): Record { + const envVars: Record = {}; + for (const envArg of getRepeatedArgs(args, '--env')) { + const separatorIndex = envArg.indexOf('='); + if (separatorIndex === -1) { + throw new Error(`Invalid --env value '${envArg}'. Expected KEY=VALUE.`); + } + const key = envArg.slice(0, separatorIndex); + const value = envArg.slice(separatorIndex + 1); + requireValue(key, '--env key'); + envVars[key] = value; + } + return envVars; +} + +function getAppStoreEnvVars( + configArg: IAppVersionConfig, + overridesArg: Record, +): Record { + const envVars: Record = {}; + const missingRequiredEnvVars: string[] = []; + + for (const envVar of configArg.envVars || []) { + const value = overridesArg[envVar.key] ?? envVar.value ?? ''; + if (envVar.required && !value) { + missingRequiredEnvVars.push(envVar.key); + } + envVars[envVar.key] = value; + } + + for (const [key, value] of Object.entries(overridesArg)) { + envVars[key] = value; + } + + if (missingRequiredEnvVars.length > 0) { + throw new Error( + `Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}. Use --env KEY=VALUE.` + ); + } + + return envVars; +} + +function requiresTemplateValue(envVarsArg: Record, templateNameArg: string): boolean { + return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`)); +} + +function requireValue(valueArg: string | undefined, labelArg: string): asserts valueArg is string { + if (!valueArg) { + throw new Error(`Missing required ${labelArg}`); + } +} + +function assertValidPort(portArg: number, labelArg: string): void { + if (!Number.isInteger(portArg) || portArg <= 0 || portArg > 65535) { + throw new Error(`Invalid ${labelArg}: ${portArg}`); + } } function printHelp(): void { @@ -532,9 +706,13 @@ Commands: ssl list ssl force-renew - nginx reload - nginx test - nginx status + appstore list + appstore config [--version ] + appstore install --name [--domain ] [--version ] [--env KEY=VALUE] + + proxy reload # nginx alias is still supported + proxy test + proxy status systemd enable Install and enable systemd service systemd disable Stop, disable, and remove systemd service @@ -568,6 +746,7 @@ Production Workflow: Examples: onebox server --ephemeral # Start dev server onebox service add myapp --image nginx:latest --domain app.example.com --port 80 + onebox appstore install cloudly --name cloudly --domain cloudly.example.com --env SERVEZONE_ADMINACCOUNT=admin:password onebox registry add --url registry.example.com --username user --password pass onebox systemd enable onebox systemd start