finish setup

This commit is contained in:
Sidney Alcantara
2021-09-20 14:37:56 +10:00
parent 0f0346080c
commit caf8e9ddf4
14 changed files with 571 additions and 69 deletions

View File

@@ -19,7 +19,7 @@
"@mui/material": "^5.0.0",
"@mui/styles": "^5.0.0",
"@rowy/form-builder": "^0.1.2",
"@rowy/multiselect": "^0.1.6",
"@rowy/multiselect": "^0.1.7",
"@tinymce/tinymce-react": "^3.4.0",
"algoliasearch": "^4.8.6",
"ansi-to-react": "^6.1.5",

View File

@@ -40,7 +40,7 @@ export default function ScrollableDialogContent({
style={{
visibility: scrollInfo.y.percentage > 0 ? "visible" : "hidden",
}}
sx={{ mb: "-1px", ...dividerSx, ...topDividerSx }}
sx={{ ...dividerSx, ...topDividerSx }}
/>
)}
@@ -51,7 +51,7 @@ export default function ScrollableDialogContent({
style={{
visibility: scrollInfo.y.percentage < 1 ? "visible" : "hidden",
}}
sx={{ mt: "-1px", ...dividerSx, ...bottomDividerSx }}
sx={{ ...dividerSx, ...bottomDividerSx }}
/>
)}
</>

View File

@@ -20,6 +20,7 @@ export default function SetupItem({
alignItems="flex-start"
aria-busy={status === "loading"}
aria-describedby={status === "loading" ? "progress" : undefined}
style={{ width: "100%" }}
>
{status === "complete" ? (
<CheckIcon aria-label="Item complete" color="action" />

View File

@@ -5,7 +5,7 @@ import OpenInNewIcon from "components/InlineOpenInNewIcon";
import { useAppContext } from "contexts/AppContext";
export default function Welcome({
export default function Step0Welcome({
completion,
setCompletion,
}: ISetupStepBodyProps) {
@@ -18,7 +18,7 @@ export default function Welcome({
Get up and running in around 5 minutes.
</Typography>
<Typography variant="body1" paragraph>
Youll easily set up backend functionality, Firestore Rules, and user
Youll easily set up back-end functionality, Firestore Rules, and user
management.
</Typography>
<Typography variant="body1">

View File

@@ -160,6 +160,7 @@ export const checkServiceAccount = async (
const res = await rowyRun({
rowyRunUrl,
route: runRoutes.serviceAccountAccess,
signal,
});
return Object.values(res).reduce(
(acc, value) => acc && value,

View File

@@ -0,0 +1,240 @@
import { useState, useEffect } from "react";
import { ISetupStepBodyProps } from "pages/Setup";
import {
Typography,
FormControlLabel,
Checkbox,
Button,
Link,
TextField,
Grid,
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import CopyIcon from "assets/icons/Copy";
import InlineOpenInNewIcon from "components/InlineOpenInNewIcon";
import SetupItem from "./SetupItem";
import SignInWithGoogle from "./SignInWithGoogle";
import { name } from "@root/package.json";
import { useAppContext } from "contexts/AppContext";
import { CONFIG } from "config/dbPaths";
import { requiredRules, adminRules, utilFns } from "config/firestoreRules";
import { rowyRun } from "utils/rowyRun";
import { runRoutes } from "constants/runRoutes";
export default function Step4Rules({
rowyRunUrl,
completion,
setCompletion,
}: ISetupStepBodyProps) {
const { projectId, currentUser, getAuthToken } = useAppContext();
const [hasRules, setHasRules] = useState(completion.rules);
const [adminRule, setAdminRule] = useState(true);
const rules = `${
adminRule ? adminRules : ""
}${requiredRules}${utilFns}`.replace("\n", "");
const [currentRules, setCurrentRules] = useState("");
useEffect(() => {
if (rowyRunUrl && !hasRules && !currentRules)
getAuthToken()
.then((authToken) =>
rowyRun({
rowyRunUrl,
route: runRoutes.firestoreRules,
authToken,
})
)
.then((data) => setCurrentRules(data?.source?.[0]?.content ?? ""));
}, [rowyRunUrl, hasRules, currentRules, getAuthToken]);
const [newRules, setNewRules] = useState("");
useEffect(() => {
const inserted = currentRules.replace(
/match\s*\/databases\/\{database\}\/documents\s*\{/,
`match /databases/{database}/documents {\n` + rules
);
setNewRules(inserted);
}, [currentRules, rules]);
const [rulesStatus, setRulesStatus] = useState<"IDLE" | "LOADING" | string>(
"IDLE"
);
const setRules = async () => {
setRulesStatus("LOADING");
try {
const authToken = await getAuthToken();
if (!authToken) throw new Error("Failed to generate auth token");
const res = await rowyRun({
rowyRunUrl,
route: runRoutes.setFirestoreRules,
authToken,
body: { ruleset: newRules },
});
if (!res.success) throw new Error(res.message);
const isSuccessful = await checkRules(rowyRunUrl, authToken);
if (isSuccessful) {
setCompletion((c) => ({ ...c, rules: true }));
setHasRules(true);
}
setRulesStatus("IDLE");
} catch (e: any) {
console.error(e);
setRulesStatus(e.message);
}
};
return (
<>
<Typography variant="inherit">
{name} configuration is stored in the <code>{CONFIG}</code> collection
on Firestore. Your users will need read access to this collection and
admins will need write access.
</Typography>
<SetupItem
status={hasRules ? "complete" : "incomplete"}
title={
hasRules
? "Firestore Rules are set up."
: "Add the following rules to enable access to Rowy configuration:"
}
>
{!hasRules && (
<>
<FormControlLabel
control={
<Checkbox
checked={adminRule}
onChange={(e) => setAdminRule(e.target.checked)}
/>
}
label="Allow admins to read and write all documents"
sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }}
/>
<Typography
variant="body2"
component="pre"
sx={{
width: { sm: "100%", md: 840 - 72 - 32 },
height: 136,
resize: "both",
overflow: "auto",
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
/>
<Button
startIcon={<CopyIcon />}
onClick={() => navigator.clipboard.writeText(rules)}
>
Copy to Clipboard
</Button>
</>
)}
</SetupItem>
{!hasRules && (
<SetupItem
status="incomplete"
title={
<>
You can add these rules{" "}
<Link
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
in the Firebase Console
<InlineOpenInNewIcon />
</Link>{" "}
or directly below:
</>
}
>
<TextField
id="new-rules"
label="New Rules"
value={newRules}
onChange={(e) => setNewRules(e.target.value)}
multiline
rows={5}
fullWidth
sx={{
"& .MuiInputBase-input": {
fontFamily: "mono",
letterSpacing: 0,
resize: "vertical",
},
}}
/>
<LoadingButton
variant="contained"
color="primary"
onClick={setRules}
loading={rulesStatus === "LOADING"}
>
Set Firestore Rules
</LoadingButton>
{rulesStatus !== "LOADING" && typeof rulesStatus === "string" && (
<Typography variant="caption" color="error">
{rulesStatus}
</Typography>
)}
</SetupItem>
)}
</>
);
}
export const checkRules = async (
rowyRunUrl: string,
authToken: string,
signal?: AbortSignal
) => {
if (!authToken) return false;
try {
const res = await rowyRun({
rowyRunUrl,
route: runRoutes.firestoreRules,
authToken,
signal,
});
const rules = res?.source?.[0]?.content || "";
if (!rules) return false;
const sanitizedRules = rules.replace(/\s{2,}/g, " ").replace(/\n/g, " ");
const hasRules =
sanitizedRules.includes(
requiredRules.replace(/\s{2,}/g, " ").replace(/\n/g, " ")
) &&
sanitizedRules.includes(
utilFns.replace(/\s{2,}/g, " ").replace(/\n/g, " ")
);
return hasRules;
} catch (e: any) {
console.error(e);
return false;
}
};

View File

@@ -0,0 +1,115 @@
import { useState, useEffect } from "react";
import { ISetupStepBodyProps } from "pages/Setup";
import { Typography, Button } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SetupItem from "./SetupItem";
import SignInWithGoogle from "./SignInWithGoogle";
import { name } from "@root/package.json";
import { useAppContext } from "contexts/AppContext";
import { CONFIG } from "config/dbPaths";
import { rowyRun } from "utils/rowyRun";
import { runRoutes } from "constants/runRoutes";
export default function Step5Migrate({
rowyRunUrl,
completion,
setCompletion,
}: ISetupStepBodyProps) {
const { getAuthToken } = useAppContext();
const [status, setStatus] = useState<"LOADING" | boolean | string>(
completion.migrate
);
const migrate = async () => {
setStatus("LOADING");
try {
const authToken = await getAuthToken();
const res = await rowyRun({
route: runRoutes.migrateFT2Rowy,
rowyRunUrl,
authToken,
});
if (!res.success) throw new Error(res.message);
const check = await checkMigrate(rowyRunUrl, authToken);
if (!check.migrationRequired) {
setCompletion((c) => ({ ...c, migrate: true }));
setStatus(true);
}
} catch (e: any) {
console.error(e);
setStatus(e.message);
}
};
return (
<>
<Typography variant="inherit">
It looks like youve previously configured your Firestore database for
Firetable. You can migrate this configuration, including your XX tables
to {name}.
</Typography>
<SetupItem
status={status === true ? "complete" : "incomplete"}
title={
status === true ? (
<>
Configuration migrated to the <code>{CONFIG}</code> collection.
</>
) : (
<>
Migrate your configuration to the <code>{CONFIG}</code>{" "}
collection.
</>
)
}
>
{status !== true && (
<>
<LoadingButton
variant="contained"
color="primary"
loading={status === "LOADING"}
onClick={migrate}
>
Migrate
</LoadingButton>
{status !== "LOADING" && typeof status === "string" && (
<Typography variant="caption" color="error">
{status}
</Typography>
)}
</>
)}
</SetupItem>
</>
);
}
export const checkMigrate = async (
rowyRunUrl: string,
authToken: string,
signal?: AbortSignal
) => {
if (!authToken) return false;
try {
const res = await rowyRun({
rowyRunUrl,
route: runRoutes.checkFT2Rowy,
authToken,
signal,
});
return res.migrationRequired;
} catch (e: any) {
console.error(e);
return false;
}
};

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { useSnackbar } from "notistack";
import { Typography, Stack, RadioGroup, Radio } from "@mui/material";
import ThumbUpIcon from "@mui/icons-material/ThumbUpAlt";
import ThumbUpOffIcon from "@mui/icons-material/ThumbUpOffAlt";
import ThumbDownIcon from "@mui/icons-material/ThumbDownAlt";
import ThumbDownOffIcon from "@mui/icons-material/ThumbDownOffAlt";
import { name } from "@root/package.json";
import { analytics } from "analytics";
export default function Step6Finish() {
const { enqueueSnackbar } = useSnackbar();
const [rating, setRating] = useState<"up" | "down" | undefined>();
const handleRate = (e) => {
setRating(e.target.value);
analytics.logEvent("rate_setup", { rating: e.target.value });
enqueueSnackbar("Thanks for your feedback!");
};
return (
<>
<Typography variant="body1" gutterBottom>
You can now continue to {name} and create a table from your Firestore
collections.
</Typography>
<Stack
component="fieldset"
spacing={1}
direction="row"
alignItems="center"
justifyContent="center"
sx={{ appearance: "none", p: 0, m: 0, border: "none" }}
>
<Typography variant="body1" component="legend">
How was your setup experience?
</Typography>
<RadioGroup
style={{ flexDirection: "row" }}
value={rating}
onChange={handleRate}
>
<Radio
checkedIcon={<ThumbUpIcon />}
icon={<ThumbUpOffIcon />}
inputProps={{ "aria-label": "Thumbs up" }}
value="up"
color="secondary"
disabled={rating !== undefined}
/>
<Radio
checkedIcon={<ThumbDownIcon />}
icon={<ThumbDownOffIcon />}
inputProps={{ "aria-label": "Thumbs down" }}
value="down"
color="secondary"
disabled={rating !== undefined}
/>
</RadioGroup>
</Stack>
</>
);
}

View File

@@ -1,8 +1,10 @@
export const SETTINGS = "_rowy_/settings";
export const PUBLIC_SETTINGS = "_rowy_/publicSettings";
export const CONFIG = "_rowy_" as const;
export const TABLE_SCHEMAS = SETTINGS + "/schema";
export const TABLE_GROUP_SCHEMAS = SETTINGS + "/groupSchema";
export const SETTINGS = `${CONFIG}/settings` as const;
export const PUBLIC_SETTINGS = `${CONFIG}/publicSettings` as const;
export const USER_MANAGEMENT = "_rowy_/userManagement";
export const USERS = USER_MANAGEMENT + "/users";
export const TABLE_SCHEMAS = `${SETTINGS}/schema` as const;
export const TABLE_GROUP_SCHEMAS = `${SETTINGS}/groupSchema` as const;
export const USER_MANAGEMENT = `${CONFIG}/userManagement` as const;
export const USERS = `${USER_MANAGEMENT}/users` as const;

View File

@@ -0,0 +1,39 @@
import { CONFIG, USERS, PUBLIC_SETTINGS } from "./dbPaths";
export const requiredRules = `
// Rowy: Allow signed in users to read Rowy configuration and admins to write
match /${CONFIG}/{docId} {
allow read: if request.auth != null;
allow write: if hasAnyRole(["ADMIN", "OWNER"]);
match /{document=**} {
allow read: if request.auth != null;
allow write: if hasAnyRole(["ADMIN", "OWNER"]);
}
}
// Rowy: Allow users to edit their settings
match /${USERS}/{userId} {
allow get, update, delete: if isDocOwner(userId);
allow create: if request.auth != null;
}
// Rowy: Allow public to read public Rowy configuration
match /${PUBLIC_SETTINGS} {
allow get: if true;
}
` as const;
export const adminRules = `
// Allow admins to read and write all documents
match /{document=**} {
allow read, write: if hasAnyRole(["ADMIN", "OWNER"]);
}
` as const;
export const utilFns = `
// Rowy: Utility functions
function isDocOwner(docId) {
return request.auth != null && (request.auth.uid == resource.id || request.auth.uid == docId);
}
function hasAnyRole(roles) {
return request.auth != null && request.auth.token.roles.hasAny(roles);
}
` as const;

View File

@@ -31,18 +31,21 @@ type actionScriptRequest = {
export type runRouteRequest = actionScriptRequest | impersonateUserRequest;
export const runRoutes: Record<string, RunRoute> = {
impersonateUser: { path: "/impersonateUser", method: "GET" },
version: { path: "/version", method: "GET" },
region: { path: "/region", method: "GET" },
firestoreRules: { path: "/firestoreRules", method: "GET" },
setFirestoreRules: { path: "/setFirestoreRules", method: "POST" },
listCollections: { path: "/listCollections", method: "GET" },
serviceAccountAccess: { path: "/serviceAccountAccess", method: "GET" },
checkFT2Rowy: { path: "/checkFT2Rowy", method: "GET" },
migrateFT2Rowy: { path: "/migrateFT2Rowy", method: "GET" },
actionScript: { path: "/actionScript", method: "POST" },
buildFunction: { path: "/buildFunction", method: "POST" },
projectOwner: { path: "/projectOwner", method: "GET" },
setOwnerRoles: { path: "/setOwnerRoles", method: "GET" },
};
export const runRoutes = {
impersonateUser: { path: "/impersonateUser", method: "GET" } as RunRoute,
version: { path: "/version", method: "GET" } as RunRoute,
region: { path: "/region", method: "GET" } as RunRoute,
firestoreRules: { path: "/firestoreRules", method: "GET" } as RunRoute,
setFirestoreRules: { path: "/setFirestoreRules", method: "POST" } as RunRoute,
listCollections: { path: "/listCollections", method: "GET" } as RunRoute,
serviceAccountAccess: {
path: "/serviceAccountAccess",
method: "GET",
} as RunRoute,
checkFT2Rowy: { path: "/checkFT2Rowy", method: "GET" } as RunRoute,
migrateFT2Rowy: { path: "/migrateFT2Rowy", method: "GET" } as RunRoute,
actionScript: { path: "/actionScript", method: "POST" } as RunRoute,
buildFunction: { path: "/buildFunction", method: "POST" } as RunRoute,
projectOwner: { path: "/projectOwner", method: "GET" } as RunRoute,
setOwnerRoles: { path: "/setOwnerRoles", method: "GET" } as RunRoute,
} as const;

View File

@@ -34,6 +34,9 @@ import Step1RowyRun, { checkRowyRun } from "components/Setup/Step1RowyRun";
import Step2ServiceAccount, { checkServiceAccount } from "components/Setup/Step2ServiceAccount";
// prettier-ignore
import Step3ProjectOwner, { checkProjectOwner } from "@src/components/Setup/Step3ProjectOwner";
import Step4Rules, { checkRules } from "components/Setup/Step4Rules";
import Step5Migrate, { checkMigrate } from "components/Setup/Step5Migrate";
import Step6Finish from "components/Setup/Step6Finish";
import { name } from "@root/package.json";
import routes from "constants/routes";
@@ -60,6 +63,7 @@ const checkAllSteps = async (
rowyRunUrl: string,
currentUser: firebase.default.User | null | undefined,
userRoles: string[] | null,
authToken: string,
signal: AbortSignal
) => {
console.log("Check all steps");
@@ -69,23 +73,30 @@ const checkAllSteps = async (
if (rowyRunValidation.isValidRowyRunUrl) {
if (rowyRunValidation.isLatestVersion) completion.rowyRun = true;
const serviceAccount = await checkServiceAccount(rowyRunUrl, signal);
if (serviceAccount) completion.serviceAccount = true;
const projectOwner = await checkProjectOwner(
rowyRunUrl,
currentUser,
userRoles,
signal
);
if (projectOwner) completion.projectOwner = true;
const promises = [
checkServiceAccount(rowyRunUrl, signal).then((serviceAccount) => {
if (serviceAccount) completion.serviceAccount = true;
}),
checkProjectOwner(rowyRunUrl, currentUser, userRoles, signal).then(
(projectOwner) => {
if (projectOwner) completion.projectOwner = true;
}
),
checkRules(rowyRunUrl, authToken, signal).then((rules) => {
if (rules) completion.rules = true;
}),
checkMigrate(rowyRunUrl, authToken, signal).then((requiresMigration) => {
if (requiresMigration) completion.migrate = false;
}),
];
await Promise.all(promises);
}
return completion;
};
export default function SetupPage() {
const { currentUser, userRoles } = useAppContext();
const { currentUser, userRoles, getAuthToken } = useAppContext();
const fullScreenHeight = use100vh() ?? 0;
const isMobile = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
@@ -110,16 +121,24 @@ export default function SetupPage() {
if (rowyRunUrl) {
setCheckingAllSteps(true);
checkAllSteps(rowyRunUrl, currentUser, userRoles, signal).then(
(result) => {
if (!signal.aborted) setCompletion((c) => ({ ...c, ...result }));
setCheckingAllSteps(false);
}
getAuthToken().then((authToken) =>
checkAllSteps(
rowyRunUrl,
currentUser,
userRoles,
authToken,
signal
).then((result) => {
if (!signal.aborted) {
setCompletion((c) => ({ ...c, ...result }));
setCheckingAllSteps(false);
}
})
);
}
return () => controller.abort();
}, [rowyRunUrl, currentUser, userRoles]);
}, [rowyRunUrl, currentUser, userRoles, getAuthToken]);
const stepProps = { completion, setCompletion, checkAllSteps, rowyRunUrl };
@@ -176,14 +195,14 @@ export default function SetupPage() {
id: "rules",
shortTitle: `Rules`,
title: `Set Up Firestore Rules`,
body: `x`,
body: <Step4Rules {...stepProps} />,
},
completion.migrate !== undefined
? {
id: "migrate",
shortTitle: `Migrate`,
title: `Migrate to ${name} (optional)`,
body: `x`,
body: <Step5Migrate {...stepProps} />,
}
: ({} as ISetupStep),
{
@@ -191,21 +210,17 @@ export default function SetupPage() {
layout: "centered" as "centered",
shortTitle: `Finish`,
title: `Youre all set up!`,
body: (
<div>
You can now create a table from your Firestore collections or continue
to {name}
</div>
),
body: <Step6Finish />,
actions: (
<>
<Button variant="contained" color="primary">
Create Table
</Button>
<Button component={Link} to={routes.home} sx={{ ml: 1 }}>
Continue to {name}
</Button>
</>
<Button
variant="contained"
color="primary"
component={Link}
to={routes.home}
sx={{ ml: 1 }}
>
Continue to {name}
</Button>
),
},
].filter((x) => x.id);
@@ -239,7 +254,7 @@ export default function SetupPage() {
backdropFilter: "blur(20px) saturate(150%)",
maxWidth: 840,
width: "100%",
width: (theme) => `calc(100vw - ${theme.spacing(2)})`,
maxHeight: (theme) =>
`calc(${
fullScreenHeight > 0 ? `${fullScreenHeight}px` : "100vh"
@@ -249,7 +264,7 @@ export default function SetupPage() {
height: 840 * 0.75,
p: 0,
"& > *": { px: { xs: 2, sm: 4 } },
"& > *, & > .MuiDialogContent-root": { px: { xs: 2, sm: 4 } },
display: "flex",
flexDirection: "column",
@@ -373,7 +388,10 @@ export default function SetupPage() {
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={100}>
<ScrollableDialogContent disableTopDivider>
<ScrollableDialogContent
disableTopDivider={step.layout === "centered"}
sx={{ overflowX: "auto" }}
>
<Stack spacing={4}>{step.body}</Stack>
</ScrollableDialogContent>
</SlideTransition>

View File

@@ -42,14 +42,29 @@ export const components = (theme: Theme): ThemeOptions => {
components: {
MuiCssBaseline: {
styleOverrides: {
code: {
// https://css-tricks.com/revisiting-prefers-reduced-motion-the-reduced-motion-media-query/
"@media screen and (prefers-reduced-motion: reduce), (update: slow)":
{
"*:not(.MuiCircularProgress-root *):not(.MuiLinearProgress-root *)":
{
animationDuration: "0.001ms !important",
animationIteratonCount: "1 !important",
transitionDuration: "0.001ms !important",
scrollBehavior: "auto !important",
},
},
"code, pre, pre.MuiTypography-root": {
fontFamily: theme.typography.fontFamilyMono,
letterSpacing: 0,
backgroundColor: theme.palette.action.selected,
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
padding: `${1 / 16}em ${4 / 16}em`,
},
"pre, pre.MuiTypography-root": {
padding: `${4 / 16}em ${8 / 16}em`,
},
".chrome-picker": {
colorScheme: "light",
@@ -818,7 +833,7 @@ export const components = (theme: Theme): ThemeOptions => {
MuiStepIcon: {
styleOverrides: {
root: {
color: theme.palette.action.selected,
color: theme.palette.action.hover,
"&.Mui-completed:not(.Mui-active)": {
color: theme.palette.text.disabled,
},

View File

@@ -2672,10 +2672,10 @@
use-debounce "^3.4.3"
yup "^0.32.9"
"@rowy/multiselect@^0.1.6":
version "0.1.6"
resolved "https://registry.yarnpkg.com/@rowy/multiselect/-/multiselect-0.1.6.tgz#a1edfbc67d4f9267cb73a3d33d5a2570662b74d1"
integrity sha512-NxyskBT/8GA1ARtWv1XSBr8ltfmArhnETujbf/PgT7sRtDhv5dFB/XNSMH46rNYuN6zGmG1jHR/pIRs2fTy08w==
"@rowy/multiselect@^0.1.7":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@rowy/multiselect/-/multiselect-0.1.7.tgz#d751cf12886a560f25ba9aa98908ab1aa124b78b"
integrity sha512-sCvnWl5sP6W5N3NQI360diu+Iktxh4VmsaiHmTk9Y85BdPPjeTTKcTuKqbewZsYXDkk4gEcxOpx5XD00Ap9xhw==
"@sindresorhus/is@^0.14.0":
version "0.14.0"