diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ed689ac67..4746db020 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -4,19 +4,23 @@ module.exports = { browser: true, es6: true, }, - extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], - parser: "@typescript-eslint/parser", + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parser: '@typescript-eslint/parser', parserOptions: { - ecmaVersion: "latest", - sourceType: "module", + ecmaVersion: 'latest', + sourceType: 'module', }, - ignorePatterns: ["dist/", "node_modules/", "*.gen.ts"], - plugins: ["@typescript-eslint", "unused-imports"], + ignorePatterns: ['dist/', 'node_modules/', '*.gen.ts'], + plugins: ['@typescript-eslint', 'unused-imports', '@stylistic/ts'], rules: { - "@typescript-eslint/member-ordering": "error", - "@typescript-eslint/ban-ts-comment": "off", // "move fast" mode - "@typescript-eslint/no-explicit-any": "off", // "move fast" mode - "linebreak-style": ["error", "unix"], - "unused-imports/no-unused-imports": "error", + '@typescript-eslint/member-ordering': 'error', + '@typescript-eslint/ban-ts-comment': 'off', // "move fast" mode + '@typescript-eslint/no-explicit-any': 'off', // "move fast" mode + 'linebreak-style': ['error', 'unix'], + 'unused-imports/no-unused-imports': 'error', + // No double quotes + quotes: ['error', 'single', { avoidEscape: true }], + // No extra semicolon + '@stylistic/ts/semi': ['error', 'never'], }, -}; +} diff --git a/apps/docs/package.json b/apps/docs/package.json index ff8492cbd..ddc3588c4 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -66,6 +66,7 @@ }, "devDependencies": { "@nodelib/fs.walk": "^2.0.0", + "@stylistic/eslint-plugin-ts": "^1.6.2", "@types/mdx": "^2.0.8", "eslint-config-next": "13.4.19", "knip": "^2.25.2", diff --git a/apps/docs/src/app/getting-started/api-key/APIKey.tsx b/apps/docs/src/app/getting-started/api-key/APIKey.tsx index 04460fe7d..ee6e5bc66 100644 --- a/apps/docs/src/app/getting-started/api-key/APIKey.tsx +++ b/apps/docs/src/app/getting-started/api-key/APIKey.tsx @@ -1,12 +1,12 @@ -"use client"; +'use client' -import clsx from "clsx"; -import { useAccessToken, useApiKey, useUser } from "@/utils/useUser"; -import { usePostHog } from "posthog-js/react"; -import { useSignIn } from "@/utils/useSignIn"; -import { obfuscateSecret } from "@/utils/obfuscate"; -import { Button } from "@/components/Button"; -import { CopyButton } from "@/components/CopyButton"; +import clsx from 'clsx' +import { useAccessToken, useApiKey, useUser } from '@/utils/useUser' +import { usePostHog } from 'posthog-js/react' +import { useSignIn } from '@/utils/useSignIn' +import { obfuscateSecret } from '@/utils/obfuscate' +import { Button } from '@/components/Button' +import { CopyButton } from '@/components/CopyButton' import { Note } from '@/components/mdx' export function CopyableSecret({ @@ -30,14 +30,14 @@ export function CopyableSecret({ code={secret} onAfterCopy={onAfterCopy} customPositionClassNames={clsx( - "top-[-2px] bottom-[2px]" /* nudge 2px up*/, - "left-[-8px] right-[-8px]" /* widen a little to fit nicely */, - "min-h-[28px]" + 'top-[-2px] bottom-[2px]' /* nudge 2px up*/, + 'left-[-8px] right-[-8px]' /* widen a little to fit nicely */, + 'min-h-[28px]' )} /> - ); + ) } function SecretBlock({ name, description, secret, posthog, tip }) { @@ -48,7 +48,7 @@ function SecretBlock({ name, description, secret, posthog, tip }) {
posthog?.capture("copied API key")} + onAfterCopy={() => posthog?.capture('copied API key')} obfuscateStart={12} obfuscateEnd={5} /> @@ -57,15 +57,15 @@ function SecretBlock({ name, description, secret, posthog, tip }) { {description} {tip}
- ); + ) } function APIKey() { - const signIn = useSignIn(); - const { user } = useUser(); - const apiKey = useApiKey(); - const posthog = usePostHog(); - const accessToken = useAccessToken(); + const signIn = useSignIn() + const { user } = useUser() + const apiKey = useApiKey() + const posthog = usePostHog() + const accessToken = useAccessToken() return (
@@ -119,7 +119,7 @@ function APIKey() {
)} - ); + ) } -export default APIKey; +export default APIKey diff --git a/apps/docs/src/app/pricing/ManageBilling.tsx b/apps/docs/src/app/pricing/ManageBilling.tsx index be6c72b66..a3a75ab1f 100644 --- a/apps/docs/src/app/pricing/ManageBilling.tsx +++ b/apps/docs/src/app/pricing/ManageBilling.tsx @@ -1,27 +1,29 @@ 'use client' +import { useEffect, useState } from 'react' import { Button } from '@/components/Button' import { useUser } from '@/utils/useUser' -import { useRouter } from 'next/navigation' - - -const manageBillingURL = process.env.NEXT_PUBLIC_STRIPE_BILLING_URL function ManageBilling() { const { user } = useUser() - const router = useRouter() + const [url, setURL] = useState('') + + useEffect(function getBillingURL() { + if (!user) return + const u = `${process.env.NEXT_PUBLIC_STRIPE_BILLING_URL}?prefilled_email=${user.teams[0].email}` + setURL(u) + }, [user]) - if (!user || !manageBillingURL) { + if (!user || !url) { return } return ( -
-
-
- +
+
+ + +
) } diff --git a/apps/docs/src/app/pricing/SwitchToHobbyButton.tsx b/apps/docs/src/app/pricing/SwitchToHobbyButton.tsx new file mode 100644 index 000000000..f4864267e --- /dev/null +++ b/apps/docs/src/app/pricing/SwitchToHobbyButton.tsx @@ -0,0 +1,45 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Button } from '@/components/Button' +import { useUser } from '@/utils/useUser' + +import { TierActiveTag } from './TierActiveTag' + + +const tierID = 'base_v1' + +function SwitchToHobbyButton() { + const { user } = useUser() + const [url, setURL] = useState('') + + useEffect(function getBillingURL() { + if (!user) return + const u = `${process.env.NEXT_PUBLIC_STRIPE_BILLING_URL}?prefilled_email=${user.teams[0].email}` + setURL(u) + }, [user]) + + // Only show the button if the user is on the base_v1 tier. + // Teams can have custom tiers. We only want the button to users on the free tier. + if (!user) { + return + } + + return ( +
+
+ {user.pricingTier.id === tierID && ( + + )} + + {user.pricingTier.id !== tierID && ( + + + + )} +
+
+ ) +} + +export default SwitchToHobbyButton diff --git a/apps/docs/src/app/pricing/SwitchTearButton.tsx b/apps/docs/src/app/pricing/SwitchToProButton.tsx similarity index 70% rename from apps/docs/src/app/pricing/SwitchTearButton.tsx rename to apps/docs/src/app/pricing/SwitchToProButton.tsx index 772206335..e9832833a 100644 --- a/apps/docs/src/app/pricing/SwitchTearButton.tsx +++ b/apps/docs/src/app/pricing/SwitchToProButton.tsx @@ -5,9 +5,7 @@ import { useUser } from '@/utils/useUser' import { useState } from 'react' import { useRouter } from 'next/navigation' -export interface Props { - tierID: string -} +import { TierActiveTag } from './TierActiveTag' const billingApiURL = process.env.NEXT_PUBLIC_BILLING_API_URL function createCheckout(tierID: string, teamID: string) { @@ -23,17 +21,10 @@ function createCheckout(tierID: string, teamID: string) { }) } -function tierDisplayName(tierID: string) { - if (tierID === 'pro_v1') { - return 'Pro' - } - throw new Error(`Unknown tierID: ${tierID}`) -} +const tierID = 'pro_v1' -function SwitchTearButton({ - tierID, -}: Props) { +function SwitchTierButton() { const { user } = useUser() const [error, setError] = useState('') const router = useRouter() @@ -53,21 +44,27 @@ function SwitchTearButton({ // Only show the button if the user is on the base_v1 tier. // Teams can have custom tiers. We only want the button to users on the free tier. - if (!user || !billingApiURL || user.teams[0].tier !== 'base_v1') { + if (!user || !billingApiURL) { return } return (
-
-
- + {user.pricingTier.id === tierID && ( + + )} + + + {user.pricingTier.id !== tierID && ( + + )}
+ {error && ( {error} )} @@ -75,4 +72,4 @@ function SwitchTearButton({ ) } -export default SwitchTearButton +export default SwitchTierButton diff --git a/apps/docs/src/app/pricing/TierActiveTag.tsx b/apps/docs/src/app/pricing/TierActiveTag.tsx new file mode 100644 index 000000000..5203ce8a7 --- /dev/null +++ b/apps/docs/src/app/pricing/TierActiveTag.tsx @@ -0,0 +1,9 @@ + +export function TierActiveTag() { + return ( + + ACTIVE + + ) +} + diff --git a/apps/docs/src/app/pricing/page.mdx b/apps/docs/src/app/pricing/page.mdx index b2545c45d..3e3aed98f 100644 --- a/apps/docs/src/app/pricing/page.mdx +++ b/apps/docs/src/app/pricing/page.mdx @@ -1,5 +1,6 @@ import Promo from './Promo' -import SwitchTearButton from './SwitchTearButton' +import SwitchToHobbyButton from './SwitchToHobbyButton' +import SwitchToProButton from './SwitchToProButton' import ManageBilling from './ManageBilling' # Pricing @@ -9,7 +10,6 @@ import ManageBilling from './ManageBilling' We charge you based on your real sandbox usage. All new users get one-time free $100 worth of usage in credits. {{ className: 'lead' }} - ## Resource pricing - vCPU: $0.000014 per second (~$0.05 per hour) - GB RAM: $0.0000045 per second (~$0.018 per hour) @@ -18,7 +18,8 @@ We charge you based on your real sandbox usage. All new users get one-time free ## Plans Additionally to the usage costs, you can also select a tier that includes dedicated support, prioritized features, and customizable sandbox machine specs. -### Hobby tier (usage costs) +### Hobby tier ($0/month + usage costs) + - One-time $100 of usage in credits - Community support - 1 hours max sandbox session length @@ -28,13 +29,12 @@ Additionally to the usage costs, you can also select a tier that includes dedica - 512 MB RAM - 1 GB free disk storage -### Pro tier ($150 per month + usage costs) - - +### Pro tier ($150/month + usage costs) + - One-time $100 of usage in credits - Dedicated Slack channel with live Pro support from our team - Prioritized features -- Customize your sandbox machine specs based on your needs +- Customize your sandbox machine specs based on your needs ([let us know](/getting-help)) - 24 hours max sandbox session length - Up to 100 concurrently running sandboxes - Pro sandbox machine specs: @@ -43,4 +43,4 @@ Additionally to the usage costs, you can also select a tier that includes dedica - 5 GB free disk storage -If you need more resources, please [reach out to us](/getting-help) with your use case. +If you need any additional features or resources, please [reach out to us](/getting-help) with your use case. diff --git a/apps/docs/src/app/sandbox/overview/features.tsx b/apps/docs/src/app/sandbox/overview/features.tsx index c1ca9cf94..d90a9d7d0 100644 --- a/apps/docs/src/app/sandbox/overview/features.tsx +++ b/apps/docs/src/app/sandbox/overview/features.tsx @@ -1,13 +1,13 @@ /* eslint-disable react/jsx-key */ -import Link from 'next/link'; +import Link from 'next/link' export const features = [ Open-source sandbox for LLM , - "A full VM environment", - "No need for orchestration or infrastructure management", - "Ability to give each user of your AI app their own isolated environment", + 'A full VM environment', + 'No need for orchestration or infrastructure management', + 'Ability to give each user of your AI app their own isolated environment', Python & Node.js SDK @@ -22,7 +22,7 @@ export const features = [ , and more. , - "Support for up to 24h long-running sandbox sessions", + 'Support for up to 24h long-running sandbox sessions', Ability to upload files to the sandbox and download files from the sandbox, , diff --git a/apps/docs/src/components/Banner.tsx b/apps/docs/src/components/Banner.tsx index de6c8c867..8162ad45a 100644 --- a/apps/docs/src/components/Banner.tsx +++ b/apps/docs/src/components/Banner.tsx @@ -10,21 +10,21 @@ interface Promo { validTo: string } -const fetcher = (url: string) => fetch(url).then((res) => res.json()); +const fetcher = (url: string) => fetch(url).then((res) => res.json()) export function Banner() { const { user } = useUser() const { data, error, isLoading } = useSWR(`/docs/pricing/promo?${new URLSearchParams({ id: user?.pricingTier.id })}`, fetcher) - if (isLoading) return null; + if (isLoading) return null if (error) { console.error('Error fetching promo:', error) - return null; + return null } - if (!user?.pricingTier.isPromo) return null; + if (!user?.pricingTier.isPromo) return null return (
diff --git a/apps/docs/src/utils/useSandbox.tsx b/apps/docs/src/utils/useSandbox.tsx index 17a3468fe..f63f446e9 100644 --- a/apps/docs/src/utils/useSandbox.tsx +++ b/apps/docs/src/utils/useSandbox.tsx @@ -69,9 +69,9 @@ export const useSandboxStore = create((set, get) => ({ type SandboxMeta = { sandbox: Sandbox; promise: Promise; -}; +} export type SandboxStore = { sandbox: SandboxMeta; initSandbox: (apiKey: string) => Promise; -}; +} diff --git a/apps/docs/src/utils/useUser.tsx b/apps/docs/src/utils/useUser.tsx index 955b3d588..4417d1b69 100644 --- a/apps/docs/src/utils/useUser.tsx +++ b/apps/docs/src/utils/useUser.tsx @@ -8,10 +8,11 @@ import * as Sentry from '@sentry/nextjs' import { useSandboxStore } from './useSandbox' type Team = { - id: string, - name: string, - tier: string, + id: string + name: string + tier: string is_default: boolean + email: string } type UserContextType = { @@ -31,7 +32,7 @@ type UserContextType = { }) | null; error: Error | null; -}; +} export const UserContext = createContext(undefined) @@ -102,7 +103,7 @@ export const CustomUserContextProvider = (props) => { // @ts-ignore const { data: userTeams, teamsError } = await supabase .from('users_teams') - .select('teams (id, name, is_default, tier)') + .select('teams (id, name, is_default, tier, email)') .eq('user_id', session?.user.id) // Due to RLS, we could also safely just fetch all, but let's be explicit for sure if (teamsError) Sentry.captureException(teamsError) // TODO: Adjust when user can be part of multiple teams diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0481b866f..47043ab38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: '@nodelib/fs.walk': specifier: ^2.0.0 version: 2.0.0 + '@stylistic/eslint-plugin-ts': + specifier: ^1.6.2 + version: 1.6.2(eslint@8.49.0)(typescript@5.1.6) '@types/mdx': specifier: ^2.0.8 version: 2.0.8 @@ -2560,6 +2563,35 @@ packages: p-map: 4.0.0 dev: true + /@stylistic/eslint-plugin-js@1.6.2(eslint@8.49.0): + resolution: {integrity: sha512-ndT6X2KgWGxv8101pdMOxL8pihlYIHcOv3ICd70cgaJ9exwkPn8hJj4YQwslxoAlre1TFHnXd/G1/hYXgDrjIA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: '>=8.40.0' + dependencies: + '@types/eslint': 8.56.2 + acorn: 8.11.3 + escape-string-regexp: 4.0.0 + eslint: 8.49.0 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + dev: true + + /@stylistic/eslint-plugin-ts@1.6.2(eslint@8.49.0)(typescript@5.1.6): + resolution: {integrity: sha512-FizV58em0OjO/xFHRIy/LJJVqzxCNmYC/xVtKDf8aGDRgZpLo+lkaBKfBrbMkAGzhBKbYj+iLEFI4WEl6aVZGQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: '>=8.40.0' + dependencies: + '@stylistic/eslint-plugin-js': 1.6.2(eslint@8.49.0) + '@types/eslint': 8.56.2 + '@typescript-eslint/utils': 6.21.0(eslint@8.49.0)(typescript@5.1.6) + eslint: 8.49.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /@supabase/auth-helpers-nextjs@0.7.4(@supabase/supabase-js@2.36.0): resolution: {integrity: sha512-MyGCkB7LEDcGrKmesKGjyG54Jlbiss8VGTegrNde/ywXujQyRFnzycijoASDBc6i9SmE7eE1qWBsMr2URb07nw==} peerDependencies: @@ -2794,6 +2826,13 @@ packages: '@types/json-schema': 7.0.13 dev: false + /@types/eslint@8.56.2: + resolution: {integrity: sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==} + dependencies: + '@types/estree': 1.0.1 + '@types/json-schema': 7.0.13 + dev: true + /@types/estree-jsx@1.0.0: resolution: {integrity: sha512-3qvGd0z8F2ENTGr/GG1yViqfiKmRfrXVx5sJyHGFu3z7m5g5utCQtGp/g29JnjflhtQJBv1WDQukHiT58xPcYQ==} dependencies: @@ -2802,7 +2841,6 @@ packages: /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} - dev: false /@types/hast@2.3.6: resolution: {integrity: sha512-47rJE80oqPmFdVDCD7IheXBrVdwuBgsYwoczFvKmwfo2Mzsnt+V9OONsYauFmICb6lQPpCuXYJWejBNs4pDJRg==} @@ -3113,6 +3151,14 @@ packages: - supports-color dev: true + /@typescript-eslint/scope-manager@6.21.0: + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + dev: true + /@typescript-eslint/scope-manager@6.7.2: resolution: {integrity: sha512-bgi6plgyZjEqapr7u2mhxGR6E8WCzKNUFWNh6fkpVe9+yzRZeYtDTbsIBzKbcxI+r1qVWt6VIoMSNZ4r2A+6Yw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -3169,6 +3215,11 @@ packages: - supports-color dev: true + /@typescript-eslint/types@6.21.0: + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + dev: true + /@typescript-eslint/types@6.7.2: resolution: {integrity: sha512-flJYwMYgnUNDAN9/GAI3l8+wTmvTYdv64fcH8aoJK76Y+1FCZ08RtI5zDerM/FYT5DMkAc+19E4aLmd5KqdFyg==} engines: {node: ^16.0.0 || >=18.0.0} @@ -3179,6 +3230,28 @@ packages: engines: {node: ^16.0.0 || >=18.0.0} dev: true + /@typescript-eslint/typescript-estree@6.21.0(typescript@5.1.6): + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.5.4 + ts-api-utils: 1.0.3(typescript@5.1.6) + typescript: 5.1.6 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/typescript-estree@6.7.2(typescript@5.1.6): resolution: {integrity: sha512-kiJKVMLkoSciGyFU0TOY0fRxnp9qq1AzVOHNeN1+B9erKFCJ4Z8WdjAkKQPP+b1pWStGFqezMLltxO+308dJTQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -3242,6 +3315,25 @@ packages: - supports-color dev: true + /@typescript-eslint/utils@6.21.0(eslint@8.49.0)(typescript@5.1.6): + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.49.0) + '@types/json-schema': 7.0.13 + '@types/semver': 7.5.2 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.1.6) + eslint: 8.49.0 + semver: 7.5.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /@typescript-eslint/utils@6.7.2(eslint@8.49.0)(typescript@5.3.3): resolution: {integrity: sha512-ZCcBJug/TS6fXRTsoTkgnsvyWSiXwMNiPzBUani7hDidBdj1779qwM1FIAmpH4lvlOZNF3EScsxxuGifjpLSWQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -3280,6 +3372,14 @@ packages: - typescript dev: true + /@typescript-eslint/visitor-keys@6.21.0: + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + dev: true + /@typescript-eslint/visitor-keys@6.7.2: resolution: {integrity: sha512-uVw9VIMFBUTz8rIeaUT3fFe8xIUx8r4ywAdlQv1ifH+6acn/XF8Y6rwJ7XNmkNMDrTW+7+vxFFPIF40nJCVsMQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -3500,6 +3600,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + /acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'}