Parcourir la source

feat: specify which app have no GUI and therefore don't show the "open" button

Nicolas Meienberger il y a 2 ans
Parent
commit
bed2404541

+ 1 - 1
packages/dashboard/codegen.yml

@@ -1,5 +1,5 @@
 overwrite: true
-schema: "http://localhost:3001/graphql"
+schema: "http://localhost:3000/api/graphql"
 documents: "src/graphql/**/*.graphql"
 generates:
   src/generated/graphql.tsx:

+ 4 - 2
packages/dashboard/src/components/Form/FormInput.tsx

@@ -10,12 +10,14 @@ interface IProps {
   className?: string;
   isInvalid?: boolean;
   size?: Parameters<typeof Input>[0]['size'];
+  hint?: string;
 }
 
-const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, ...rest }) => {
+const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, hint, ...rest }) => {
   return (
     <div className={clsx('transition-all', className)}>
-      {label && <label>{label}</label>}
+      {label && <label className="mb-1">{label}</label>}
+      {hint && <div className="text-sm text-gray-500 mb-1">{hint}</div>}
       <Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...rest} />
       {isInvalid && <span className="text-red-500 text-sm">{error}</span>}
     </div>

+ 281 - 230
packages/dashboard/src/generated/graphql.tsx

@@ -63,6 +63,7 @@ export type AppInfo = {
   https?: Maybe<Scalars['Boolean']>;
   id: Scalars['String'];
   name: Scalars['String'];
+  no_gui?: Maybe<Scalars['Boolean']>;
   port: Scalars['Float'];
   requirements?: Maybe<Scalars['JSONObject']>;
   short_desc: Scalars['String'];
@@ -153,42 +154,34 @@ export type Mutation = {
   updateAppConfig: App;
 };
 
-
 export type MutationInstallAppArgs = {
   input: AppInputType;
 };
 
-
 export type MutationLoginArgs = {
   input: UsernamePasswordInput;
 };
 
-
 export type MutationRegisterArgs = {
   input: UsernamePasswordInput;
 };
 
-
 export type MutationStartAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationStopAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationUninstallAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationUpdateAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationUpdateAppConfigArgs = {
   input: AppInputType;
 };
@@ -205,7 +198,6 @@ export type Query = {
   version: VersionResponse;
 };
 
-
 export type QueryGetAppArgs = {
   id: Scalars['String'];
 };
@@ -252,125 +244,183 @@ export type InstallAppMutationVariables = Exact<{
   input: AppInputType;
 }>;
 
-
-export type InstallAppMutation = { __typename?: 'Mutation', installApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type InstallAppMutation = { __typename?: 'Mutation'; installApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type LoginMutationVariables = Exact<{
   input: UsernamePasswordInput;
 }>;
 
+export type LoginMutation = { __typename?: 'Mutation'; login: { __typename?: 'TokenResponse'; token: string } };
 
-export type LoginMutation = { __typename?: 'Mutation', login: { __typename?: 'TokenResponse', token: string } };
+export type LogoutMutationVariables = Exact<{ [key: string]: never }>;
 
-export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
-
-
-export type LogoutMutation = { __typename?: 'Mutation', logout: boolean };
+export type LogoutMutation = { __typename?: 'Mutation'; logout: boolean };
 
 export type RegisterMutationVariables = Exact<{
   input: UsernamePasswordInput;
 }>;
 
+export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'TokenResponse'; token: string } };
 
-export type RegisterMutation = { __typename?: 'Mutation', register: { __typename?: 'TokenResponse', token: string } };
-
-export type RestartMutationVariables = Exact<{ [key: string]: never; }>;
+export type RestartMutationVariables = Exact<{ [key: string]: never }>;
 
-
-export type RestartMutation = { __typename?: 'Mutation', restart: boolean };
+export type RestartMutation = { __typename?: 'Mutation'; restart: boolean };
 
 export type StartAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
-
-export type StartAppMutation = { __typename?: 'Mutation', startApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type StartAppMutation = { __typename?: 'Mutation'; startApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type StopAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
-
-export type StopAppMutation = { __typename?: 'Mutation', stopApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type StopAppMutation = { __typename?: 'Mutation'; stopApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type UninstallAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
+export type UninstallAppMutation = { __typename?: 'Mutation'; uninstallApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
-export type UninstallAppMutation = { __typename?: 'Mutation', uninstallApp: { __typename: 'App', id: string, status: AppStatusEnum } };
-
-export type UpdateMutationVariables = Exact<{ [key: string]: never; }>;
+export type UpdateMutationVariables = Exact<{ [key: string]: never }>;
 
-
-export type UpdateMutation = { __typename?: 'Mutation', update: boolean };
+export type UpdateMutation = { __typename?: 'Mutation'; update: boolean };
 
 export type UpdateAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
-
-export type UpdateAppMutation = { __typename?: 'Mutation', updateApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type UpdateAppMutation = { __typename?: 'Mutation'; updateApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type UpdateAppConfigMutationVariables = Exact<{
   input: AppInputType;
 }>;
 
-
-export type UpdateAppConfigMutation = { __typename?: 'Mutation', updateAppConfig: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type UpdateAppConfigMutation = { __typename?: 'Mutation'; updateAppConfig: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type GetAppQueryVariables = Exact<{
   appId: Scalars['String'];
 }>;
 
+export type GetAppQuery = {
+  __typename?: 'Query';
+  getApp: {
+    __typename?: 'App';
+    id: string;
+    status: AppStatusEnum;
+    config: any;
+    version?: number | null;
+    exposed: boolean;
+    domain?: string | null;
+    updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
+    info?: {
+      __typename?: 'AppInfo';
+      id: string;
+      port: number;
+      name: string;
+      description: string;
+      available: boolean;
+      version?: string | null;
+      tipi_version: number;
+      short_desc: string;
+      author: string;
+      source: string;
+      categories: Array<AppCategoriesEnum>;
+      url_suffix?: string | null;
+      https?: boolean | null;
+      exposable?: boolean | null;
+      no_gui?: boolean | null;
+      form_fields: Array<{
+        __typename?: 'FormField';
+        type: FieldTypesEnum;
+        label: string;
+        max?: number | null;
+        min?: number | null;
+        hint?: string | null;
+        required?: boolean | null;
+        env_variable: string;
+      }>;
+    } | null;
+  };
+};
 
-export type GetAppQuery = { __typename?: 'Query', getApp: { __typename?: 'App', id: string, status: AppStatusEnum, config: any, version?: number | null, exposed: boolean, domain?: string | null, updateInfo?: { __typename?: 'UpdateInfo', current: number, latest: number, dockerVersion?: string | null } | null, info?: { __typename?: 'AppInfo', id: string, port: number, name: string, description: string, available: boolean, version?: string | null, tipi_version: number, short_desc: string, author: string, source: string, categories: Array<AppCategoriesEnum>, url_suffix?: string | null, https?: boolean | null, exposable?: boolean | null, form_fields: Array<{ __typename?: 'FormField', type: FieldTypesEnum, label: string, max?: number | null, min?: number | null, hint?: string | null, required?: boolean | null, env_variable: string }> } | null } };
-
-export type InstalledAppsQueryVariables = Exact<{ [key: string]: never; }>;
-
-
-export type InstalledAppsQuery = { __typename?: 'Query', installedApps: Array<{ __typename?: 'App', id: string, status: AppStatusEnum, config: any, version?: number | null, updateInfo?: { __typename?: 'UpdateInfo', current: number, latest: number, dockerVersion?: string | null } | null, info?: { __typename?: 'AppInfo', id: string, name: string, description: string, tipi_version: number, short_desc: string, https?: boolean | null } | null }> };
-
-export type ConfiguredQueryVariables = Exact<{ [key: string]: never; }>;
-
-
-export type ConfiguredQuery = { __typename?: 'Query', isConfigured: boolean };
-
-export type ListAppsQueryVariables = Exact<{ [key: string]: never; }>;
-
-
-export type ListAppsQuery = { __typename?: 'Query', listAppsInfo: { __typename?: 'ListAppsResonse', total: number, apps: Array<{ __typename?: 'AppInfo', id: string, available: boolean, tipi_version: number, port: number, name: string, version?: string | null, short_desc: string, author: string, categories: Array<AppCategoriesEnum>, https?: boolean | null }> } };
+export type InstalledAppsQueryVariables = Exact<{ [key: string]: never }>;
 
-export type MeQueryVariables = Exact<{ [key: string]: never; }>;
+export type InstalledAppsQuery = {
+  __typename?: 'Query';
+  installedApps: Array<{
+    __typename?: 'App';
+    id: string;
+    status: AppStatusEnum;
+    config: any;
+    version?: number | null;
+    updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
+    info?: { __typename?: 'AppInfo'; id: string; name: string; description: string; tipi_version: number; short_desc: string; https?: boolean | null } | null;
+  }>;
+};
 
+export type ConfiguredQueryVariables = Exact<{ [key: string]: never }>;
 
-export type MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string } | null };
+export type ConfiguredQuery = { __typename?: 'Query'; isConfigured: boolean };
 
-export type RefreshTokenQueryVariables = Exact<{ [key: string]: never; }>;
+export type ListAppsQueryVariables = Exact<{ [key: string]: never }>;
 
+export type ListAppsQuery = {
+  __typename?: 'Query';
+  listAppsInfo: {
+    __typename?: 'ListAppsResonse';
+    total: number;
+    apps: Array<{
+      __typename?: 'AppInfo';
+      id: string;
+      available: boolean;
+      tipi_version: number;
+      port: number;
+      name: string;
+      version?: string | null;
+      short_desc: string;
+      author: string;
+      categories: Array<AppCategoriesEnum>;
+      https?: boolean | null;
+    }>;
+  };
+};
 
-export type RefreshTokenQuery = { __typename?: 'Query', refreshToken?: { __typename?: 'TokenResponse', token: string } | null };
+export type MeQueryVariables = Exact<{ [key: string]: never }>;
 
-export type SystemInfoQueryVariables = Exact<{ [key: string]: never; }>;
+export type MeQuery = { __typename?: 'Query'; me?: { __typename?: 'User'; id: string } | null };
 
+export type RefreshTokenQueryVariables = Exact<{ [key: string]: never }>;
 
-export type SystemInfoQuery = { __typename?: 'Query', systemInfo?: { __typename?: 'SystemInfoResponse', cpu: { __typename?: 'Cpu', load: number }, disk: { __typename?: 'DiskMemory', available: number, used: number, total: number }, memory: { __typename?: 'DiskMemory', available: number, used: number, total: number } } | null };
+export type RefreshTokenQuery = { __typename?: 'Query'; refreshToken?: { __typename?: 'TokenResponse'; token: string } | null };
 
-export type VersionQueryVariables = Exact<{ [key: string]: never; }>;
+export type SystemInfoQueryVariables = Exact<{ [key: string]: never }>;
 
+export type SystemInfoQuery = {
+  __typename?: 'Query';
+  systemInfo?: {
+    __typename?: 'SystemInfoResponse';
+    cpu: { __typename?: 'Cpu'; load: number };
+    disk: { __typename?: 'DiskMemory'; available: number; used: number; total: number };
+    memory: { __typename?: 'DiskMemory'; available: number; used: number; total: number };
+  } | null;
+};
 
-export type VersionQuery = { __typename?: 'Query', version: { __typename?: 'VersionResponse', current: string, latest?: string | null } };
+export type VersionQueryVariables = Exact<{ [key: string]: never }>;
 
+export type VersionQuery = { __typename?: 'Query'; version: { __typename?: 'VersionResponse'; current: string; latest?: string | null } };
 
 export const InstallAppDocument = gql`
-    mutation InstallApp($input: AppInputType!) {
-  installApp(input: $input) {
-    id
-    status
-    __typename
+  mutation InstallApp($input: AppInputType!) {
+    installApp(input: $input) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type InstallAppMutationFn = Apollo.MutationFunction<InstallAppMutation, InstallAppMutationVariables>;
 
 /**
@@ -398,12 +448,12 @@ export type InstallAppMutationHookResult = ReturnType<typeof useInstallAppMutati
 export type InstallAppMutationResult = Apollo.MutationResult<InstallAppMutation>;
 export type InstallAppMutationOptions = Apollo.BaseMutationOptions<InstallAppMutation, InstallAppMutationVariables>;
 export const LoginDocument = gql`
-    mutation Login($input: UsernamePasswordInput!) {
-  login(input: $input) {
-    token
+  mutation Login($input: UsernamePasswordInput!) {
+    login(input: $input) {
+      token
+    }
   }
-}
-    `;
+`;
 export type LoginMutationFn = Apollo.MutationFunction<LoginMutation, LoginMutationVariables>;
 
 /**
@@ -431,10 +481,10 @@ export type LoginMutationHookResult = ReturnType<typeof useLoginMutation>;
 export type LoginMutationResult = Apollo.MutationResult<LoginMutation>;
 export type LoginMutationOptions = Apollo.BaseMutationOptions<LoginMutation, LoginMutationVariables>;
 export const LogoutDocument = gql`
-    mutation Logout {
-  logout
-}
-    `;
+  mutation Logout {
+    logout
+  }
+`;
 export type LogoutMutationFn = Apollo.MutationFunction<LogoutMutation, LogoutMutationVariables>;
 
 /**
@@ -461,12 +511,12 @@ export type LogoutMutationHookResult = ReturnType<typeof useLogoutMutation>;
 export type LogoutMutationResult = Apollo.MutationResult<LogoutMutation>;
 export type LogoutMutationOptions = Apollo.BaseMutationOptions<LogoutMutation, LogoutMutationVariables>;
 export const RegisterDocument = gql`
-    mutation Register($input: UsernamePasswordInput!) {
-  register(input: $input) {
-    token
+  mutation Register($input: UsernamePasswordInput!) {
+    register(input: $input) {
+      token
+    }
   }
-}
-    `;
+`;
 export type RegisterMutationFn = Apollo.MutationFunction<RegisterMutation, RegisterMutationVariables>;
 
 /**
@@ -494,10 +544,10 @@ export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
 export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
 export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
 export const RestartDocument = gql`
-    mutation Restart {
-  restart
-}
-    `;
+  mutation Restart {
+    restart
+  }
+`;
 export type RestartMutationFn = Apollo.MutationFunction<RestartMutation, RestartMutationVariables>;
 
 /**
@@ -524,14 +574,14 @@ export type RestartMutationHookResult = ReturnType<typeof useRestartMutation>;
 export type RestartMutationResult = Apollo.MutationResult<RestartMutation>;
 export type RestartMutationOptions = Apollo.BaseMutationOptions<RestartMutation, RestartMutationVariables>;
 export const StartAppDocument = gql`
-    mutation StartApp($id: String!) {
-  startApp(id: $id) {
-    id
-    status
-    __typename
+  mutation StartApp($id: String!) {
+    startApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type StartAppMutationFn = Apollo.MutationFunction<StartAppMutation, StartAppMutationVariables>;
 
 /**
@@ -559,14 +609,14 @@ export type StartAppMutationHookResult = ReturnType<typeof useStartAppMutation>;
 export type StartAppMutationResult = Apollo.MutationResult<StartAppMutation>;
 export type StartAppMutationOptions = Apollo.BaseMutationOptions<StartAppMutation, StartAppMutationVariables>;
 export const StopAppDocument = gql`
-    mutation StopApp($id: String!) {
-  stopApp(id: $id) {
-    id
-    status
-    __typename
+  mutation StopApp($id: String!) {
+    stopApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type StopAppMutationFn = Apollo.MutationFunction<StopAppMutation, StopAppMutationVariables>;
 
 /**
@@ -594,14 +644,14 @@ export type StopAppMutationHookResult = ReturnType<typeof useStopAppMutation>;
 export type StopAppMutationResult = Apollo.MutationResult<StopAppMutation>;
 export type StopAppMutationOptions = Apollo.BaseMutationOptions<StopAppMutation, StopAppMutationVariables>;
 export const UninstallAppDocument = gql`
-    mutation UninstallApp($id: String!) {
-  uninstallApp(id: $id) {
-    id
-    status
-    __typename
+  mutation UninstallApp($id: String!) {
+    uninstallApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type UninstallAppMutationFn = Apollo.MutationFunction<UninstallAppMutation, UninstallAppMutationVariables>;
 
 /**
@@ -629,10 +679,10 @@ export type UninstallAppMutationHookResult = ReturnType<typeof useUninstallAppMu
 export type UninstallAppMutationResult = Apollo.MutationResult<UninstallAppMutation>;
 export type UninstallAppMutationOptions = Apollo.BaseMutationOptions<UninstallAppMutation, UninstallAppMutationVariables>;
 export const UpdateDocument = gql`
-    mutation Update {
-  update
-}
-    `;
+  mutation Update {
+    update
+  }
+`;
 export type UpdateMutationFn = Apollo.MutationFunction<UpdateMutation, UpdateMutationVariables>;
 
 /**
@@ -659,14 +709,14 @@ export type UpdateMutationHookResult = ReturnType<typeof useUpdateMutation>;
 export type UpdateMutationResult = Apollo.MutationResult<UpdateMutation>;
 export type UpdateMutationOptions = Apollo.BaseMutationOptions<UpdateMutation, UpdateMutationVariables>;
 export const UpdateAppDocument = gql`
-    mutation UpdateApp($id: String!) {
-  updateApp(id: $id) {
-    id
-    status
-    __typename
+  mutation UpdateApp($id: String!) {
+    updateApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type UpdateAppMutationFn = Apollo.MutationFunction<UpdateAppMutation, UpdateAppMutationVariables>;
 
 /**
@@ -694,14 +744,14 @@ export type UpdateAppMutationHookResult = ReturnType<typeof useUpdateAppMutation
 export type UpdateAppMutationResult = Apollo.MutationResult<UpdateAppMutation>;
 export type UpdateAppMutationOptions = Apollo.BaseMutationOptions<UpdateAppMutation, UpdateAppMutationVariables>;
 export const UpdateAppConfigDocument = gql`
-    mutation UpdateAppConfig($input: AppInputType!) {
-  updateAppConfig(input: $input) {
-    id
-    status
-    __typename
+  mutation UpdateAppConfig($input: AppInputType!) {
+    updateAppConfig(input: $input) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type UpdateAppConfigMutationFn = Apollo.MutationFunction<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>;
 
 /**
@@ -729,47 +779,48 @@ export type UpdateAppConfigMutationHookResult = ReturnType<typeof useUpdateAppCo
 export type UpdateAppConfigMutationResult = Apollo.MutationResult<UpdateAppConfigMutation>;
 export type UpdateAppConfigMutationOptions = Apollo.BaseMutationOptions<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>;
 export const GetAppDocument = gql`
-    query GetApp($appId: String!) {
-  getApp(id: $appId) {
-    id
-    status
-    config
-    version
-    exposed
-    domain
-    updateInfo {
-      current
-      latest
-      dockerVersion
-    }
-    info {
+  query GetApp($appId: String!) {
+    getApp(id: $appId) {
       id
-      port
-      name
-      description
-      available
+      status
+      config
       version
-      tipi_version
-      short_desc
-      author
-      source
-      categories
-      url_suffix
-      https
-      exposable
-      form_fields {
-        type
-        label
-        max
-        min
-        hint
-        required
-        env_variable
+      exposed
+      domain
+      updateInfo {
+        current
+        latest
+        dockerVersion
+      }
+      info {
+        id
+        port
+        name
+        description
+        available
+        version
+        tipi_version
+        short_desc
+        author
+        source
+        categories
+        url_suffix
+        https
+        exposable
+        no_gui
+        form_fields {
+          type
+          label
+          max
+          min
+          hint
+          required
+          env_variable
+        }
       }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useGetAppQuery__
@@ -799,28 +850,28 @@ export type GetAppQueryHookResult = ReturnType<typeof useGetAppQuery>;
 export type GetAppLazyQueryHookResult = ReturnType<typeof useGetAppLazyQuery>;
 export type GetAppQueryResult = Apollo.QueryResult<GetAppQuery, GetAppQueryVariables>;
 export const InstalledAppsDocument = gql`
-    query InstalledApps {
-  installedApps {
-    id
-    status
-    config
-    version
-    updateInfo {
-      current
-      latest
-      dockerVersion
-    }
-    info {
+  query InstalledApps {
+    installedApps {
       id
-      name
-      description
-      tipi_version
-      short_desc
-      https
+      status
+      config
+      version
+      updateInfo {
+        current
+        latest
+        dockerVersion
+      }
+      info {
+        id
+        name
+        description
+        tipi_version
+        short_desc
+        https
+      }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useInstalledAppsQuery__
@@ -849,10 +900,10 @@ export type InstalledAppsQueryHookResult = ReturnType<typeof useInstalledAppsQue
 export type InstalledAppsLazyQueryHookResult = ReturnType<typeof useInstalledAppsLazyQuery>;
 export type InstalledAppsQueryResult = Apollo.QueryResult<InstalledAppsQuery, InstalledAppsQueryVariables>;
 export const ConfiguredDocument = gql`
-    query Configured {
-  isConfigured
-}
-    `;
+  query Configured {
+    isConfigured
+  }
+`;
 
 /**
  * __useConfiguredQuery__
@@ -881,24 +932,24 @@ export type ConfiguredQueryHookResult = ReturnType<typeof useConfiguredQuery>;
 export type ConfiguredLazyQueryHookResult = ReturnType<typeof useConfiguredLazyQuery>;
 export type ConfiguredQueryResult = Apollo.QueryResult<ConfiguredQuery, ConfiguredQueryVariables>;
 export const ListAppsDocument = gql`
-    query ListApps {
-  listAppsInfo {
-    apps {
-      id
-      available
-      tipi_version
-      port
-      name
-      version
-      short_desc
-      author
-      categories
-      https
+  query ListApps {
+    listAppsInfo {
+      apps {
+        id
+        available
+        tipi_version
+        port
+        name
+        version
+        short_desc
+        author
+        categories
+        https
+      }
+      total
     }
-    total
   }
-}
-    `;
+`;
 
 /**
  * __useListAppsQuery__
@@ -927,12 +978,12 @@ export type ListAppsQueryHookResult = ReturnType<typeof useListAppsQuery>;
 export type ListAppsLazyQueryHookResult = ReturnType<typeof useListAppsLazyQuery>;
 export type ListAppsQueryResult = Apollo.QueryResult<ListAppsQuery, ListAppsQueryVariables>;
 export const MeDocument = gql`
-    query Me {
-  me {
-    id
+  query Me {
+    me {
+      id
+    }
   }
-}
-    `;
+`;
 
 /**
  * __useMeQuery__
@@ -961,12 +1012,12 @@ export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
 export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
 export type MeQueryResult = Apollo.QueryResult<MeQuery, MeQueryVariables>;
 export const RefreshTokenDocument = gql`
-    query RefreshToken {
-  refreshToken {
-    token
+  query RefreshToken {
+    refreshToken {
+      token
+    }
   }
-}
-    `;
+`;
 
 /**
  * __useRefreshTokenQuery__
@@ -995,24 +1046,24 @@ export type RefreshTokenQueryHookResult = ReturnType<typeof useRefreshTokenQuery
 export type RefreshTokenLazyQueryHookResult = ReturnType<typeof useRefreshTokenLazyQuery>;
 export type RefreshTokenQueryResult = Apollo.QueryResult<RefreshTokenQuery, RefreshTokenQueryVariables>;
 export const SystemInfoDocument = gql`
-    query SystemInfo {
-  systemInfo {
-    cpu {
-      load
-    }
-    disk {
-      available
-      used
-      total
-    }
-    memory {
-      available
-      used
-      total
+  query SystemInfo {
+    systemInfo {
+      cpu {
+        load
+      }
+      disk {
+        available
+        used
+        total
+      }
+      memory {
+        available
+        used
+        total
+      }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useSystemInfoQuery__
@@ -1041,13 +1092,13 @@ export type SystemInfoQueryHookResult = ReturnType<typeof useSystemInfoQuery>;
 export type SystemInfoLazyQueryHookResult = ReturnType<typeof useSystemInfoLazyQuery>;
 export type SystemInfoQueryResult = Apollo.QueryResult<SystemInfoQuery, SystemInfoQueryVariables>;
 export const VersionDocument = gql`
-    query Version {
-  version {
-    current
-    latest
+  query Version {
+    version {
+      current
+      latest
+    }
   }
-}
-    `;
+`;
 
 /**
  * __useVersionQuery__
@@ -1074,4 +1125,4 @@ export function useVersionLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<Ve
 }
 export type VersionQueryHookResult = ReturnType<typeof useVersionQuery>;
 export type VersionLazyQueryHookResult = ReturnType<typeof useVersionLazyQuery>;
-export type VersionQueryResult = Apollo.QueryResult<VersionQuery, VersionQueryVariables>;
+export type VersionQueryResult = Apollo.QueryResult<VersionQuery, VersionQueryVariables>;

+ 1 - 0
packages/dashboard/src/graphql/queries/getApp.graphql

@@ -26,6 +26,7 @@ query GetApp($appId: String!) {
       url_suffix
       https
       exposable
+      no_gui
       form_fields {
         type
         label

+ 4 - 1
packages/dashboard/src/modules/Apps/components/AppActions.tsx

@@ -76,7 +76,10 @@ const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onS
       }
       break;
     case AppStatusEnum.Running:
-      buttons.push(StopButton, OpenButton);
+      buttons.push(StopButton);
+      if (!app.no_gui) {
+        buttons.push(OpenButton);
+      }
       if (hasSettings) {
         buttons.push(SettingsButton);
       }

+ 3 - 1
packages/dashboard/src/modules/Apps/components/InstallForm.tsx

@@ -28,7 +28,9 @@ const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues, exp
       <Field
         key={field.env_variable}
         name={field.env_variable}
-        render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label={field.label} {...input} />}
+        render={({ input, meta }) => (
+          <FormInput hint={field.hint || ''} className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label={field.label} {...input} />
+        )}
       />
     );
   };

+ 5 - 4
packages/system-api/src/core/config/TipiConfig.ts

@@ -112,10 +112,11 @@ class Config {
     this.config = configSchema.parse(newConf);
 
     if (writeFile) {
-      const currentJsonConf = readJsonFile<Partial<z.infer<typeof configSchema>>>('/runtipi/state/settings.json') || {};
-      currentJsonConf[key] = value;
-      const partialConfig = configSchema.partial();
-      const parsed = partialConfig.parse(currentJsonConf);
+      const currentJsonConf = readJsonFile('/runtipi/state/settings.json') || {};
+      const parsedConf = configSchema.partial().parse(currentJsonConf);
+
+      parsedConf[key] = value;
+      const parsed = configSchema.partial().parse(parsedConf);
 
       fs.writeFileSync('/runtipi/state/settings.json', JSON.stringify(parsed));
     }

+ 1 - 1
packages/system-api/src/core/config/__tests__/TipiConfig.test.ts

@@ -48,7 +48,7 @@ describe('Test: setConfig', () => {
     expect(config).toBeDefined();
     expect(config.appsRepoUrl).toBe(randomWord);
 
-    const settingsJson = readJsonFile<any>('/runtipi/state/settings.json');
+    const settingsJson = readJsonFile('/runtipi/state/settings.json') as { [key: string]: string };
 
     expect(settingsJson).toBeDefined();
     expect(settingsJson.appsRepoUrl).toBe(randomWord);

+ 31 - 18
packages/system-api/src/core/updates/v040.ts

@@ -1,12 +1,15 @@
+import { z } from 'zod';
 import logger from '../../config/logger/logger';
 import App from '../../modules/apps/app.entity';
-import { AppInfo, AppStatusEnum } from '../../modules/apps/apps.types';
+import { appInfoSchema } from '../../modules/apps/apps.helpers';
+import { AppStatusEnum } from '../../modules/apps/apps.types';
 import User from '../../modules/auth/user.entity';
 import { deleteFolder, fileExists, readFile, readJsonFile } from '../../modules/fs/fs.helpers';
 import Update, { UpdateStatusEnum } from '../../modules/system/update.entity';
 import { getConfig } from '../config/TipiConfig';
 
-type AppsState = { installed: string };
+const appStateSchema = z.object({ installed: z.string().optional().default('') });
+const userStateSchema = z.object({ email: z.string(), password: z.string() }).array();
 
 const UPDATE_NAME = 'v040';
 
@@ -25,17 +28,21 @@ const migrateApp = async (appId: string): Promise<void> => {
 
     const form: Record<string, string> = {};
 
-    const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
-    configFile?.form_fields?.forEach((field) => {
-      const envVar = field.env_variable;
-      const envVarValue = envVarsMap.get(envVar);
+    const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
+    const parsedConfig = appInfoSchema.safeParse(configFile);
 
-      if (envVarValue) {
-        form[field.env_variable] = envVarValue;
-      }
-    });
+    if (parsedConfig.success) {
+      parsedConfig.data.form_fields.forEach((field) => {
+        const envVar = field.env_variable;
+        const envVarValue = envVarsMap.get(envVar);
+
+        if (envVarValue) {
+          form[field.env_variable] = envVarValue;
+        }
+      });
 
-    await App.create({ id: appId, status: AppStatusEnum.STOPPED, config: form }).save();
+      await App.create({ id: appId, status: AppStatusEnum.STOPPED, config: form }).save();
+    }
   } else {
     logger.info('App already migrated');
   }
@@ -56,19 +63,25 @@ export const updateV040 = async (): Promise<void> => {
 
     // Migrate apps
     if (fileExists('/runtipi/state/apps.json')) {
-      const state: AppsState = await readJsonFile('/runtipi/state/apps.json');
-      const installed: string[] = state.installed.split(' ').filter(Boolean);
+      const state = await readJsonFile('/runtipi/state/apps.json');
+      const parsedState = appStateSchema.safeParse(state);
 
-      await Promise.all(installed.map((appId) => migrateApp(appId)));
-      deleteFolder('/runtipi/state/apps.json');
+      if (parsedState.success) {
+        const installed: string[] = parsedState.data.installed.split(' ').filter(Boolean);
+        await Promise.all(installed.map((appId) => migrateApp(appId)));
+        deleteFolder('/runtipi/state/apps.json');
+      }
     }
 
     // Migrate users
     if (fileExists('/state/users.json')) {
-      const state: { email: string; password: string }[] = await readJsonFile('/runtipi/state/users.json');
+      const state = await readJsonFile('/runtipi/state/users.json');
+      const parsedState = userStateSchema.safeParse(state);
 
-      await Promise.all(state.map((user) => migrateUser(user)));
-      deleteFolder('/runtipi/state/users.json');
+      if (parsedState.success) {
+        await Promise.all(parsedState.data.map((user) => migrateUser(user)));
+        deleteFolder('/runtipi/state/users.json');
+      }
     }
 
     await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.SUCCESS }).save();

+ 2 - 0
packages/system-api/src/helpers/helpers.ts

@@ -1,3 +1,5 @@
 const objectKeys = <T extends object>(obj: T): (keyof T)[] => Object.keys(obj) as (keyof T)[];
 
+export const notEmpty = <TValue>(value: TValue | null | undefined): value is TValue => value !== null && value !== undefined;
+
 export default { objectKeys };

+ 18 - 1
packages/system-api/src/modules/apps/__tests__/apps.factory.ts

@@ -13,6 +13,23 @@ interface IProps {
   supportedArchitectures?: AppSupportedArchitecturesEnum[];
 }
 
+type CreateConfigParams = {
+  id?: string;
+};
+
+const createAppConfig = (props?: CreateConfigParams): AppInfo => ({
+  id: props?.id || faker.random.alphaNumeric(32),
+  available: true,
+  port: faker.datatype.number(),
+  name: faker.random.alphaNumeric(32),
+  description: faker.random.alphaNumeric(32),
+  tipi_version: 1,
+  short_desc: faker.random.alphaNumeric(32),
+  author: faker.random.alphaNumeric(32),
+  source: faker.internet.url(),
+  categories: [AppCategoriesEnum.AUTOMATION],
+});
+
 const createApp = async (props: IProps) => {
   const { installed = false, status = AppStatusEnum.RUNNING, requiredPort, randomField = false, exposed = false, domain = '', exposable = false, supportedArchitectures } = props;
 
@@ -82,4 +99,4 @@ const createApp = async (props: IProps) => {
   return { appInfo, MockFiles, appEntity };
 };
 
-export { createApp };
+export { createApp, createAppConfig };

+ 6 - 11
packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts

@@ -5,8 +5,8 @@ import logger from '../../../config/logger/logger';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import App from '../app.entity';
 import { checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
-import { AppInfo } from '../apps.types';
-import { createApp } from './apps.factory';
+import { AppCategoriesEnum, AppInfo } from '../apps.types';
+import { createApp, createAppConfig } from './apps.factory';
 
 jest.mock('fs-extra');
 jest.mock('child_process');
@@ -39,7 +39,7 @@ describe('checkAppRequirements', () => {
   });
 
   it('Should throw an error if app does not exist', async () => {
-    await expect(checkAppRequirements('not-existing-app')).rejects.toThrow('App not-existing-app not found');
+    await expect(checkAppRequirements('not-existing-app')).rejects.toThrow('App not-existing-app has invalid config.json file');
   });
 });
 
@@ -163,7 +163,7 @@ describe('Test: generateEnvFile', () => {
     } catch (e: unknown) {
       if (e instanceof Error) {
         expect(e).toBeDefined();
-        expect(e.message).toBe('App not-existing-app not found');
+        expect(e.message).toBe('App not-existing-app has invalid config.json file');
       } else {
         fail('Should throw an error');
       }
@@ -257,9 +257,7 @@ describe('Test: getAppInfo', () => {
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
 
-    const newConfig = {
-      id: faker.random.alphaNumeric(32),
-    };
+    const newConfig = createAppConfig();
 
     fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
 
@@ -273,10 +271,7 @@ describe('Test: getAppInfo', () => {
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
 
-    const newConfig = {
-      id: faker.random.alphaNumeric(32),
-      available: true,
-    };
+    const newConfig = createAppConfig();
 
     fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
 

+ 1 - 1
packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts

@@ -182,7 +182,7 @@ describe('InstallApp', () => {
       variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
     });
 
-    expect(errors?.[0].message).toBe('App not-existing not found');
+    expect(errors?.[0].message).toBe('App not-existing has invalid config.json file');
     expect(data?.installApp).toBeUndefined();
   });
 

+ 76 - 35
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -1,19 +1,52 @@
 import crypto from 'crypto';
 import fs from 'fs-extra';
+import { z } from 'zod';
 import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../fs/fs.helpers';
-import { AppInfo, AppStatusEnum } from './apps.types';
+import { AppCategoriesEnum, AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum, FieldTypes } from './apps.types';
 import logger from '../../config/logger/logger';
 import { getConfig } from '../../core/config/TipiConfig';
 import { AppEntityType } from './app.types';
 
+const formFieldSchema = z.object({
+  type: z.nativeEnum(FieldTypes),
+  label: z.string(),
+  max: z.number().optional(),
+  min: z.number().optional(),
+  hint: z.string().optional(),
+  required: z.boolean().optional().default(false),
+  env_variable: z.string(),
+});
+
+export const appInfoSchema = z.object({
+  id: z.string(),
+  available: z.boolean(),
+  port: z.number().min(1).max(65535),
+  name: z.string(),
+  description: z.string().optional().default(''),
+  version: z.string().optional().default('latest'),
+  tipi_version: z.number(),
+  short_desc: z.string(),
+  author: z.string(),
+  source: z.string(),
+  website: z.string().optional(),
+  categories: z.nativeEnum(AppCategoriesEnum).array(),
+  url_suffix: z.string().optional(),
+  form_fields: z.array(formFieldSchema).optional().default([]),
+  https: z.boolean().optional().default(false),
+  exposable: z.boolean().optional().default(false),
+  no_gui: z.boolean().optional().default(false),
+  supported_architectures: z.nativeEnum(AppSupportedArchitecturesEnum).array().optional(),
+});
+
 export const checkAppRequirements = async (appName: string) => {
-  const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
+  const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(configFile);
 
-  if (!configFile) {
-    throw new Error(`App ${appName} not found`);
+  if (!parsedConfig.success) {
+    throw new Error(`App ${appName} has invalid config.json file`);
   }
 
-  if (configFile?.supported_architectures && !configFile.supported_architectures.includes(getConfig().architecture)) {
+  if (parsedConfig.data.supported_architectures && !parsedConfig.data.supported_architectures.includes(getConfig().architecture)) {
     throw new Error(`App ${appName} is not supported on this architecture`);
   }
 
@@ -34,10 +67,16 @@ export const getEnvMap = (appName: string): Map<string, string> => {
 };
 
 export const checkEnvFile = (appName: string) => {
-  const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${appName}/config.json`);
+  const configFile = readJsonFile(`/runtipi/apps/${appName}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(configFile);
+
+  if (!parsedConfig.success) {
+    throw new Error(`App ${appName} has invalid config.json file`);
+  }
+
   const envMap = getEnvMap(appName);
 
-  configFile?.form_fields?.forEach((field) => {
+  parsedConfig.data.form_fields?.forEach((field) => {
     const envVar = field.env_variable;
     const envVarValue = envMap.get(envVar);
 
@@ -54,17 +93,18 @@ const getEntropy = (name: string, length: number) => {
 };
 
 export const generateEnvFile = (app: AppEntityType) => {
-  const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
+  const configFile = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(configFile);
 
-  if (!configFile) {
-    throw new Error(`App ${app.id} not found`);
+  if (!parsedConfig.success) {
+    throw new Error(`App ${app.id} has invalid config.json file`);
   }
 
   const baseEnvFile = readFile('/runtipi/.env').toString();
-  let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
+  let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\n`;
   const envMap = getEnvMap(app.id);
 
-  configFile.form_fields?.forEach((field) => {
+  parsedConfig.data.form_fields.forEach((field) => {
     const formValue = app.config[field.env_variable];
     const envVar = field.env_variable;
 
@@ -89,7 +129,7 @@ export const generateEnvFile = (app: AppEntityType) => {
     envFile += `APP_DOMAIN=${app.domain}\n`;
     envFile += 'APP_PROTOCOL=https\n';
   } else {
-    envFile += `APP_DOMAIN=${getConfig().internalIp}:${configFile.port}\n`;
+    envFile += `APP_DOMAIN=${getConfig().internalIp}:${parsedConfig.data.port}\n`;
   }
 
   // Create app-data folder if it doesn't exist
@@ -107,9 +147,10 @@ export const getAvailableApps = async (): Promise<string[]> => {
 
   appsDir.forEach((app) => {
     if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)) {
-      const configFile = readJsonFile<AppInfo>(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
+      const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
+      const parsedConfig = appInfoSchema.safeParse(configFile);
 
-      if (configFile?.available) {
+      if (parsedConfig.success && parsedConfig.data.available) {
         apps.push(app);
       }
     }
@@ -124,23 +165,22 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
     const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
 
     if (installed && fileExists(`/runtipi/apps/${id}/config.json`)) {
-      const configFile = readJsonFile<AppInfo>(`/runtipi/apps/${id}/config.json`);
+      const configFile = readJsonFile(`/runtipi/apps/${id}/config.json`);
+      const parsedConfig = appInfoSchema.safeParse(configFile);
 
-      if (configFile) {
-        configFile.description = readFile(`/runtipi/apps/${id}/metadata/description.md`).toString();
+      if (parsedConfig.success && parsedConfig.data.available) {
+        const description = readFile(`/runtipi/apps/${id}/metadata/description.md`);
+        return { ...parsedConfig.data, description };
       }
-
-      return configFile;
     }
-    if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
-      const configFile = readJsonFile<AppInfo>(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
 
-      if (configFile) {
-        configFile.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
-      }
+    if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
+      const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+      const parsedConfig = appInfoSchema.safeParse(configFile);
 
-      if (configFile?.available) {
-        return configFile;
+      if (parsedConfig.success && parsedConfig.data.available) {
+        const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
+        return { ...parsedConfig.data, description };
       }
     }
 
@@ -158,17 +198,18 @@ export const getUpdateInfo = async (id: string, version: number) => {
     return null;
   }
 
-  const repoConfig = readJsonFile<AppInfo>(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+  const repoConfig = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(repoConfig);
 
-  if (!repoConfig?.tipi_version) {
-    return null;
+  if (parsedConfig.success) {
+    return {
+      current: version || 0,
+      latest: parsedConfig.data.tipi_version,
+      dockerVersion: parsedConfig.data.version,
+    };
   }
 
-  return {
-    current: version || 0,
-    latest: repoConfig?.tipi_version,
-    dockerVersion: repoConfig?.version,
-  };
+  return null;
 };
 
 export const ensureAppFolder = (appName: string, cleanup = false) => {

+ 43 - 19
packages/system-api/src/modules/apps/apps.service.ts

@@ -1,12 +1,13 @@
 import validator from 'validator';
 import { Not } from 'typeorm';
 import { createFolder, readFile, readJsonFile } from '../fs/fs.helpers';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder } from './apps.helpers';
+import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, appInfoSchema } from './apps.helpers';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import App from './app.entity';
 import logger from '../../config/logger/logger';
 import { getConfig } from '../../core/config/TipiConfig';
 import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
+import { notEmpty } from '../../helpers/helpers';
 
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 const filterApp = (app: AppInfo): boolean => {
@@ -115,9 +116,14 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
     // Create app folder
     createFolder(`/app/storage/app-data/${id}`);
 
-    const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
+    const appInfo = await readJsonFile(`/runtipi/apps/${id}/config.json`);
+    const parsedAppInfo = appInfoSchema.safeParse(appInfo);
 
-    if (!appInfo?.exposable && exposed) {
+    if (!parsedAppInfo.success) {
+      throw new Error(`App ${id} config.json is not valid`);
+    }
+
+    if (!parsedAppInfo.data.exposable && exposed) {
       throw new Error(`App ${id} is not exposable`);
     }
 
@@ -128,7 +134,7 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
       }
     }
 
-    app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: Number(appInfo?.tipi_version || 0), exposed: exposed || false, domain }).save();
+    app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: parsedAppInfo.data.tipi_version, exposed: exposed || false, domain }).save();
 
     // Create env file
     generateEnvFile(app);
@@ -155,12 +161,23 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
 const listApps = async (): Promise<ListAppsResonse> => {
   const folders: string[] = await getAvailableApps();
 
-  const apps: AppInfo[] = folders.map((app) => readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)).filter(Boolean);
+  const apps = folders.map((app) => readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)).filter(Boolean);
+
+  const parsedApps = apps
+    .map((app) => {
+      const result = appInfoSchema.safeParse(app);
+
+      if (!result.success) {
+        logger.error(`App ${JSON.stringify(app)} has invalid config.json`);
+        return null;
+      }
+
+      const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${result.data.id}/metadata/description.md`);
+      return { ...result.data, description };
+    })
+    .filter(notEmpty);
 
-  const filteredApps = filterApps(apps).map((app) => {
-    const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
-    return { ...app, description };
-  });
+  const filteredApps = filterApps(parsedApps);
 
   return { apps: filteredApps, total: apps.length };
 };
@@ -182,9 +199,20 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
     throw new Error(`Domain ${domain} is not valid`);
   }
 
-  const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
+  let app = await App.findOne({ where: { id } });
+
+  if (!app) {
+    throw new Error(`App ${id} not found`);
+  }
 
-  if (!appInfo?.exposable && exposed) {
+  const appInfo = await readJsonFile(`/runtipi/apps/${id}/config.json`);
+  const parsedAppInfo = appInfoSchema.safeParse(appInfo);
+
+  if (!parsedAppInfo.success) {
+    throw new Error(`App ${id} has invalid config.json`);
+  }
+
+  if (!parsedAppInfo.data.exposable && exposed) {
     throw new Error(`App ${id} is not exposable`);
   }
 
@@ -195,12 +223,6 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
     }
   }
 
-  let app = await App.findOne({ where: { id } });
-
-  if (!app) {
-    throw new Error(`App ${id} not found`);
-  }
-
   await App.update({ id }, { config: form, exposed: exposed || false, domain });
   app = (await App.findOne({ where: { id } })) as App;
 
@@ -309,8 +331,10 @@ const updateApp = async (id: string) => {
   const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]);
 
   if (success) {
-    const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
-    await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
+    const appInfo = await readJsonFile(`/runtipi/apps/${id}/config.json`);
+    const parsedAppInfo = appInfoSchema.parse(appInfo);
+
+    await App.update({ id }, { status: AppStatusEnum.RUNNING, version: parsedAppInfo.tipi_version });
   } else {
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
     throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);

+ 3 - 0
packages/system-api/src/modules/apps/apps.types.ts

@@ -139,6 +139,9 @@ class AppInfo {
   @Field(() => Boolean, { nullable: true })
   exposable?: boolean;
 
+  @Field(() => Boolean, { nullable: true })
+  no_gui?: boolean;
+
   @Field(() => [AppSupportedArchitecturesEnum], { nullable: true })
   supported_architectures?: AppSupportedArchitecturesEnum[];
 }

+ 1 - 1
packages/system-api/src/modules/fs/fs.helpers.ts

@@ -1,6 +1,6 @@
 import fs from 'fs-extra';
 
-export const readJsonFile = <T>(path: string): T | null => {
+export const readJsonFile = (path: string): unknown | null => {
   try {
     const rawFile = fs.readFileSync(path).toString();