diff --git a/api/internal/features/deploy/docker/init.go b/api/internal/features/deploy/docker/init.go index c40c61899..6cc12e2d1 100644 --- a/api/internal/features/deploy/docker/init.go +++ b/api/internal/features/deploy/docker/init.go @@ -331,7 +331,8 @@ func (s *DockerService) ComposeUp(composeFilePath string, envVars map[string]str for k, v := range envVars { envVarsStr += fmt.Sprintf("export %s=%s && ", k, v) } - command := fmt.Sprintf("%sdocker compose -f %s up -d", envVarsStr, composeFilePath) + // Use --force-recreate to handle existing containers and --remove-orphans to clean up old containers + command := fmt.Sprintf("%sdocker compose -f %s up -d --force-recreate --remove-orphans 2>&1", envVarsStr, composeFilePath) output, err := client.RunCommand(command) if err != nil { return fmt.Errorf("failed to start docker compose services: %v, output: %s", err, output) diff --git a/api/internal/features/extension/engine/docker_compose.go b/api/internal/features/extension/engine/docker_compose.go index 256fe7571..a72b56b60 100644 --- a/api/internal/features/extension/engine/docker_compose.go +++ b/api/internal/features/extension/engine/docker_compose.go @@ -14,13 +14,15 @@ type dockerComposeModule struct{} func (dockerComposeModule) Type() string { return "docker_compose" } func (dockerComposeModule) Execute(_ *ssh.SSH, step types.SpecStep, vars map[string]interface{}) (string, func(), error) { - file, _ := step.Properties["file"].(string) + fileRaw, _ := step.Properties["file"].(string) action, _ := step.Properties["action"].(string) // up, down, pull, build, restart _, _ = step.Properties["project"].(string) _, _ = step.Properties["args"].(string) revertCmdRaw, _ := step.Properties["revert_cmd"].(string) _, _ = step.Properties["user"].(string) + file := replaceVars(fileRaw, vars) + if action == "" { return "", nil, fmt.Errorf("docker_compose action is required") } diff --git a/api/internal/features/extension/engine/file.go b/api/internal/features/extension/engine/file.go index d77f1bdfb..82c406e7c 100644 --- a/api/internal/features/extension/engine/file.go +++ b/api/internal/features/extension/engine/file.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/pkg/sftp" "github.com/raghavyuva/nixopus-api/internal/features/ssh" @@ -108,10 +109,36 @@ var actionHandlers = map[string]fileAction{ "mkdir": handleMkdir, } +// expandTilde expands ~ to the actual home directory path for SFTP compatibility +func expandTilde(path string, sshClient *ssh.SSH) string { + if len(path) > 0 && path[0] == '~' { + if len(path) == 1 || path[1] == '/' { + // Get home directory via command + homeOutput, err := sshClient.RunCommand("echo $HOME") + if err == nil && len(homeOutput) > 0 { + home := strings.TrimSpace(homeOutput) + if len(path) == 1 { + return home + } + return home + path[1:] + } + } + } + return path +} + func (fileModule) Execute(sshClient *ssh.SSH, step types.SpecStep, vars map[string]interface{}) (string, func(), error) { action, _ := step.Properties["action"].(string) - src, _ := step.Properties["src"].(string) - dest, _ := step.Properties["dest"].(string) + srcRaw, _ := step.Properties["src"].(string) + destRaw, _ := step.Properties["dest"].(string) + + // Replace variables in src and dest paths + src := replaceVars(srcRaw, vars) + dest := replaceVars(destRaw, vars) + + // Expand tilde to $HOME for SFTP compatibility + src = expandTilde(src, sshClient) + dest = expandTilde(dest, sshClient) if action == "mkdir" && dest == "" { return "", nil, fmt.Errorf("dest is required for mkdir action") diff --git a/api/internal/features/extension/engine/package.go b/api/internal/features/extension/engine/package.go index f453b79d4..d72eff6ac 100644 --- a/api/internal/features/extension/engine/package.go +++ b/api/internal/features/extension/engine/package.go @@ -13,8 +13,12 @@ type packageModule struct{} func (packageModule) Type() string { return "package" } func (packageModule) Execute(sshClient *ssh.SSH, step types.SpecStep, vars map[string]interface{}) (string, func(), error) { - name, _ := step.Properties["name"].(string) - state, _ := step.Properties["state"].(string) + nameRaw, _ := step.Properties["name"].(string) + stateRaw, _ := step.Properties["state"].(string) + + name := replaceVars(nameRaw, vars) + state := replaceVars(stateRaw, vars) + if name == "" { return "", nil, fmt.Errorf("package name is required") } diff --git a/api/internal/features/extension/engine/service.go b/api/internal/features/extension/engine/service.go index 053585fe8..d98b7db33 100644 --- a/api/internal/features/extension/engine/service.go +++ b/api/internal/features/extension/engine/service.go @@ -12,10 +12,14 @@ type serviceModule struct{} func (serviceModule) Type() string { return "service" } func (serviceModule) Execute(sshClient *ssh.SSH, step types.SpecStep, vars map[string]interface{}) (string, func(), error) { - name, _ := step.Properties["name"].(string) + nameRaw, _ := step.Properties["name"].(string) action, _ := step.Properties["action"].(string) revertAction, _ := step.Properties["revert_action"].(string) - runAsUser, _ := step.Properties["user"].(string) + runAsUserRaw, _ := step.Properties["user"].(string) + + name := replaceVars(nameRaw, vars) + runAsUser := replaceVars(runAsUserRaw, vars) + if name == "" { return "", nil, fmt.Errorf("service name is required for service step") } diff --git a/api/internal/features/extension/engine/user.go b/api/internal/features/extension/engine/user.go index 6e9d49e5b..d9b3aa41e 100644 --- a/api/internal/features/extension/engine/user.go +++ b/api/internal/features/extension/engine/user.go @@ -13,13 +13,18 @@ type userModule struct{} func (userModule) Type() string { return "user" } func (userModule) Execute(sshClient *ssh.SSH, step types.SpecStep, vars map[string]interface{}) (string, func(), error) { - username, _ := step.Properties["username"].(string) + usernameRaw, _ := step.Properties["username"].(string) action, _ := step.Properties["action"].(string) - shell, _ := step.Properties["shell"].(string) - home, _ := step.Properties["home"].(string) - groups, _ := step.Properties["groups"].(string) + shellRaw, _ := step.Properties["shell"].(string) + homeRaw, _ := step.Properties["home"].(string) + groupsRaw, _ := step.Properties["groups"].(string) revertAction, _ := step.Properties["revert_action"].(string) + username := replaceVars(usernameRaw, vars) + shell := replaceVars(shellRaw, vars) + home := replaceVars(homeRaw, vars) + groups := replaceVars(groupsRaw, vars) + if username == "" { return "", nil, fmt.Errorf("username is required for user operations") } diff --git a/api/internal/features/ssh/init.go b/api/internal/features/ssh/init.go index 764496a8d..495f21ace 100644 --- a/api/internal/features/ssh/init.go +++ b/api/internal/features/ssh/init.go @@ -135,10 +135,11 @@ func (s *SSH) RunCommand(cmd string) (string, error) { if err != nil { return "", err } - output, err := client.Run(cmd) + defer client.Close() + output, err := client.Run(cmd) if err != nil { - return "", err + return string(output), err } return string(output), nil diff --git a/api/templates/deploy-postiz.yaml b/api/templates/deploy-postiz.yaml new file mode 100644 index 000000000..c97765c5f --- /dev/null +++ b/api/templates/deploy-postiz.yaml @@ -0,0 +1,546 @@ +metadata: + id: "deploy-postiz" + name: "Postiz" + description: "Postiz is an open-source social media management platform for scheduling posts across multiple platforms including X/Twitter, LinkedIn, Reddit, Facebook, Instagram, TikTok, YouTube, Pinterest, and more." + author: "Nixopus Team" + icon: "📱" + category: "Containers" + type: "install" + version: "1.0.0" + isVerified: false + +variables: + container_name: + type: "string" + description: "Name of the Postiz container" + default: "postiz" + is_required: true + + host_port: + type: "integer" + description: "Host port to expose Postiz" + default: 5001 + is_required: true + + main_url: + type: "string" + description: "Main URL for Postiz (e.g., https://postiz.your-domain.com)" + default: "http://localhost:5001" + is_required: true + + jwt_secret: + type: "string" + description: "JWT secret for authentication (use a strong random string)" + default: "CHANGE_ME_IN_PRODUCTION_USE_A_STRONG_SECRET" + is_required: true + + postgres_user: + type: "string" + description: "PostgreSQL database user" + default: "postiz-user" + is_required: true + + postgres_password: + type: "string" + description: "PostgreSQL database password" + default: "postiz-password" + is_required: true + + postgres_db: + type: "string" + description: "PostgreSQL database name" + default: "postiz-db-local" + is_required: true + + disable_registration: + type: "string" + description: "Disable new user registration (true/false)" + default: "false" + is_required: false + + storage_provider: + type: "string" + description: "Storage provider (local or cloudflare)" + default: "local" + is_required: false + + cloudflare_account_id: + type: "string" + description: "Cloudflare R2 Account ID (optional)" + default: "" + is_required: false + + cloudflare_access_key: + type: "string" + description: "Cloudflare R2 Access Key (optional)" + default: "" + is_required: false + + cloudflare_secret_access_key: + type: "string" + description: "Cloudflare R2 Secret Access Key (optional)" + default: "" + is_required: false + + cloudflare_bucketname: + type: "string" + description: "Cloudflare R2 Bucket Name (optional)" + default: "" + is_required: false + + cloudflare_bucket_url: + type: "string" + description: "Cloudflare R2 Bucket URL (optional)" + default: "" + is_required: false + + openai_api_key: + type: "string" + description: "OpenAI API Key for AI features (optional)" + default: "" + is_required: false + + x_api_key: + type: "string" + description: "X (Twitter) API Key" + default: "" + is_required: false + + x_api_secret: + type: "string" + description: "X (Twitter) API Secret" + default: "" + is_required: false + + linkedin_client_id: + type: "string" + description: "LinkedIn Client ID" + default: "" + is_required: false + + linkedin_client_secret: + type: "string" + description: "LinkedIn Client Secret" + default: "" + is_required: false + + reddit_client_id: + type: "string" + description: "Reddit Client ID" + default: "" + is_required: false + + reddit_client_secret: + type: "string" + description: "Reddit Client Secret" + default: "" + is_required: false + + github_client_id: + type: "string" + description: "GitHub Client ID" + default: "" + is_required: false + + github_client_secret: + type: "string" + description: "GitHub Client Secret" + default: "" + is_required: false + + beehiive_api_key: + type: "string" + description: "Beehiiv API Key" + default: "" + is_required: false + + beehiive_publication_id: + type: "string" + description: "Beehiiv Publication ID" + default: "" + is_required: false + + threads_app_id: + type: "string" + description: "Threads App ID" + default: "" + is_required: false + + threads_app_secret: + type: "string" + description: "Threads App Secret" + default: "" + is_required: false + + facebook_app_id: + type: "string" + description: "Facebook App ID" + default: "" + is_required: false + + facebook_app_secret: + type: "string" + description: "Facebook App Secret" + default: "" + is_required: false + + youtube_client_id: + type: "string" + description: "YouTube Client ID" + default: "" + is_required: false + + youtube_client_secret: + type: "string" + description: "YouTube Client Secret" + default: "" + is_required: false + + tiktok_client_id: + type: "string" + description: "TikTok Client ID" + default: "" + is_required: false + + tiktok_client_secret: + type: "string" + description: "TikTok Client Secret" + default: "" + is_required: false + + pinterest_client_id: + type: "string" + description: "Pinterest Client ID" + default: "" + is_required: false + + pinterest_client_secret: + type: "string" + description: "Pinterest Client Secret" + default: "" + is_required: false + + dribbble_client_id: + type: "string" + description: "Dribbble Client ID" + default: "" + is_required: false + + dribbble_client_secret: + type: "string" + description: "Dribbble Client Secret" + default: "" + is_required: false + + discord_client_id: + type: "string" + description: "Discord Client ID" + default: "" + is_required: false + + discord_client_secret: + type: "string" + description: "Discord Client Secret" + default: "" + is_required: false + + discord_bot_token_id: + type: "string" + description: "Discord Bot Token ID" + default: "" + is_required: false + + slack_id: + type: "string" + description: "Slack Client ID" + default: "" + is_required: false + + slack_secret: + type: "string" + description: "Slack Client Secret" + default: "" + is_required: false + + slack_signing_secret: + type: "string" + description: "Slack Signing Secret" + default: "" + is_required: false + + mastodon_url: + type: "string" + description: "Mastodon Instance URL" + default: "https://mastodon.social" + is_required: false + + mastodon_client_id: + type: "string" + description: "Mastodon Client ID" + default: "" + is_required: false + + mastodon_client_secret: + type: "string" + description: "Mastodon Client Secret" + default: "" + is_required: false + + oauth_display_name: + type: "string" + description: "OAuth Provider Display Name (e.g., Authentik)" + default: "" + is_required: false + + oauth_logo_url: + type: "string" + description: "OAuth Provider Logo URL" + default: "" + is_required: false + + postiz_generic_oauth: + type: "string" + description: "Enable Generic OAuth (true/false)" + default: "false" + is_required: false + + postiz_oauth_url: + type: "string" + description: "OAuth Provider Base URL" + default: "" + is_required: false + + postiz_oauth_auth_url: + type: "string" + description: "OAuth Authorization URL" + default: "" + is_required: false + + postiz_oauth_token_url: + type: "string" + description: "OAuth Token URL" + default: "" + is_required: false + + postiz_oauth_userinfo_url: + type: "string" + description: "OAuth UserInfo URL" + default: "" + is_required: false + + postiz_oauth_client_id: + type: "string" + description: "OAuth Client ID" + default: "" + is_required: false + + postiz_oauth_client_secret: + type: "string" + description: "OAuth Client Secret" + default: "" + is_required: false + + discord_support: + type: "string" + description: "Discord Support Server Invite URL" + default: "" + is_required: false + + polotno: + type: "string" + description: "Polotno API Key for design features" + default: "" + is_required: false + + stripe_publishable_key: + type: "string" + description: "Stripe Publishable Key" + default: "" + is_required: false + + stripe_secret_key: + type: "string" + description: "Stripe Secret Key" + default: "" + is_required: false + + stripe_signing_key: + type: "string" + description: "Stripe Webhook Signing Key" + default: "" + is_required: false + + stripe_signing_key_connect: + type: "string" + description: "Stripe Connect Signing Key" + default: "" + is_required: false + + proxy_domain: + type: "string" + description: "Domain name for reverse proxy (optional, leave empty to skip)" + default: "" + is_required: false + + compose_dir: + type: "string" + description: "Directory to deploy Postiz stack" + default: "/opt/postiz" + is_required: true + + compose_url: + type: "string" + description: "URL to download docker-compose.yml from" + default: "https://raw.githubusercontent.com/zhravan/nixopus-docker-extensions/refs/heads/main/postiz/docker-compose.yml" + is_required: true + +execution: + run: + - name: "Cleanup existing Postiz containers" + type: "command" + properties: + cmd: "sh -c 'cd {{ compose_dir }} 2>/dev/null && docker compose down --remove-orphans 2>/dev/null || true'" + timeout: 60 + ignore_errors: true + + - name: "Create Postiz deployment directory" + type: "file" + properties: + action: "mkdir" + dest: "{{ compose_dir }}" + timeout: 30 + + - name: "Download docker-compose.yml from repository" + type: "command" + properties: + cmd: "sh -c 'curl -fsSL \"{{ compose_url }}\" -o {{ compose_dir }}/docker-compose.yml'" + timeout: 60 + + - name: "Create .env file with configuration" + type: "command" + properties: + cmd: | + cat > {{ compose_dir }}/.env << 'ENV_EOF' + CONTAINER_NAME={{ container_name }} + HOST_PORT={{ host_port }} + MAIN_URL={{ main_url }} + JWT_SECRET={{ jwt_secret }} + POSTGRES_USER={{ postgres_user }} + POSTGRES_PASSWORD={{ postgres_password }} + POSTGRES_DB={{ postgres_db }} + DISABLE_REGISTRATION={{ disable_registration }} + STORAGE_PROVIDER={{ storage_provider }} + CLOUDFLARE_ACCOUNT_ID={{ cloudflare_account_id }} + CLOUDFLARE_ACCESS_KEY={{ cloudflare_access_key }} + CLOUDFLARE_SECRET_ACCESS_KEY={{ cloudflare_secret_access_key }} + CLOUDFLARE_BUCKETNAME={{ cloudflare_bucketname }} + CLOUDFLARE_BUCKET_URL={{ cloudflare_bucket_url }} + OPENAI_API_KEY={{ openai_api_key }} + X_API_KEY={{ x_api_key }} + X_API_SECRET={{ x_api_secret }} + LINKEDIN_CLIENT_ID={{ linkedin_client_id }} + LINKEDIN_CLIENT_SECRET={{ linkedin_client_secret }} + REDDIT_CLIENT_ID={{ reddit_client_id }} + REDDIT_CLIENT_SECRET={{ reddit_client_secret }} + GITHUB_CLIENT_ID={{ github_client_id }} + GITHUB_CLIENT_SECRET={{ github_client_secret }} + BEEHIIVE_API_KEY={{ beehiive_api_key }} + BEEHIIVE_PUBLICATION_ID={{ beehiive_publication_id }} + THREADS_APP_ID={{ threads_app_id }} + THREADS_APP_SECRET={{ threads_app_secret }} + FACEBOOK_APP_ID={{ facebook_app_id }} + FACEBOOK_APP_SECRET={{ facebook_app_secret }} + YOUTUBE_CLIENT_ID={{ youtube_client_id }} + YOUTUBE_CLIENT_SECRET={{ youtube_client_secret }} + TIKTOK_CLIENT_ID={{ tiktok_client_id }} + TIKTOK_CLIENT_SECRET={{ tiktok_client_secret }} + PINTEREST_CLIENT_ID={{ pinterest_client_id }} + PINTEREST_CLIENT_SECRET={{ pinterest_client_secret }} + DRIBBBLE_CLIENT_ID={{ dribbble_client_id }} + DRIBBBLE_CLIENT_SECRET={{ dribbble_client_secret }} + DISCORD_CLIENT_ID={{ discord_client_id }} + DISCORD_CLIENT_SECRET={{ discord_client_secret }} + DISCORD_BOT_TOKEN_ID={{ discord_bot_token_id }} + SLACK_ID={{ slack_id }} + SLACK_SECRET={{ slack_secret }} + SLACK_SIGNING_SECRET={{ slack_signing_secret }} + MASTODON_URL={{ mastodon_url }} + MASTODON_CLIENT_ID={{ mastodon_client_id }} + MASTODON_CLIENT_SECRET={{ mastodon_client_secret }} + OAUTH_DISPLAY_NAME={{ oauth_display_name }} + OAUTH_LOGO_URL={{ oauth_logo_url }} + POSTIZ_GENERIC_OAUTH={{ postiz_generic_oauth }} + POSTIZ_OAUTH_URL={{ postiz_oauth_url }} + POSTIZ_OAUTH_AUTH_URL={{ postiz_oauth_auth_url }} + POSTIZ_OAUTH_TOKEN_URL={{ postiz_oauth_token_url }} + POSTIZ_OAUTH_USERINFO_URL={{ postiz_oauth_userinfo_url }} + POSTIZ_OAUTH_CLIENT_ID={{ postiz_oauth_client_id }} + POSTIZ_OAUTH_CLIENT_SECRET={{ postiz_oauth_client_secret }} + DISCORD_SUPPORT={{ discord_support }} + POLOTNO={{ polotno }} + STRIPE_PUBLISHABLE_KEY={{ stripe_publishable_key }} + STRIPE_SECRET_KEY={{ stripe_secret_key }} + STRIPE_SIGNING_KEY={{ stripe_signing_key }} + STRIPE_SIGNING_KEY_CONNECT={{ stripe_signing_key_connect }} + ENV_EOF + timeout: 60 + + - name: "Pull Docker images" + type: "command" + properties: + cmd: "sh -c 'cd {{ compose_dir }} && docker compose pull'" + timeout: 600 + + - name: "Start Postiz stack with Docker Compose" + type: "docker_compose" + properties: + action: "up" + file: "{{ compose_dir }}/docker-compose.yml" + timeout: 300 + + - name: "Add reverse proxy configuration" + type: "proxy" + properties: + action: "add" + domain: "{{ proxy_domain }}" + port: "{{ host_port }}" + timeout: 30 + ignore_errors: true + + validate: + - name: "Wait for Postiz to be ready" + type: "command" + properties: + cmd: "sleep 60" + timeout: 65 + + - name: "Check PostgreSQL container" + type: "command" + properties: + cmd: "docker ps --filter name=postiz-postgres --filter status=running --format '{{.Names}}' | grep -q postiz-postgres" + timeout: 30 + + - name: "Check Redis container" + type: "command" + properties: + cmd: "docker ps --filter name=postiz-redis --filter status=running --format '{{.Names}}' | grep -q postiz-redis" + timeout: 30 + + - name: "Check Postiz container" + type: "command" + properties: + cmd: "docker ps --filter name={{ container_name }} --filter status=running --format '{{.Names}}' | grep -q {{ container_name }}" + timeout: 30 + + + - name: "Check PostgreSQL health" + type: "command" + properties: + cmd: "docker exec postiz-postgres pg_isready -U {{ postgres_user }} -d {{ postgres_db }}" + timeout: 30 + + - name: "Check Redis health" + type: "command" + properties: + cmd: "docker exec postiz-redis redis-cli ping | grep -q PONG" + timeout: 30 diff --git a/view/app/extensions/components/extension-input.tsx b/view/app/extensions/components/extension-input.tsx index f19805bde..2ca4da8aa 100644 --- a/view/app/extensions/components/extension-input.tsx +++ b/view/app/extensions/components/extension-input.tsx @@ -2,13 +2,14 @@ import React from 'react'; import { DialogWrapper, DialogAction } from '@/components/ui/dialog-wrapper'; -import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { useTranslation } from '@/hooks/use-translation'; import { Extension, ExtensionVariable } from '@/redux/types/extension'; import { useExtensionInput } from '@/app/extensions/hooks/use-extension-input'; +import { Info, Sparkles } from 'lucide-react'; +import { cn } from '@/lib/utils'; interface ExtensionInputProps { open: boolean; @@ -31,11 +32,6 @@ export default function ExtensionInput({ onClose: () => onOpenChange(false) }); - const submit = () => { - onSubmit?.(values); - onOpenChange(false); - }; - const actions: DialogAction[] = [ { label: t('common.cancel'), @@ -44,37 +40,115 @@ export default function ExtensionInput({ }, { label: t('extensions.run'), - onClick: submit, + onClick: handleSubmit, variant: 'default' } ]; + const requiredFields = variables.filter((v) => v.is_required); + const optionalFields = variables.filter((v) => !v.is_required); + return ( + + {extension?.name || t('extensions.run')} + + } description={extension?.description} actions={actions} - size="lg" + size="xl" > -
+
{variables.length === 0 && ( -
{t('extensions.noVariables')}
+
+ +

{t('extensions.noVariables')}

+

+ This extension is ready to run without configuration +

+
+ )} + + {requiredFields.length > 0 && ( +
+
+
+

Required Configuration

+
+
+ {requiredFields.map((v) => ( + + + + ))} +
+
)} - {variables.map((v) => ( -
- - {errors[v.variable_name] && ( -
{errors[v.variable_name]}
- )} + + {optionalFields.length > 0 && ( +
+
+
+

+ Optional Configuration +

+
+
+ {optionalFields.map((v) => ( + + + + ))} +
- ))} + )}
); } +function FieldWrapper({ + error, + children, + fullWidth = false +}: { + error?: string; + children: React.ReactNode; + fullWidth?: boolean; +}) { + return ( +
+ {children} + {error && ( +
+ + {error} +
+ )} +
+ ); +} + function Field({ variable, value, @@ -85,33 +159,47 @@ function Field({ onChange: (name: string, value: unknown) => void; }) { const id = `var-${variable.variable_name}`; + if (variable.variable_type === 'boolean') { return ( -
+
onChange(variable.variable_name, Boolean(v))} + className="mt-0.5" /> -
- +
+ {variable.description && ( - {variable.description} + + {variable.description} + )}
); } + if (variable.variable_type === 'array') { const textValue = Array.isArray(value) ? (value as unknown[]).map((v) => String(v)).join('\n') : String(value ?? ''); return ( -
- +
+ + {variable.description && ( + {variable.description} + )}