diff --git a/.dockerignore b/.dockerignore
index f05a7ed6..0571ae48 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,12 +1,17 @@
# Ignore everything.
**
-# Allow `/src` and `Dockerfile`.
+# Allow:
+!/packages/**
+!/iris/**
!/Dockerfile
-!/src/**
+!/package.json
+!/yarn.lock
+!/lerna.json
+!/tsconfig.base.json
################################################################################
-# Ignore things inside of `/src`. (esentially Node .gitignore)
+# re-ignore (esentially Node .gitignore)
################################################################################
# Logs
**/logs
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 00000000..e69de29b
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 00000000..63f1ecd7
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,78 @@
+const allExtensions = [".ts", ".tsx", ".d.ts", ".js", ".jsx"];
+
+module.exports = {
+ root: true,
+ extends: "react-app",
+ plugins: ["import"],
+ rules: {
+ "import/newline-after-import": ["warn", { count: 1 }],
+ "import/no-extraneous-dependencies": [
+ "warn",
+ {
+ devDependencies: false,
+ optionalDependencies: false,
+ peerDependencies: false,
+ bundledDependencies: true,
+ },
+ ],
+ // "import/no-relative-parent-imports": "warn",
+ "import/order": [
+ "warn",
+ {
+ alphabetize: {
+ order: "asc",
+ caseInsensitive: true,
+ },
+ "newlines-between": "always",
+ groups: [
+ "builtin",
+ "external",
+ "internal",
+ ["parent", "sibling", "index"],
+ "object",
+ ],
+ pathGroups: [
+ {
+ pattern: "react?(-dom)",
+ group: "external",
+ position: "before",
+ },
+ {
+ pattern: "@iris/**",
+ group: "external",
+ position: "after",
+ },
+ ],
+ pathGroupsExcludedImportTypes: ["builtin"],
+ },
+ ],
+ },
+ overrides: [
+ {
+ files: ["webpack.*.js"],
+ rules: {
+ "import/no-extraneous-dependencies": [
+ "warn",
+ {
+ devDependencies: true,
+ optionalDependencies: false,
+ peerDependencies: false,
+ bundledDependencies: true,
+ },
+ ],
+ },
+ },
+ ],
+ settings: {
+ "import/extensions": allExtensions,
+ "import/external-module-folders": ["node_modules", "node_modules/@types"],
+ "import/parsers": {
+ "@typescript-eslint/parser": [".ts", ".tsx", ".d.ts"],
+ },
+ "import/resolver": {
+ node: {
+ extensions: allExtensions,
+ },
+ },
+ },
+};
diff --git a/.gitignore b/.gitignore
index 0307817a..20f5a4f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,68 +1,27 @@
-local_keys
+# See https://help.github.com/ignore-files/ for more about ignoring files.
-tmp.mp4
-tmp.webm
+**/iris-app/src/bootstrap.js
-secret.yaml
-.env
+/sample-projects
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-
-# nyc test coverage
-.nyc_output
-
-# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-bower_components
-
-# node-waf configuration
-.lock-wscript
+# dependencies
+**/node_modules
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
+# testing
+**/coverage
-# Dependency directories
-node_modules/
-jspm_packages/
+# production
+**/build
+**/dist
-# TypeScript v1 declaration files
-typings/
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# next.js build output
-.next
-
-secret.yaml
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
diff --git a/.kubeconfig b/.kubeconfig
deleted file mode 100644
index 0b2e4809..00000000
--- a/.kubeconfig
+++ /dev/null
@@ -1 +0,0 @@
-export KUBECONFIG=/Users/niko/.bluemix/plugins/container-service/clusters/bpnvi8vw0nkktonrr20g/kube-config-wdc04-annotations.yml
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..3f6fff7b
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,2 @@
+dist
+coverage
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index fd496a82..00000000
--- a/.prettierrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "singleQuote": true,
- "semi": false
-}
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index b71a3a4f..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-language: node_js
-node_js: stable
-before_deploy:
-- curl -sL https://ibm.biz/idt-installer | bash
-script: echo "No tests to run."
-deploy:
-- provider: script
- skip_cleanup: true
- script: export DEPLOY_TO=production && ./deploy.sh
- on:
- branch: master
-- provider: script
- skip_cleanup: true
- script: export DEPLOY_TO=staging && ./deploy.sh
- on:
- branch: niko-dev
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7a73a41b..a4b41def 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,2 +1,17 @@
{
+ "workbench.editor.labelFormat": "short",
+ "[typescript]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": true
+ },
+ },
+ "[typescriptreact]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": true
+ },
+ }
}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index f78f0fdd..2d2d3d2b 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,24 +1,36 @@
-FROM node:10.15.0 as nodebuild
-COPY src .
-RUN npm install && cd client && npm install && npm run build
-
-FROM node:10.15.0-alpine
-COPY --from=nodebuild server.js .
-COPY --from=nodebuild package.json .
-COPY --from=nodebuild package-lock.json .
-COPY --from=nodebuild client/build client
-
-RUN npm install --only=production &&\
- apk update &&\
- apk upgrade
+############################
+# Stage 1
+############################
+FROM node:15.0.1-slim as builder
+
+# Build the UI
+COPY yarn.lock package.json lerna.json tsconfig.base.json ./
+COPY iris ./iris/
+COPY packages ./packages/
+RUN yarn install && yarn lerna bootstrap && yarn lerna run build
+
+############################
+# Stage 2
+############################
+FROM node:15.0.1-slim
+
+EXPOSE 8080
ENV PORT 8080
-ENV NODE_ENV production
+ENV SPA_ROOT=/iris/client
+
+WORKDIR /iris
+
+# Install packages
+COPY --from=builder package.json yarn.lock lerna.json ./
+# Must use the folder name `iris` otherwise lerna won't find it.
+COPY --from=builder iris/package.json ./iris/
+RUN yarn install && yarn lerna bootstrap
-ARG CLIENT_ID
-ENV CLIENT_ID $CLIENT_ID
+# Copy in source files
+COPY --from=builder packages/iris-app/build ./client/
+COPY --from=builder iris/dist ./iris/dist/
-ARG CLIENT_SECRET
-ENV CLIENT_SECRET $CLIENT_SECRET
+WORKDIR /projects
-EXPOSE 8080
-CMD node server.js
\ No newline at end of file
+ENTRYPOINT [ "node" ]
+CMD [ "/iris/iris/dist/index.js" ]
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..7f57a342
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,49 @@
+ORG=cloudannotations
+REPO=cloudannotations
+TAG=0.0.1
+
+.PHONY: docker-publish
+docker-publish:
+ # DOCKER_BUILDKIT=1
+ @docker build -t ${ORG}/${REPO}:${TAG} .
+ @docker push ${ORG}/${REPO}:${TAG}
+
+.PHONY: clean
+clean:
+ yarn lerna exec "rm -rf node_modules dist build"
+ rm -rf node_modules
+
+.PHONY: install
+install:
+ yarn install
+ yarn lerna bootstrap
+
+.PHONY: build
+build:
+ yarn lerna run build --stream --ignore @iris/app
+ yarn lerna run build --scope @iris/app --stream
+
+# .PHONY: start
+# start:
+# yarn lerna run start --scope @iris/app --stream
+
+.PHONY: watch
+watch:
+ # yarn lerna run start --stream --scope @iris/server
+ yarn lerna run build --stream --ignore @iris/app
+ # FORCE_COLOR=true yarn lerna run start --scope @iris/app --scope @iris/server --stream
+ FORCE_COLOR=true yarn lerna run start --parallel --stream
+
+
+# Add this back to the package.json to get storybook working again...
+# installs took wayyy to long so temporary dissabled (30s to 3m)
+# "nohoist": [
+# "**"
+# ]
+.PHONY: storybook
+storybook:
+ yarn lerna exec "rm -rf node_modules/html-webpack-plugin/node_modules/webpack" --scope @iris/components
+ # build the theme and hooks, storybook watches components already
+ yarn lerna run build --stream --scope @iris/theme --scope @iris/hooks
+ # exec makes progress bar work
+ yarn lerna exec "yarn storybook" --scope @iris/components
\ No newline at end of file
diff --git a/README.md b/README.md
index 5757fb7b..b6f608a2 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,68 @@
-[](https://travis-ci.org/cloud-annotations/cloud-annotations-client)
+# Iris
-
Cloud Annotations
+## Manual Installation
-
-
-
+1. Clone this repo somewhere. Here we'll use `$HOME/.iris`.
-
+```sh
+mkdir -p "$HOME/.iris"
+git clone https://github.com/cloud-annotations/iris.git "$HOME/.iris"
+```
-## Project setup
-`git clone` the repo and `cd` into it by running the following command:
+1. Checkout the v2 branch and install the required dependencies.
-```bash
-git clone https://github.com/bourdakos1/cloud-annotations-client.git
-cd cloud-annotations-client/src
+```sh
+cd "$HOME/.iris"
+git checkout v2
+make install
```
-Install the server dependencies:
+2. Add the Iris CLI to your `$PATH`.
+
+```sh
+PATH=$PATH:"$HOME/.iris/cli/bin"
```
-npm install
+
+## Getting started
+
+1. Create a folder somewhere to hold your Iris projects.
+
+```sh
+mkdir -p "$HOME/iris-projects"
```
-Install the client dependencies:
+2. Start Iris from your projects folder.
+
+```sh
+cd "$HOME/iris-projects"
+iris start
```
-cd client && npm install
-````
-> **Note: You’ll need to have Node 8.10.0 or later on your local development machine.** You can use [nvm](https://github.com/creationix/nvm#installation) (macOS/Linux) or [nvm-windows](https://github.com/coreybutler/nvm-windows#node-version-manager-nvm-for-windows) to easily switch Node versions between different projects.
+3. Point your browser to http://localhost:9000
+
+## Development
-## Local development
-Start the app:
+### Setup
+
+```sh
+git clone git@github.com:cloud-annotations/iris.git
+cd iris
+
+git checkout v2
+
+make install
```
-npm start
+
+### Build & Watch the Frontend
+
+```sh
+make watch
```
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+### Run the backend
+
+Open a directory with Iris project folders
-## Issues
-Please file any issues on the [training repo](https://github.com/cloud-annotations/training/issues)
+```sh
+node /iris/dist/index.js
+```
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 00000000..da6c4a38
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ presets: [
+ "react-app",
+ [
+ "@babel/preset-typescript",
+ {
+ allowNamespaces: true,
+ },
+ ],
+ ],
+};
diff --git a/cert-manager-prod/certificate.yaml b/cert-manager-prod/certificate.yaml
deleted file mode 100644
index 97776d52..00000000
--- a/cert-manager-prod/certificate.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-apiVersion: cert-manager.io/v1alpha2
-kind: Certificate
-metadata:
- name: cloud-annotations-ai
-spec:
- secretName: certbot-annotations-secret
- issuerRef:
- name: letsencrypt
- commonName: cloud.annotations.ai
- dnsNames:
- - cloud.annotations.ai
\ No newline at end of file
diff --git a/cert-manager-prod/issuer.yaml b/cert-manager-prod/issuer.yaml
deleted file mode 100644
index 03b76be5..00000000
--- a/cert-manager-prod/issuer.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-apiVersion: cert-manager.io/v1alpha2
-kind: Issuer
-metadata:
- name: letsencrypt
-spec:
- acme:
- # You must replace this email address with your own.
- # Let's Encrypt will use this to contact you about expiring
- # certificates, and issues related to your account.
- email: bourdakos1@gmail.com
- server: https://acme-v02.api.letsencrypt.org/directory
- privateKeySecretRef:
- # Secret resource that will be used to store the account's private key.
- name: issuer-account-key
- # Add a single challenge solver, HTTP01 using nginx
- solvers:
- - http01:
- ingress:
- class: nginx
\ No newline at end of file
diff --git a/cert-manager-stage/certificate.yaml b/cert-manager-stage/certificate.yaml
deleted file mode 100644
index 98a57d5a..00000000
--- a/cert-manager-stage/certificate.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-apiVersion: cert-manager.io/v1alpha2
-kind: Certificate
-metadata:
- name: test-cloud-annotations-ai
-spec:
- secretName: stage-annotations-secret
- issuerRef:
- name: letsencrypt
- commonName: test.cloud.annotations.ai
- dnsNames:
- - test.cloud.annotations.ai
\ No newline at end of file
diff --git a/cert-manager-stage/issuer.yaml b/cert-manager-stage/issuer.yaml
deleted file mode 100644
index c5c24822..00000000
--- a/cert-manager-stage/issuer.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-apiVersion: cert-manager.io/v1alpha2
-kind: Issuer
-metadata:
- name: letsencrypt
-spec:
- acme:
- # You must replace this email address with your own.
- # Let's Encrypt will use this to contact you about expiring
- # certificates, and issues related to your account.
- email: bourdakos1@gmail.com
- server: https://acme-v02.api.letsencrypt.org/directory
- privateKeySecretRef:
- # Secret resource that will be used to store the account's private key.
- name: stage-issuer-account-key
- # Add a single challenge solver, HTTP01 using nginx
- solvers:
- - http01:
- ingress:
- class: nginx
\ No newline at end of file
diff --git a/cli/bin/iris b/cli/bin/iris
new file mode 100755
index 00000000..9757fca3
--- /dev/null
+++ b/cli/bin/iris
@@ -0,0 +1,2 @@
+#!/usr/bin/env node
+require("../src/index");
\ No newline at end of file
diff --git a/cli/package.json b/cli/package.json
new file mode 100644
index 00000000..e822073c
--- /dev/null
+++ b/cli/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@iris/cli",
+ "version": "0.0.12",
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "commander": "^7.1.0"
+ }
+}
diff --git a/cli/src/cli.js b/cli/src/cli.js
new file mode 100644
index 00000000..3489d59e
--- /dev/null
+++ b/cli/src/cli.js
@@ -0,0 +1,20 @@
+const { Command } = require("commander");
+
+const build = require("./commands/build");
+const server = require("./commands/server");
+const start = require("./commands/start");
+const ui = require("./commands/ui");
+
+function cli() {
+ const program = new Command();
+ program.version("0.0.1");
+
+ program.addCommand(start);
+ program.addCommand(build);
+ program.addCommand(ui);
+ program.addCommand(server);
+
+ program.parse(process.argv);
+}
+
+module.exports = cli;
diff --git a/cli/src/commands/build.js b/cli/src/commands/build.js
new file mode 100644
index 00000000..708a40e8
--- /dev/null
+++ b/cli/src/commands/build.js
@@ -0,0 +1,10 @@
+const { Command } = require("commander");
+
+const command = new Command()
+ .command("build")
+ .description("TODO: description")
+ .action(() => {
+ console.log("...");
+ });
+
+module.exports = command;
diff --git a/cli/src/commands/server.js b/cli/src/commands/server.js
new file mode 100644
index 00000000..1aa0ebb9
--- /dev/null
+++ b/cli/src/commands/server.js
@@ -0,0 +1,10 @@
+const { Command } = require("commander");
+
+const command = new Command()
+ .command("server")
+ .description("TODO: description")
+ .action(() => {
+ console.log("...");
+ });
+
+module.exports = command;
diff --git a/cli/src/commands/start.js b/cli/src/commands/start.js
new file mode 100644
index 00000000..c1cba756
--- /dev/null
+++ b/cli/src/commands/start.js
@@ -0,0 +1,82 @@
+const spawn = require("child_process").spawn;
+const fs = require("fs");
+const path = require("path");
+
+const { Command } = require("commander");
+
+const command = new Command()
+ .command("start")
+ .option("-w, --watch")
+ .option("--rebuild")
+ .description("TODO: description")
+ .action((opts) => {
+ if (opts.watch === true) {
+ watch(opts);
+ return;
+ }
+ start(opts);
+ });
+
+async function start(opts) {
+ console.log("starting...");
+ const resolvedRoot = path.resolve(__dirname, "../../..");
+
+ const irisPath = path.resolve(resolvedRoot, "iris/dist/index.js");
+ const spaRoot = path.resolve(resolvedRoot, "packages/iris-app/build");
+
+ // some of the build artifacts are missing, so rebuild the project
+ if (opts.rebuild || !fs.existsSync(irisPath) || !fs.existsSync(spaRoot)) {
+ console.log("Launching for the first time, building...");
+ const build = spawn("make", ["install", "build"], {
+ cwd: resolvedRoot,
+ stdio: "inherit",
+ });
+
+ await new Promise((resolve, _reject) => {
+ build.on("close", resolve);
+ });
+ }
+
+ spawn("node", [irisPath], {
+ env: {
+ ...process.env,
+ SPA_ROOT: spaRoot,
+ },
+ stdio: "inherit",
+ });
+}
+
+async function watch(_opts) {
+ console.log("watching...");
+
+ const resolvedRoot = path.resolve(__dirname, "../../..");
+
+ const irisPath = path.resolve(resolvedRoot, "iris/dist/index.js");
+
+ const build = spawn("make", ["install", "build"], {
+ cwd: resolvedRoot,
+ stdio: "inherit",
+ });
+
+ await new Promise((resolve, _reject) => {
+ build.on("close", resolve);
+ });
+
+ spawn("node", [irisPath], {
+ env: {
+ ...process.env,
+ },
+ stdio: "inherit",
+ });
+
+ const watch = spawn("make", ["watch"], {
+ cwd: resolvedRoot,
+ stdio: "inherit",
+ });
+
+ await new Promise((resolve, _reject) => {
+ watch.on("close", resolve);
+ });
+}
+
+module.exports = command;
diff --git a/cli/src/commands/ui.js b/cli/src/commands/ui.js
new file mode 100644
index 00000000..a9d9cd8f
--- /dev/null
+++ b/cli/src/commands/ui.js
@@ -0,0 +1,10 @@
+const { Command } = require("commander");
+
+const command = new Command()
+ .command("ui")
+ .description("TODO: description")
+ .action(() => {
+ console.log("...");
+ });
+
+module.exports = command;
diff --git a/cli/src/index.js b/cli/src/index.js
new file mode 100755
index 00000000..3d2701cb
--- /dev/null
+++ b/cli/src/index.js
@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+
+const currentNodeVersion = process.versions.node;
+const semver = currentNodeVersion.split(".");
+const major = semver[0];
+
+if (major < 10) {
+ console.error(
+ "You are running Node " +
+ currentNodeVersion +
+ ".\n" +
+ "Iris requires Node 10 or higher. \n" +
+ "Please update your version of Node."
+ );
+ process.exit(1);
+}
+
+const cli = require("./cli");
+
+cli();
diff --git a/cli/yarn.lock b/cli/yarn.lock
new file mode 100644
index 00000000..0cf10b7e
--- /dev/null
+++ b/cli/yarn.lock
@@ -0,0 +1,8 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+commander@^7.1.0:
+ version "7.1.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff"
+ integrity sha512-pRxBna3MJe6HKnBGsDyMv8ETbptw3axEdYHoqNh7gu5oDcew8fs0xnivZGm06Ogk8zGAJ9VX+OPEr2GXEQK4dg==
diff --git a/cloud-annotations.png b/cloud-annotations.png
deleted file mode 100644
index 5b3a0280..00000000
Binary files a/cloud-annotations.png and /dev/null differ
diff --git a/deploy.sh b/deploy.sh
deleted file mode 100755
index 4715802d..00000000
--- a/deploy.sh
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/bin/bash
-
-trap 'echo "The deployment was aborted. Message -- "; exit 1' ERR
-
-CLUSTER="bso3qn4w0imtmamdicu0"
-IMAGE_NAME="us.icr.io/cloud-annotations/frontend:$(git rev-parse HEAD)"
-
-# Log in
-echo "Logging in..."
-ibmcloud config --check-version=false
-ibmcloud login -a cloud.ibm.com -r us-east -g prod
-
-# Download cluster config
-echo Downloading config for $CLUSTER ...
-ibmcloud ks cluster config --cluster $CLUSTER
-
-# Build image
-echo Building $IMAGE_NAME ...
-ibmcloud cr build --no-cache --pull --build-arg CLIENT_ID=$CLIENT_ID --build-arg CLIENT_SECRET=$CLIENT_SECRET -t $IMAGE_NAME .
-
-# Apply kubernetes yamls
-echo Container build completed, updating $DEPLOYMENT ...
-sed -i "s,\(^.*image: \)\(.*$\),\1"$IMAGE_NAME"," k8s-base/frontend.yaml
-
-if [ "$DEPLOY_TO" = "production" ]
-then
- # PRODUCTION:
- kubectl apply -f k8s-base
- kubectl apply -f k8s-prod
-else
- # STAGING:
- kubectl apply -f k8s-base -n stage
- kubectl apply -f k8s-stage -n stage
-fi
-
-echo "Deployment complete"
diff --git a/generate-secret.sh b/generate-secret.sh
deleted file mode 100755
index a5241dcb..00000000
--- a/generate-secret.sh
+++ /dev/null
@@ -1,27 +0,0 @@
-# sudo certbot certonly --manual --preferred-challenges=dns
-# *.annotations.ai, annotations.ai
-# nslookup -type=TXT _acme-challenge.annotations.ai
-
-# run this script, then:
-# ./generate-secret.sh
-
-# ibmcloud api https://cloud.ibm.com
-# ibmcloud login -sso
-# ibmcloud ks cluster config annotations
-# export KUBECONFIG="/Users/niko/.bluemix/plugins/container-service/clusters/annotations/kube-config-wdc04-annotations.yml"
-# kl apply -f secret.yaml
-
-cert=$(sudo openssl base64 -in /etc/letsencrypt/live/annotations.ai/fullchain.pem)
-key=$(sudo openssl base64 -in /etc/letsencrypt/live/annotations.ai/privkey.pem)
-
-stripped_cert=$(tr -d '\n' <<< "$cert")
-stripped_key=$(tr -d '\n' <<< "$key")
-
-echo "apiVersion: v1
-kind: Secret
-metadata:
- name: annotations-secret
-type: Opaque
-data:
- tls.crt: ""$stripped_cert""
- tls.key: ""$stripped_key""" > k8s-prod/secret.yaml
diff --git a/iris/package.json b/iris/package.json
new file mode 100644
index 00000000..60119188
--- /dev/null
+++ b/iris/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@iris/server",
+ "private": true,
+ "version": "0.0.1",
+ "bin": {
+ "iris": "./bin/index.js"
+ },
+ "scripts": {
+ "start": "tsc -w",
+ "build": "tsc"
+ },
+ "dependencies": {
+ "@types/busboy": "^0.2.3",
+ "@types/compression": "^1.7.0",
+ "@types/express": "^4.17.8",
+ "@types/fs-extra": "^9.0.4",
+ "@types/node": "^14.14.2",
+ "@types/pino-http": "^5.0.5",
+ "@types/proper-lockfile": "^4.1.1",
+ "@types/redis": "^2.8.28",
+ "@types/uuid": "^8.3.0",
+ "busboy": "^0.3.1",
+ "clean-webpack-plugin": "^3.0.0",
+ "compression": "^1.7.4",
+ "express": "^4.17.1",
+ "fs-extra": "^9.0.1",
+ "helmet": "^4.1.1",
+ "ibm-cos-sdk": "^1.8.0",
+ "pino-colada": "^2.1.0",
+ "pino-http": "^5.3.0",
+ "proper-lockfile": "^4.1.1",
+ "redis": "^3.0.2",
+ "socket.io": "^3.1.2",
+ "socket.io-redis": "^6.0.1",
+ "start-server-webpack-plugin": "^2.2.5",
+ "ts-loader": "^8.0.6",
+ "tsconfig-paths-webpack-plugin": "^3.3.0",
+ "typescript": "^4.0.3",
+ "webpack": "^5.2.0",
+ "webpack-cli": "^4.1.0",
+ "webpack-merge": "^5.2.0",
+ "webpack-node-externals": "^2.5.2"
+ }
+}
diff --git a/iris/src/handlers/error.ts b/iris/src/handlers/error.ts
new file mode 100644
index 00000000..fd5c575a
--- /dev/null
+++ b/iris/src/handlers/error.ts
@@ -0,0 +1,20 @@
+import { NextFunction, Request, Response } from "express";
+
+function errorHandler(
+ err: Error,
+ _req: Request,
+ res: Response,
+ next: NextFunction
+) {
+ if (res.headersSent) {
+ return next(err);
+ }
+ // Don't leak internal errors in production.
+ if (process.env.NODE_ENV === "production") {
+ return res.sendStatus(500);
+ }
+
+ return res.status(500).json({ error: err.message });
+}
+
+export default errorHandler;
diff --git a/iris/src/handlers/not-found.ts b/iris/src/handlers/not-found.ts
new file mode 100644
index 00000000..077eb9b5
--- /dev/null
+++ b/iris/src/handlers/not-found.ts
@@ -0,0 +1,7 @@
+import { Request, Response } from "express";
+
+function notFoundHandler(_req: Request, res: Response) {
+ res.sendStatus(404);
+}
+
+export default notFoundHandler;
diff --git a/iris/src/index.ts b/iris/src/index.ts
new file mode 100644
index 00000000..79ee2e41
--- /dev/null
+++ b/iris/src/index.ts
@@ -0,0 +1,36 @@
+import http from "http";
+
+import express from "express";
+
+import errorHandler from "./handlers/error";
+import notFoundHandler from "./handlers/not-found";
+import gzip from "./middleware/gzip";
+import logger from "./middleware/logger";
+import security from "./middleware/security";
+import multiuser from "./multiuser";
+import spaRouter from "./routes/spa";
+import v2Router from "./routes/v2";
+
+const app = express();
+const server = http.createServer(app);
+const port = process.env.PORT || 9000;
+
+app.enable("trust proxy");
+app.disable("x-powered-by");
+
+multiuser(server);
+
+app.use(gzip());
+app.use(security());
+app.use(logger());
+app.use(express.json());
+
+app.use("/api/v2", v2Router);
+app.use("/", spaRouter);
+
+app.use(notFoundHandler);
+app.use(errorHandler);
+
+server.listen(port, () => {
+ console.log("listening on port " + port);
+});
diff --git a/iris/src/middleware/gzip.ts b/iris/src/middleware/gzip.ts
new file mode 100644
index 00000000..932b6f44
--- /dev/null
+++ b/iris/src/middleware/gzip.ts
@@ -0,0 +1,17 @@
+import compression from "compression";
+
+function gzip() {
+ return compression({
+ filter: (req, res) => {
+ // don't compress responses asking explicitly not
+ if (req.headers["x-no-compression"]) {
+ return false;
+ }
+
+ // use compression filter function
+ return compression.filter(req, res);
+ },
+ });
+}
+
+export default gzip;
diff --git a/iris/src/middleware/logger.ts b/iris/src/middleware/logger.ts
new file mode 100644
index 00000000..e9ca6f89
--- /dev/null
+++ b/iris/src/middleware/logger.ts
@@ -0,0 +1,12 @@
+import pinoColada from "pino-colada";
+import pino from "pino-http";
+
+function logger() {
+ return pino({
+ level: process.env.LOG_LEVEL || "info",
+ prettyPrint: process.env.NODE_ENV !== "production",
+ prettifier: pinoColada,
+ });
+}
+
+export default logger;
diff --git a/iris/src/middleware/pino-colada.d.ts b/iris/src/middleware/pino-colada.d.ts
new file mode 100644
index 00000000..1f46dd26
--- /dev/null
+++ b/iris/src/middleware/pino-colada.d.ts
@@ -0,0 +1 @@
+declare module "pino-colada";
diff --git a/iris/src/middleware/security.ts b/iris/src/middleware/security.ts
new file mode 100644
index 00000000..d9cd018d
--- /dev/null
+++ b/iris/src/middleware/security.ts
@@ -0,0 +1,7 @@
+import helmet from "helmet";
+
+function security() {
+ return helmet({ contentSecurityPolicy: false });
+}
+
+export default security;
diff --git a/iris/src/multiuser.ts b/iris/src/multiuser.ts
new file mode 100644
index 00000000..93967ac5
--- /dev/null
+++ b/iris/src/multiuser.ts
@@ -0,0 +1,79 @@
+import http from "http";
+
+import { RedisClient } from "redis";
+import { Server, Socket } from "socket.io";
+import { createAdapter } from "socket.io-redis";
+
+const host = process.env.REDIS_HOST ?? "redis";
+const port = parseInt(process.env.REDIS_PORT ?? "6379", 10);
+const password = process.env.REDIS_PASSWORD;
+
+function multiuser(server: http.Server) {
+ const io = new Server(server);
+
+ const pubClient = new RedisClient({
+ host,
+ port,
+ password,
+ });
+
+ pubClient.on("ready", () => {
+ console.log("pub client ready");
+ const subClient = pubClient.duplicate();
+ subClient.on("ready", () => {
+ console.log("sub client ready");
+ console.log("creating addapter");
+ io.adapter(createAdapter({ pubClient, subClient }));
+ });
+ subClient.on("error", () => {
+ // do nothing
+ });
+ });
+ pubClient.on("error", () => {
+ // do nothing
+ });
+
+ function broadcastHeadcount(room: string) {
+ if (room.startsWith("image:")) {
+ const count = io.of("/").adapter.rooms.get(room)?.size;
+ io.to(room).emit("headcount", count);
+ }
+ }
+
+ io.of("/").adapter.on("join-room", (room, _id) => {
+ broadcastHeadcount(room);
+ });
+
+ io.of("/").adapter.on("leave-room", (room, _id) => {
+ broadcastHeadcount(room);
+ });
+
+ io.on("connection", (socket: Socket) => {
+ socket.on("patch", (res: any) => {
+ const projectRoom = [...socket.rooms].find((r) =>
+ r.startsWith("project:")
+ );
+ if (projectRoom !== undefined) {
+ socket.to(projectRoom).emit("patch", res);
+ }
+ });
+
+ socket.on("join", async (res: any) => {
+ const projectRoom = `project:${res.project}`;
+ const imageRoom = `image:${res.project}:${res.image}`;
+
+ // TODO: we should check that you are allowed to join the room.
+
+ for (const room of socket.rooms) {
+ if (room !== imageRoom || room !== projectRoom) {
+ socket.leave(room);
+ }
+ }
+
+ socket.join(projectRoom);
+ socket.join(imageRoom);
+ });
+ });
+}
+
+export default multiuser;
diff --git a/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts b/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts
new file mode 100644
index 00000000..a1ec8210
--- /dev/null
+++ b/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts
@@ -0,0 +1,64 @@
+import COS from "ibm-cos-sdk";
+
+interface IOptions {
+ name: string;
+ projectID?: string;
+}
+
+const connections = [
+ {
+ id: "718b4cc1-1c5d-400b-b056-08244db75225",
+ providerID: "cos",
+ name: "bee-travels",
+ credentials: {
+ apikey: "Fczphmlve6ANRIrTKNgiEHVex29FFCdw2lv8g67XWqhO",
+ resource_instance_id:
+ "crn:v1:bluemix:public:cloud-object-storage:global:a/9b13b857a32341b7167255de717172f5:718b4cc1-1c5d-400b-b056-08244db75225::",
+ },
+ },
+ {
+ id: "48b970b2-c8c8-4b4e-bb0c-85b6338cd295",
+ providerID: "cos",
+ name: "Cloud Object Storage-nick",
+ credentials: {
+ apikey: "aSz22rBc1hZEnw-EwtsOyFvXz1fmWAl6iAJtr9X4JMF0",
+ resource_instance_id:
+ "crn:v1:bluemix:public:cloud-object-storage:global:a/9b13b857a32341b7167255de717172f5:48b970b2-c8c8-4b4e-bb0c-85b6338cd295::",
+ },
+ },
+];
+
+class COSProvider {
+ async getConnections() {
+ return Promise.resolve(connections);
+ }
+
+ async getProjects({ connectionID }: { connectionID: string }) {
+ const connection = connections.find((c) => c.id === connectionID);
+ const config = {
+ endpoint: "https://s3.us.cloud-object-storage.appdomain.cloud",
+ apiKeyId: connection?.credentials.apikey,
+ ibmAuthEndpoint: "https://iam.cloud.ibm.com/identity/token",
+ serviceInstanceId: connection?.credentials.resource_instance_id,
+ };
+
+ const cosClient = new COS.S3(config);
+ const list = await cosClient.listBucketsExtended().promise();
+ return list.Buckets?.map((b) => ({
+ name: b.Name,
+ modified: b.CreationDate, // TODO: this should be created
+ }));
+ }
+
+ async getProject(options: Pick) {}
+
+ async persist(annotations: any, options: Pick) {}
+
+ async getImage(imageID: string, options: Pick) {}
+
+ async deleteImage(imageID: string, options: Pick) {}
+
+ async saveImage(file: NodeJS.ReadableStream, options: IOptions) {}
+}
+
+export default COSProvider;
diff --git a/iris/src/plugins/iris-server-plugin-cos/index.ts b/iris/src/plugins/iris-server-plugin-cos/index.ts
new file mode 100644
index 00000000..5e66e046
--- /dev/null
+++ b/iris/src/plugins/iris-server-plugin-cos/index.ts
@@ -0,0 +1,8 @@
+import COSProvider from "./COSProvider";
+
+export function activate(iris: any) {
+ iris.providers.register({
+ id: "cos",
+ provider: new COSProvider(),
+ });
+}
diff --git a/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts b/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts
new file mode 100644
index 00000000..2aa0b47f
--- /dev/null
+++ b/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts
@@ -0,0 +1,218 @@
+import path from "path";
+
+import fs from "fs-extra";
+import lockfile from "proper-lockfile";
+
+interface Label {
+ id: string;
+ name: string;
+}
+
+interface Image {
+ id: string;
+ date: string;
+ annotations: string[];
+}
+
+interface Annotation {
+ id: string;
+ label: string;
+ tool?: string;
+ targets?: {
+ id: string;
+ x: number;
+ y: number;
+ }[];
+ [key: string]: any; // plugins can specify extra keys.
+}
+
+interface IProject {
+ id?: string;
+ name: string;
+ created: Date;
+ version: string;
+ labels: { [key: string]: Label };
+ annotations: { [key: string]: Annotation };
+ images: { [key: string]: Image };
+}
+
+interface IOptions {
+ name: string;
+ projectID?: string;
+}
+
+const ignoreRegex = /^lost\+found$/;
+
+class FileSystemProvider {
+ private _dir(projectID: string | undefined) {
+ if (projectID) {
+ return path.join(process.cwd(), projectID);
+ }
+ return process.cwd();
+ }
+
+ async getConnections() {
+ return Promise.resolve([
+ {
+ id: "default",
+ providerID: "file-system",
+ name: "File System",
+ icon: ``,
+ },
+ ]);
+ }
+
+ async createProject({ name }: any) {
+ await fs.ensureDir(this._dir(name));
+ }
+
+ async getProjects() {
+ const x = await fs.readdir(process.cwd(), { withFileTypes: true });
+
+ return await Promise.all(
+ x
+ .filter((dir) => dir.isDirectory() && !ignoreRegex.test(dir.name))
+ .map(async (dir) => {
+ const stats: { created?: Date; modified?: Date; opened?: Date } = {
+ created: undefined,
+ modified: undefined,
+ opened: undefined,
+ };
+
+ const files = await fs.readdir(dir.name);
+
+ const images = files.filter(
+ (f) =>
+ f.toLowerCase().endsWith(".jpg") ||
+ f.toLowerCase().endsWith(".jpeg")
+ );
+
+ let labels: string[] = [];
+ try {
+ const a = await fs.readFile(
+ path.join(dir.name, "_annotations.json"),
+ "utf-8"
+ );
+ labels = JSON.parse(a).labels;
+ } catch {}
+
+ try {
+ const s = await fs.stat(path.join(dir.name, "_annotations.json"));
+ stats.created = s.birthtime;
+ stats.modified = s.mtime;
+ stats.opened = s.atime;
+ } catch {}
+
+ return {
+ id: dir.name,
+ name: dir.name,
+ created: stats.created,
+ modified: stats.modified,
+ opened: stats.opened,
+ labels: labels,
+ images: images.length,
+ };
+ })
+ );
+ }
+
+ async getProject(options: Pick) {
+ const { projectID } = options;
+
+ const project: IProject = {
+ id: projectID,
+ name: projectID ?? path.basename(process.cwd()),
+ created: new Date(),
+ version: "v2",
+ labels: {},
+ annotations: {},
+ images: {},
+ };
+
+ try {
+ const annotationsString = await fs.readFile(
+ path.join(this._dir(projectID), "_annotations.json"),
+ "utf-8"
+ );
+ // TODO: check version
+ const { labels, annotations, images } = JSON.parse(annotationsString);
+ project.labels = labels;
+ project.annotations = annotations;
+ project.images = images;
+ } catch {
+ // we don't care if there's no annotations file.
+ }
+
+ const files = await fs.readdir(this._dir(projectID));
+
+ for (const f of files) {
+ if (
+ f.toLowerCase().endsWith(".jpg") ||
+ f.toLowerCase().endsWith(".jpeg")
+ ) {
+ // Only add image if it's not already there.
+ // TODO: We should update the date of the image maybe?
+ if (project.images[f] === undefined) {
+ project.images[f] = { id: f, date: "", annotations: [] };
+ }
+ }
+ }
+
+ return project;
+ }
+
+ async persist(annotations: any, options: Pick) {
+ const { projectID } = options;
+
+ const output = path.join(this._dir(projectID), "_annotations.json");
+
+ // TODO: This probably isn't safe:
+ // "It is unsafe to call fsPromises.writeFile() multiple times on the same
+ // file without waiting for the Promise to be resolved (or rejected)."
+ await fs.writeFile(output, JSON.stringify(annotations), "utf-8");
+ }
+
+ async getImage(imageID: string, options: Pick) {
+ const { projectID } = options;
+
+ const output = path.join(this._dir(projectID), imageID);
+
+ const isLocked = await lockfile.check(output);
+ if (!isLocked) {
+ return fs.createReadStream(output);
+ }
+
+ throw new Error("file is locked");
+ }
+
+ async deleteImage(imageID: string, options: Pick) {
+ const { projectID } = options;
+ const output = path.join(this._dir(projectID), imageID);
+ await fs.unlink(output);
+ }
+
+ async saveImage(file: NodeJS.ReadableStream, options: IOptions) {
+ const { projectID, name } = options;
+ const output = path.join(this._dir(projectID), name);
+
+ await fs.ensureFile(output);
+ const release = await lockfile.lock(output);
+ const writeStream = fs.createWriteStream(output);
+ file.pipe(writeStream);
+ return new Promise((resolve, reject) => {
+ writeStream.on("error", async (e) => {
+ await release();
+ reject(e);
+ });
+ writeStream.on("finish", async () => {
+ await release();
+ resolve();
+ });
+ });
+ }
+}
+
+export default FileSystemProvider;
diff --git a/iris/src/plugins/iris-server-plugin-file-system/index.ts b/iris/src/plugins/iris-server-plugin-file-system/index.ts
new file mode 100644
index 00000000..cdb2ee07
--- /dev/null
+++ b/iris/src/plugins/iris-server-plugin-file-system/index.ts
@@ -0,0 +1,8 @@
+import FileSystemProvider from "./FileSystemProvider";
+
+export function activate(iris: any) {
+ iris.providers.register({
+ id: "file-system",
+ provider: new FileSystemProvider(),
+ });
+}
diff --git a/iris/src/routes/spa.ts b/iris/src/routes/spa.ts
new file mode 100644
index 00000000..7159172a
--- /dev/null
+++ b/iris/src/routes/spa.ts
@@ -0,0 +1,16 @@
+import path from "path";
+
+import express, { Router } from "express";
+
+const root = process.env.SPA_ROOT;
+
+const router = Router();
+
+if (root) {
+ router.use(express.static(root, { maxAge: 31536000 }));
+ router.get("*", (_req, res) => {
+ res.sendFile(path.join(root, "index.html"));
+ });
+}
+
+export default router;
diff --git a/iris/src/routes/v2/api.ts b/iris/src/routes/v2/api.ts
new file mode 100644
index 00000000..22f5486d
--- /dev/null
+++ b/iris/src/routes/v2/api.ts
@@ -0,0 +1,279 @@
+import Busboy from "busboy";
+import { Request, Router } from "express";
+import fs from "fs-extra";
+
+////////////////////////////////////////////////////////////////////////////////
+interface IImage {
+ id: string;
+ date: string;
+}
+
+interface IProject {
+ id: string;
+ name: string;
+ created?: Date;
+ modified?: Date;
+ opened?: Date;
+ labels?: string[];
+ images?: number;
+}
+
+interface IProjectDetails {
+ id?: string;
+ name: string;
+ created: Date;
+ annotations: {
+ version: string;
+ labels: string[];
+ annotations: {}; // TODO
+ images: IImage[];
+ };
+}
+
+interface IOptions {
+ name: string;
+ projectID?: string;
+}
+
+interface IConnection {
+ id: string;
+ name: string;
+ icon?: string;
+}
+
+interface Provider {
+ getConnections: () => Promise;
+ getProjects: ({
+ connectionID,
+ }: {
+ connectionID: string;
+ }) => Promise;
+ createProject: ({
+ connectionID,
+ name,
+ }: {
+ connectionID: string;
+ name: string;
+ }) => any;
+ getProject: (
+ options: Pick
+ ) => Promise;
+ persist: (annotations: any, options: Pick) => any;
+ getImage: (
+ imageID: string,
+ options: Pick
+ ) => Promise;
+ deleteImage: (imageID: string, options: Pick) => any;
+ saveImage: (file: NodeJS.ReadableStream, options: IOptions) => any;
+}
+
+// TODO: pull from package.json
+let extensions = [
+ "../../plugins/iris-server-plugin-file-system",
+ // "../../plugins/iris-server-plugin-cos",
+];
+let providers: { [key: string]: Provider } = {};
+const iris = {
+ providers: {
+ register: ({ id, provider }: any) => {
+ providers[id] = provider;
+ },
+ },
+};
+for (const extension of extensions) {
+ try {
+ const provider = require(extension);
+ provider.activate(iris);
+ } catch (e) {
+ console.log(e);
+ }
+}
+////////////////////////////////////////////////////////////////////////////////
+
+const router = Router();
+
+function getProjectID(req: Request) {
+ const { projectID } = req.query;
+ if (process.env.SINGLE_DOCUMENT_MODE && projectID === undefined) {
+ return projectID;
+ }
+ if (!process.env.SINGLE_DOCUMENT_MODE && typeof projectID === "string") {
+ return projectID;
+ }
+ throw new Error(
+ `projectID "${projectID}" of type "${typeof projectID}" is not allowed in ${
+ process.env.SINGLE_DOCUMENT_MODE
+ ? "single document mode"
+ : "projects mode"
+ }`
+ );
+}
+
+function requiredQuery(req: Request, param: string) {
+ const x = req.query[param];
+ if (x === undefined) {
+ throw new Error(`${param} is undefined`);
+ }
+ if (typeof x === "string") {
+ return x;
+ }
+ throw new Error(`${param} "${x}" of type "${typeof x}" is not allowed`);
+}
+
+function ensureProjectMode() {
+ if (process.env.SINGLE_DOCUMENT_MODE) {
+ throw new Error("Projects not available in single document mode");
+ }
+}
+
+/**
+ * GET /api/mode
+ */
+router.get("/mode", async (_req, res) => {
+ res.json({ singleDocument: !!process.env.SINGLE_DOCUMENT_MODE });
+});
+
+/**
+ * GET /api/connections
+ */
+router.get("/connections", async (_req, res, next) => {
+ try {
+ ensureProjectMode();
+ let connections: IConnection[] = [];
+ for (const provider of Object.values(providers)) {
+ const c = await provider.getConnections();
+ connections.push(...c);
+ }
+ res.json(connections);
+ } catch (e) {
+ next(e);
+ }
+});
+
+/**
+ * GET /api/projects
+ */
+router.get("/projects", async (req, res, next) => {
+ try {
+ ensureProjectMode();
+ const providerID = requiredQuery(req, "providerID");
+ const connectionID = requiredQuery(req, "connectionID");
+ const projects = await providers[providerID].getProjects({ connectionID });
+ res.json(projects);
+ } catch (e) {
+ next(e);
+ }
+});
+
+/**
+ * POST /api/projects
+ */
+router.post("/projects", async (req, res, next) => {
+ try {
+ ensureProjectMode();
+ const providerID = requiredQuery(req, "providerID");
+ const connectionID = requiredQuery(req, "connectionID");
+ const name = requiredQuery(req, "name");
+ await providers[providerID].createProject({ connectionID, name });
+ res.sendStatus(200);
+ } catch (e) {
+ next(e);
+ }
+});
+
+/**
+ * GET /api/project
+ * @queryParam {string} [projectID]
+ */
+router.get("/project", async (req, res, next) => {
+ try {
+ const projectID = getProjectID(req);
+ const project = await providers["file-system"].getProject({ projectID });
+ res.json(project);
+ } catch (e) {
+ next(e);
+ }
+});
+
+/**
+ * PUT /api/project
+ * @queryParam {string} [projectID]
+ */
+router.put("/project", async (req, res, next) => {
+ try {
+ const projectID = getProjectID(req);
+ await providers["file-system"].persist(req.body, { projectID });
+ res.sendStatus(200);
+ } catch (e) {
+ next(e);
+ }
+});
+
+/**
+ * GET /api/images/:imageID
+ * @pathParam {string} imageID
+ * @queryParam {string} [projectID]
+ */
+router.get("/images/:imageID", async (req, res, next) => {
+ const { imageID } = req.params;
+
+ try {
+ const projectID = getProjectID(req);
+ const image = await providers["file-system"].getImage(imageID, {
+ projectID,
+ });
+
+ image.on("error", (e) => {
+ throw e;
+ });
+
+ res.set("Content-Type", "image/jpeg");
+ image.pipe(res);
+ } catch (e) {
+ next(e);
+ }
+});
+
+/**
+ * DELETE /api/images/:imageID
+ * @pathParam {string} imageID
+ * @queryParam {string} [projectID]
+ */
+router.delete("/images/:imageID", async (req, res, next) => {
+ const { imageID } = req.params;
+
+ try {
+ const projectID = getProjectID(req);
+ await providers["file-system"].deleteImage(imageID, { projectID });
+ res.sendStatus(200);
+ } catch (e) {
+ next(e);
+ }
+});
+
+/**
+ * POST /api/images
+ * @queryParam {string} [projectID]
+ */
+router.post("/images", async (req, res, next) => {
+ try {
+ const projectID = getProjectID(req);
+ const busboy = new Busboy({
+ headers: req.headers,
+ limits: {
+ files: 1,
+ },
+ });
+
+ busboy.on("file", async (name, file) => {
+ await providers["file-system"].saveImage(file, { name, projectID });
+ res.sendStatus(200);
+ });
+
+ req.pipe(busboy);
+ } catch (e) {
+ next(e);
+ }
+});
+
+export default router;
diff --git a/iris/src/routes/v2/auth.ts b/iris/src/routes/v2/auth.ts
new file mode 100644
index 00000000..503cf205
--- /dev/null
+++ b/iris/src/routes/v2/auth.ts
@@ -0,0 +1,9 @@
+import { Router } from "express";
+
+const router = Router();
+
+router.get("/status", (_req, res) => {
+ res.sendStatus(200);
+});
+
+export default router;
diff --git a/iris/src/routes/v2/index.ts b/iris/src/routes/v2/index.ts
new file mode 100644
index 00000000..f2f86a04
--- /dev/null
+++ b/iris/src/routes/v2/index.ts
@@ -0,0 +1,11 @@
+import { Router } from "express";
+
+import apiRouter from "./api";
+import authRouter from "./auth";
+
+const router = Router();
+
+router.use("/auth", authRouter);
+router.use("/", apiRouter);
+
+export default router;
diff --git a/iris/tsconfig.json b/iris/tsconfig.json
new file mode 100644
index 00000000..ab20b976
--- /dev/null
+++ b/iris/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "declarationMap": true,
+ "declaration": true,
+ "sourceMap": true,
+ "outDir": "dist",
+
+ "target": "es2017",
+ "lib": ["es2017", "es2019.array"],
+ "module": "commonjs"
+ },
+ "include": ["src"]
+}
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 00000000..caa26704
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,12 @@
+module.exports = {
+ testEnvironment: "jest-environment-jsdom",
+ setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"],
+ coverageThreshold: {
+ global: {
+ statements: 15,
+ branches: 10,
+ functions: 15,
+ lines: 15,
+ },
+ },
+};
diff --git a/k8s-base/frontend.yaml b/k8s-base/frontend.yaml
deleted file mode 100644
index 27f52d68..00000000
--- a/k8s-base/frontend.yaml
+++ /dev/null
@@ -1,38 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- name: frontend
- labels:
- app: annotations
- tier: frontend
-spec:
- ports:
- - port: 443
- targetPort: 8080
- protocol: TCP
- selector:
- app: annotations
- tier: frontend
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: frontend
-spec:
- selector:
- matchLabels:
- app: annotations
- tier: frontend
- replicas: 6
- template:
- metadata:
- labels:
- app: annotations
- tier: frontend
- spec:
- containers:
- - name: frontend
- image: us.icr.io/cloud-annotations/frontend:em-patch-2
- imagePullPolicy: Always
- ports:
- - containerPort: 8080
diff --git a/k8s-base/redis.yaml b/k8s-base/redis.yaml
deleted file mode 100644
index eaf52a62..00000000
--- a/k8s-base/redis.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- labels:
- run: redis
- name: redis
-spec:
- ports:
- - port: 6379
- targetPort: 6379
- selector:
- run: redis
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: redis
-spec:
- replicas: 1
- selector:
- matchLabels:
- run: redis
- template:
- metadata:
- labels:
- run: redis
-
- spec:
- containers:
- - name: redis
- image: redis
- ports:
- - containerPort: 6379
\ No newline at end of file
diff --git a/k8s-prod/ingress.yaml b/k8s-prod/ingress.yaml
deleted file mode 100644
index 19cab825..00000000
--- a/k8s-prod/ingress.yaml
+++ /dev/null
@@ -1,73 +0,0 @@
-apiVersion: networking.k8s.io/v1beta1
-kind: Ingress
-metadata:
- name: ingress
- annotations:
- ingress.bluemix.net/sticky-cookie-services: "serviceName=frontend name=sticky expires=5h path=/ hash=sha1"
- ingress.bluemix.net/client-max-body-size: "size=10m"
- # nginx.ingress.kubernetes.io/affinity: "cookie"
- # nginx.ingress.kubernetes.io/proxy-body-size: "10m"
-spec:
- tls:
- - hosts:
- - cloud.annotations.ai
- secretName: certbot-annotations-secret
- - hosts:
- - annotations2-442dbba0442be6c8c50f31ed96b00601-0000.us-east.containers.appdomain.cloud
- secretName: annotations2-442dbba0442be6c8c50f31ed96b00601-0000
- rules:
- - host: cloud.annotations.ai
- http:
- paths:
- - path: /
- backend:
- serviceName: frontend
- servicePort: 443
- - path: /docs
- backend:
- serviceName: docs
- servicePort: 80
- - path: /workshops
- backend:
- serviceName: docs
- servicePort: 80
- - path: /demos
- backend:
- serviceName: docs
- servicePort: 80
- - path: /sdks
- backend:
- serviceName: docs
- servicePort: 80
- - path: /docs-assets
- backend:
- serviceName: docs
- servicePort: 80
-
- - host: annotations2-442dbba0442be6c8c50f31ed96b00601-0000.us-east.containers.appdomain.cloud
- http:
- paths:
- - path: /
- backend:
- serviceName: frontend
- servicePort: 443
- - path: /docs
- backend:
- serviceName: docs
- servicePort: 80
- - path: /workshops
- backend:
- serviceName: docs
- servicePort: 80
- - path: /demos
- backend:
- serviceName: docs
- servicePort: 80
- - path: /sdks
- backend:
- serviceName: docs
- servicePort: 80
- - path: /docs-assets
- backend:
- serviceName: docs
- servicePort: 80
\ No newline at end of file
diff --git a/k8s-stage/ingress.yaml b/k8s-stage/ingress.yaml
deleted file mode 100644
index 20c90dd8..00000000
--- a/k8s-stage/ingress.yaml
+++ /dev/null
@@ -1,40 +0,0 @@
-apiVersion: networking.k8s.io/v1beta1
-kind: Ingress
-metadata:
- name: ingress
- annotations:
- nginx.ingress.kubernetes.io/affinity: "cookie"
- nginx.ingress.kubernetes.io/proxy-body-size: "10m"
-spec:
- tls:
- - hosts:
- - test.cloud.annotations.ai
- secretName: stage-annotations-secret
- rules:
- - host: test.cloud.annotations.ai
- http:
- paths:
- - path: /
- backend:
- serviceName: frontend
- servicePort: 443
- - path: /docs
- backend:
- serviceName: docs
- servicePort: 80
- - path: /workshops
- backend:
- serviceName: docs
- servicePort: 80
- - path: /demos
- backend:
- serviceName: docs
- servicePort: 80
- - path: /sdks
- backend:
- serviceName: docs
- servicePort: 80
- - path: /docs-assets
- backend:
- serviceName: docs
- servicePort: 80
diff --git a/lerna.json b/lerna.json
new file mode 100644
index 00000000..1809fae6
--- /dev/null
+++ b/lerna.json
@@ -0,0 +1,10 @@
+{
+ "packages": [
+ "packages/*",
+ "iris",
+ "cli"
+ ],
+ "npmClient": "yarn",
+ "useWorkspaces": true,
+ "version": "0.0.0"
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..17569e2a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "iris",
+ "private": true,
+ "workspaces": {
+ "packages": [
+ "packages/*",
+ "iris",
+ "cli"
+ ]
+ },
+ "scripts": {
+ "test": "is-ci \"test:coverage\" \"test:watch\"",
+ "test:coverage": "jest --coverage",
+ "test:watch": "jest --watch",
+ "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --watch"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.7.4",
+ "@babel/plugin-transform-runtime": "^7.7.4",
+ "@babel/preset-env": "^7.7.4",
+ "@babel/preset-react": "^7.7.4",
+ "@babel/preset-typescript": "^7.12.7",
+ "@babel/runtime": "^7.7.4",
+ "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/react": "^9.3.2",
+ "@types/jest": "^26.0.17",
+ "@typescript-eslint/eslint-plugin": "2.x",
+ "@typescript-eslint/parser": "2.x",
+ "babel-eslint": "10.x",
+ "babel-jest": "^26.6.3",
+ "babel-loader": "^8.1.0",
+ "babel-plugin-named-asset-import": "^0.3.7",
+ "babel-preset-react-app": "^10.0.0",
+ "eslint": "6.x",
+ "eslint-config-react-app": "^5.2.1",
+ "eslint-plugin-flowtype": "4.x",
+ "eslint-plugin-import": "^2.22.0",
+ "eslint-plugin-jsx-a11y": "6.x",
+ "eslint-plugin-react": "7.x",
+ "eslint-plugin-react-hooks": "2.x",
+ "husky": "^3.1.0",
+ "is-ci-cli": "^2.0.0",
+ "jest": "^24.9.0",
+ "lerna": "^3.22.1",
+ "lint-staged": "^9.4.3",
+ "typescript": "^4.0.5"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "lint-staged"
+ }
+ },
+ "lint-staged": {
+ "*.{tsx,ts,js,md,css,html}": [
+ "prettier --write",
+ "git add"
+ ]
+ }
+}
diff --git a/packages/iris-api/package.json b/packages/iris-api/package.json
new file mode 100644
index 00000000..86b51053
--- /dev/null
+++ b/packages/iris-api/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@iris/api",
+ "version": "0.0.1",
+ "main": "dist/index.js",
+ "license": "MIT",
+ "scripts": {
+ "start": "tsc -w",
+ "build": "tsc"
+ },
+ "dependencies": {
+ "@types/uuid": "^8.3.0",
+ "swr": "^0.3.6"
+ }
+}
diff --git a/packages/iris-api/src/api.ts b/packages/iris-api/src/api.ts
new file mode 100644
index 00000000..7ef1791b
--- /dev/null
+++ b/packages/iris-api/src/api.ts
@@ -0,0 +1,57 @@
+import endpoint from "./endpoint";
+import { APIOptions, HttpError, MethodOptions } from "./types";
+import { stripEmptyKeys } from "./utils";
+
+export async function fetcher(endpoint: RequestInfo, options?: RequestInit) {
+ const res = await fetch(endpoint, options);
+
+ if (!res.ok) {
+ const error = new Error(res.statusText) as HttpError;
+ error.status = res.status;
+ throw error;
+ }
+
+ // swallow json parse errors
+ // TODO: handle this better, what about `res.blob()` etc...
+ try {
+ return await res.json();
+ } catch {}
+}
+
+export async function request(route: string, options: APIOptions) {
+ const { body, json, headers, method, signal, ...rest } = options;
+
+ const url = endpoint(route, rest);
+
+ return await fetcher(url, {
+ signal,
+ method,
+ headers: headers ? stripEmptyKeys(headers) : undefined,
+ body: json ? JSON.stringify(json) : body,
+ });
+}
+
+async function get(route: string, options: MethodOptions = {}) {
+ return await request(route, { ...options, method: "GET" });
+}
+
+async function post(route: string, options: MethodOptions = {}) {
+ return await request(route, { ...options, method: "POST" });
+}
+
+async function put(route: string, options: MethodOptions = {}) {
+ return await request(route, { ...options, method: "PUT" });
+}
+
+async function del(route: string, options: MethodOptions = {}) {
+ return await request(route, { ...options, method: "DELETE" });
+}
+
+const api = {
+ get,
+ post,
+ put,
+ del,
+};
+
+export default api;
diff --git a/packages/iris-api/src/endpoint.ts b/packages/iris-api/src/endpoint.ts
new file mode 100644
index 00000000..10172640
--- /dev/null
+++ b/packages/iris-api/src/endpoint.ts
@@ -0,0 +1,22 @@
+import { EndpointOptions, PathParams, QueryParams } from "./types";
+import { stripEmptyKeys } from "./utils";
+
+function injectPathParams(route: string, path?: PathParams) {
+ return route.replace(/\/:(\w+)/gi, (_match, group1) => `/${path?.[group1]}`);
+}
+
+function queryString(query?: QueryParams) {
+ if (query !== undefined) {
+ const safe = stripEmptyKeys(query);
+ return "?" + new URLSearchParams(safe).toString();
+ }
+ return "";
+}
+
+function endpoint(route: string, options: EndpointOptions = {}) {
+ const { baseUrl = "/api/v2", path, query } = options;
+
+ return baseUrl + injectPathParams(route, path) + queryString(query);
+}
+
+export default endpoint;
diff --git a/packages/iris-api/src/index.ts b/packages/iris-api/src/index.ts
new file mode 100644
index 00000000..14ccc0a4
--- /dev/null
+++ b/packages/iris-api/src/index.ts
@@ -0,0 +1,4 @@
+export * from "./types";
+export * from "./api";
+export { default as api } from "./api";
+export { default as endpoint } from "./endpoint";
diff --git a/packages/iris-api/src/types.ts b/packages/iris-api/src/types.ts
new file mode 100644
index 00000000..7305fe4e
--- /dev/null
+++ b/packages/iris-api/src/types.ts
@@ -0,0 +1,39 @@
+export interface PathParams {
+ [key: string]: string | undefined;
+}
+
+export interface QueryParams {
+ [key: string]: string | undefined;
+}
+
+export interface Headers {
+ [key: string]: string | undefined;
+}
+
+export interface EndpointOptions {
+ baseUrl?: string;
+ path?: PathParams;
+ query?: QueryParams;
+}
+
+export interface MethodOptions extends EndpointOptions {
+ headers?: Headers;
+ body?:
+ | string
+ | URLSearchParams
+ | FormData
+ | Blob
+ | ArrayBufferView
+ | ArrayBuffer
+ | ReadableStream;
+ json?: any;
+ signal?: AbortSignal;
+}
+
+export interface APIOptions extends MethodOptions {
+ method: string;
+}
+
+export interface HttpError extends Error {
+ status: number;
+}
diff --git a/packages/iris-api/src/utils.ts b/packages/iris-api/src/utils.ts
new file mode 100644
index 00000000..7b0f2879
--- /dev/null
+++ b/packages/iris-api/src/utils.ts
@@ -0,0 +1,9 @@
+export function stripEmptyKeys(o: { [key: string]: T | undefined }) {
+ let safe: { [key: string]: T } = {};
+ for (const [key, val] of Object.entries(o)) {
+ if (val !== undefined && val !== null) {
+ safe[key] = val;
+ }
+ }
+ return safe;
+}
diff --git a/packages/iris-api/tsconfig.json b/packages/iris-api/tsconfig.json
new file mode 100644
index 00000000..d4d4911e
--- /dev/null
+++ b/packages/iris-api/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "declarationMap": true,
+ "declaration": true,
+ "sourceMap": true,
+ "outDir": "dist",
+
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "module": "esnext",
+ "jsx": "react"
+ },
+ "include": ["src"]
+}
diff --git a/packages/iris-app/config/env.js b/packages/iris-app/config/env.js
new file mode 100644
index 00000000..09ec03c5
--- /dev/null
+++ b/packages/iris-app/config/env.js
@@ -0,0 +1,101 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const paths = require('./paths');
+
+// Make sure that including paths.js after env.js will read .env variables.
+delete require.cache[require.resolve('./paths')];
+
+const NODE_ENV = process.env.NODE_ENV;
+if (!NODE_ENV) {
+ throw new Error(
+ 'The NODE_ENV environment variable is required but was not specified.'
+ );
+}
+
+// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
+const dotenvFiles = [
+ `${paths.dotenv}.${NODE_ENV}.local`,
+ `${paths.dotenv}.${NODE_ENV}`,
+ // Don't include `.env.local` for `test` environment
+ // since normally you expect tests to produce the same
+ // results for everyone
+ NODE_ENV !== 'test' && `${paths.dotenv}.local`,
+ paths.dotenv,
+].filter(Boolean);
+
+// Load environment variables from .env* files. Suppress warnings using silent
+// if this file is missing. dotenv will never modify any environment variables
+// that have already been set. Variable expansion is supported in .env files.
+// https://github.com/motdotla/dotenv
+// https://github.com/motdotla/dotenv-expand
+dotenvFiles.forEach(dotenvFile => {
+ if (fs.existsSync(dotenvFile)) {
+ require('dotenv-expand')(
+ require('dotenv').config({
+ path: dotenvFile,
+ })
+ );
+ }
+});
+
+// We support resolving modules according to `NODE_PATH`.
+// This lets you use absolute paths in imports inside large monorepos:
+// https://github.com/facebook/create-react-app/issues/253.
+// It works similar to `NODE_PATH` in Node itself:
+// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
+// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
+// Otherwise, we risk importing Node.js core modules into an app instead of webpack shims.
+// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
+// We also resolve them to make sure all tools using them work consistently.
+const appDirectory = fs.realpathSync(process.cwd());
+process.env.NODE_PATH = (process.env.NODE_PATH || '')
+ .split(path.delimiter)
+ .filter(folder => folder && !path.isAbsolute(folder))
+ .map(folder => path.resolve(appDirectory, folder))
+ .join(path.delimiter);
+
+// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
+// injected into the application via DefinePlugin in webpack configuration.
+const REACT_APP = /^REACT_APP_/i;
+
+function getClientEnvironment(publicUrl) {
+ const raw = Object.keys(process.env)
+ .filter(key => REACT_APP.test(key))
+ .reduce(
+ (env, key) => {
+ env[key] = process.env[key];
+ return env;
+ },
+ {
+ // Useful for determining whether we’re running in production mode.
+ // Most importantly, it switches React into the correct mode.
+ NODE_ENV: process.env.NODE_ENV || 'development',
+ // Useful for resolving the correct path to static assets in `public`.
+ // For example,
.
+ // This should only be used as an escape hatch. Normally you would put
+ // images into the `src` and `import` them in code to get their paths.
+ PUBLIC_URL: publicUrl,
+ // We support configuring the sockjs pathname during development.
+ // These settings let a developer run multiple simultaneous projects.
+ // They are used as the connection `hostname`, `pathname` and `port`
+ // in webpackHotDevClient. They are used as the `sockHost`, `sockPath`
+ // and `sockPort` options in webpack-dev-server.
+ WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST,
+ WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH,
+ WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT,
+ }
+ );
+ // Stringify all values so we can feed into webpack DefinePlugin
+ const stringified = {
+ 'process.env': Object.keys(raw).reduce((env, key) => {
+ env[key] = JSON.stringify(raw[key]);
+ return env;
+ }, {}),
+ };
+
+ return { raw, stringified };
+}
+
+module.exports = getClientEnvironment;
diff --git a/packages/iris-app/config/getHttpsConfig.js b/packages/iris-app/config/getHttpsConfig.js
new file mode 100644
index 00000000..013d493c
--- /dev/null
+++ b/packages/iris-app/config/getHttpsConfig.js
@@ -0,0 +1,66 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const chalk = require('react-dev-utils/chalk');
+const paths = require('./paths');
+
+// Ensure the certificate and key provided are valid and if not
+// throw an easy to debug error
+function validateKeyAndCerts({ cert, key, keyFile, crtFile }) {
+ let encrypted;
+ try {
+ // publicEncrypt will throw an error with an invalid cert
+ encrypted = crypto.publicEncrypt(cert, Buffer.from('test'));
+ } catch (err) {
+ throw new Error(
+ `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}`
+ );
+ }
+
+ try {
+ // privateDecrypt will throw an error with an invalid key
+ crypto.privateDecrypt(key, encrypted);
+ } catch (err) {
+ throw new Error(
+ `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${
+ err.message
+ }`
+ );
+ }
+}
+
+// Read file and throw an error if it doesn't exist
+function readEnvFile(file, type) {
+ if (!fs.existsSync(file)) {
+ throw new Error(
+ `You specified ${chalk.cyan(
+ type
+ )} in your env, but the file "${chalk.yellow(file)}" can't be found.`
+ );
+ }
+ return fs.readFileSync(file);
+}
+
+// Get the https config
+// Return cert files if provided in env, otherwise just true or false
+function getHttpsConfig() {
+ const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env;
+ const isHttps = HTTPS === 'true';
+
+ if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) {
+ const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE);
+ const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE);
+ const config = {
+ cert: readEnvFile(crtFile, 'SSL_CRT_FILE'),
+ key: readEnvFile(keyFile, 'SSL_KEY_FILE'),
+ };
+
+ validateKeyAndCerts({ ...config, keyFile, crtFile });
+ return config;
+ }
+ return isHttps;
+}
+
+module.exports = getHttpsConfig;
diff --git a/packages/iris-app/config/jest/cssTransform.js b/packages/iris-app/config/jest/cssTransform.js
new file mode 100644
index 00000000..8f651148
--- /dev/null
+++ b/packages/iris-app/config/jest/cssTransform.js
@@ -0,0 +1,14 @@
+'use strict';
+
+// This is a custom Jest transformer turning style imports into empty objects.
+// http://facebook.github.io/jest/docs/en/webpack.html
+
+module.exports = {
+ process() {
+ return 'module.exports = {};';
+ },
+ getCacheKey() {
+ // The output is always the same.
+ return 'cssTransform';
+ },
+};
diff --git a/packages/iris-app/config/jest/fileTransform.js b/packages/iris-app/config/jest/fileTransform.js
new file mode 100644
index 00000000..aab67618
--- /dev/null
+++ b/packages/iris-app/config/jest/fileTransform.js
@@ -0,0 +1,40 @@
+'use strict';
+
+const path = require('path');
+const camelcase = require('camelcase');
+
+// This is a custom Jest transformer turning file imports into filenames.
+// http://facebook.github.io/jest/docs/en/webpack.html
+
+module.exports = {
+ process(src, filename) {
+ const assetFilename = JSON.stringify(path.basename(filename));
+
+ if (filename.match(/\.svg$/)) {
+ // Based on how SVGR generates a component name:
+ // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
+ const pascalCaseFilename = camelcase(path.parse(filename).name, {
+ pascalCase: true,
+ });
+ const componentName = `Svg${pascalCaseFilename}`;
+ return `const React = require('react');
+ module.exports = {
+ __esModule: true,
+ default: ${assetFilename},
+ ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
+ return {
+ $$typeof: Symbol.for('react.element'),
+ type: 'svg',
+ ref: ref,
+ key: null,
+ props: Object.assign({}, props, {
+ children: ${assetFilename}
+ })
+ };
+ }),
+ };`;
+ }
+
+ return `module.exports = ${assetFilename};`;
+ },
+};
diff --git a/packages/iris-app/config/modules.js b/packages/iris-app/config/modules.js
new file mode 100644
index 00000000..c8efd0dd
--- /dev/null
+++ b/packages/iris-app/config/modules.js
@@ -0,0 +1,141 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const paths = require('./paths');
+const chalk = require('react-dev-utils/chalk');
+const resolve = require('resolve');
+
+/**
+ * Get additional module paths based on the baseUrl of a compilerOptions object.
+ *
+ * @param {Object} options
+ */
+function getAdditionalModulePaths(options = {}) {
+ const baseUrl = options.baseUrl;
+
+ // We need to explicitly check for null and undefined (and not a falsy value) because
+ // TypeScript treats an empty string as `.`.
+ if (baseUrl == null) {
+ // If there's no baseUrl set we respect NODE_PATH
+ // Note that NODE_PATH is deprecated and will be removed
+ // in the next major release of create-react-app.
+
+ const nodePath = process.env.NODE_PATH || '';
+ return nodePath.split(path.delimiter).filter(Boolean);
+ }
+
+ const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
+
+ // We don't need to do anything if `baseUrl` is set to `node_modules`. This is
+ // the default behavior.
+ if (path.relative(paths.appNodeModules, baseUrlResolved) === '') {
+ return null;
+ }
+
+ // Allow the user set the `baseUrl` to `appSrc`.
+ if (path.relative(paths.appSrc, baseUrlResolved) === '') {
+ return [paths.appSrc];
+ }
+
+ // If the path is equal to the root directory we ignore it here.
+ // We don't want to allow importing from the root directly as source files are
+ // not transpiled outside of `src`. We do allow importing them with the
+ // absolute path (e.g. `src/Components/Button.js`) but we set that up with
+ // an alias.
+ if (path.relative(paths.appPath, baseUrlResolved) === '') {
+ return null;
+ }
+
+ // Otherwise, throw an error.
+ throw new Error(
+ chalk.red.bold(
+ "Your project's `baseUrl` can only be set to `src` or `node_modules`." +
+ ' Create React App does not support other values at this time.'
+ )
+ );
+}
+
+/**
+ * Get webpack aliases based on the baseUrl of a compilerOptions object.
+ *
+ * @param {*} options
+ */
+function getWebpackAliases(options = {}) {
+ const baseUrl = options.baseUrl;
+
+ if (!baseUrl) {
+ return {};
+ }
+
+ const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
+
+ if (path.relative(paths.appPath, baseUrlResolved) === '') {
+ return {
+ src: paths.appSrc,
+ };
+ }
+}
+
+/**
+ * Get jest aliases based on the baseUrl of a compilerOptions object.
+ *
+ * @param {*} options
+ */
+function getJestAliases(options = {}) {
+ const baseUrl = options.baseUrl;
+
+ if (!baseUrl) {
+ return {};
+ }
+
+ const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
+
+ if (path.relative(paths.appPath, baseUrlResolved) === '') {
+ return {
+ '^src/(.*)$': '/src/$1',
+ };
+ }
+}
+
+function getModules() {
+ // Check if TypeScript is setup
+ const hasTsConfig = fs.existsSync(paths.appTsConfig);
+ const hasJsConfig = fs.existsSync(paths.appJsConfig);
+
+ if (hasTsConfig && hasJsConfig) {
+ throw new Error(
+ 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.'
+ );
+ }
+
+ let config;
+
+ // If there's a tsconfig.json we assume it's a
+ // TypeScript project and set up the config
+ // based on tsconfig.json
+ if (hasTsConfig) {
+ const ts = require(resolve.sync('typescript', {
+ basedir: paths.appNodeModules,
+ }));
+ config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config;
+ // Otherwise we'll check if there is jsconfig.json
+ // for non TS projects.
+ } else if (hasJsConfig) {
+ config = require(paths.appJsConfig);
+ }
+
+ config = config || {};
+ const options = config.compilerOptions || {};
+
+ const additionalModulePaths = getAdditionalModulePaths(options);
+
+ return {
+ additionalModulePaths: additionalModulePaths,
+ webpackAliases: getWebpackAliases(options),
+ jestAliases: getJestAliases(options),
+ hasTsConfig,
+ };
+}
+
+module.exports = getModules();
diff --git a/packages/iris-app/config/paths.js b/packages/iris-app/config/paths.js
new file mode 100644
index 00000000..cbd59034
--- /dev/null
+++ b/packages/iris-app/config/paths.js
@@ -0,0 +1,71 @@
+"use strict";
+
+const path = require("path");
+const fs = require("fs");
+const getPublicUrlOrPath = require("react-dev-utils/getPublicUrlOrPath");
+
+// Make sure any symlinks in the project folder are resolved:
+// https://github.com/facebook/create-react-app/issues/637
+const appDirectory = fs.realpathSync(process.cwd());
+const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath);
+
+// We use `PUBLIC_URL` environment variable or "homepage" field to infer
+// "public path" at which the app is served.
+// webpack needs to know it to put the right
+
+