├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierignore ├── README.md ├── babel.config.js ├── lerna.json ├── package.json ├── packages ├── api │ ├── .babelrc.js │ ├── package.json │ ├── src │ │ ├── corsOptions.ts │ │ ├── createImageUpload.ts │ │ ├── createSendEmailVerification.ts │ │ ├── createVerifyEmail.ts │ │ ├── index.ts │ │ └── sendMail.ts │ └── tsconfig.json ├── auth │ ├── package.json │ ├── src │ │ ├── api │ │ │ ├── functions │ │ │ │ ├── createCookieHandler.ts │ │ │ │ ├── createRefreshTokenHandler.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── strategies │ │ │ │ ├── index.ts │ │ │ │ └── jsonwebtoken │ │ │ │ │ ├── index.ts │ │ │ │ │ └── stategy.ts │ │ │ └── utils │ │ │ │ ├── createAccessToken.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middyJwt.ts │ │ │ │ └── withAuthentication.ts │ │ ├── index.ts │ │ └── web │ │ │ └── index.tsx │ ├── tsconfig.browser.json │ ├── tsconfig.json │ └── tsconfig.types.json ├── cli │ ├── .babelrc │ ├── package.json │ ├── src │ │ ├── commands │ │ │ ├── convert.ts │ │ │ ├── create-key.ts │ │ │ ├── db.ts │ │ │ ├── dbCommands │ │ │ │ ├── generate.ts │ │ │ │ ├── initialize.ts │ │ │ │ ├── migrate.ts │ │ │ │ ├── migrateCommands │ │ │ │ │ ├── save.ts │ │ │ │ │ └── up.ts │ │ │ │ └── seed.ts │ │ │ ├── deploy.ts │ │ │ ├── dev.ts │ │ │ ├── gen.ts │ │ │ ├── genCommands │ │ │ │ ├── emails.ts │ │ │ │ └── graphql.ts │ │ │ ├── init.ts │ │ │ ├── repull.ts │ │ │ ├── serve.ts │ │ │ ├── serveCommands │ │ │ │ └── emails.ts │ │ │ ├── test.ts │ │ │ └── tunnel.ts │ │ └── index.ts │ └── tsconfig.json ├── config │ ├── package.json │ └── src │ │ └── index.js ├── core │ ├── package.json │ ├── src │ │ ├── getApiEndpoint │ │ │ └── index.ts │ │ └── index.ts │ └── tsconfig.json ├── create-saruni-app │ ├── package.json │ └── src │ │ ├── .babelrc │ │ └── create-saruni-app.ts ├── dev-server │ ├── .babelrc.js │ ├── package.json │ └── src │ │ └── index.ts ├── email │ ├── package.json │ ├── src │ │ ├── commands │ │ │ └── serve.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── create-static.ts │ │ │ └── index.ts │ └── tsconfig.json ├── internal │ ├── package.json │ ├── src │ │ ├── env │ │ │ └── index.ts │ │ ├── error │ │ │ ├── authentication.ts │ │ │ ├── authorization.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── paths │ │ │ └── index.ts │ └── tsconfig.json ├── test │ ├── package.json │ ├── src │ │ ├── ApiTestContext │ │ │ └── index.ts │ │ ├── WebTestContext │ │ │ └── index.ts │ │ └── index.ts │ └── tsconfig.json └── web │ ├── package.json │ ├── src │ ├── Apollo │ │ └── index.tsx │ └── index.ts │ └── tsconfig.json ├── tasks ├── publish-local ├── run-local-npm └── verdaccio.yml └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # artifacts 5 | dist 6 | tasks/.verdaccio -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@tambium/typescript", 4 | "plugin:@tambium/node", 5 | "plugin:@tambium/prettier" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules/ 3 | 4 | # editor 5 | .vscode 6 | 7 | # operating system 8 | **/.DS_Store 9 | 10 | # builds 11 | dist/ 12 | 13 | # yarn 14 | .yarn/* 15 | !.yarn/releases 16 | !.yarn/plugins 17 | .pnp.js 18 | .yarn/unplugged 19 | .yarn/build-state.yml 20 | 21 | # logs 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # verdaccio 27 | tasks/.verdaccio 28 | 29 | # lerna 30 | lerna-debug.log -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # artifacts 5 | dist 6 | tasks/.verdaccio -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saruni 2 | 3 | Saruni is a web application framework that uses a modern stack to improve developer experience. Our goal is for small development teams to feel comfortable writing and deploying world-class applications without breaking a sweat. Saruni offers sensible defaults for common challenges but makes every effort to give developers control. 4 | 5 | Learn how to use Saruni in your projects at [saruni.dev](https://saruni.dev/docs/getting-started/overview). 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-typescript', 4 | [ 5 | '@babel/preset-env', 6 | { 7 | targets: { 8 | node: true, 9 | }, 10 | useBuiltIns: 'usage', 11 | corejs: { 12 | version: 3.6, 13 | proposals: true, 14 | }, 15 | }, 16 | ], 17 | ], 18 | plugins: [ 19 | ['@babel/plugin-proposal-class-properties', { loose: true }], 20 | 21 | [ 22 | '@babel/plugin-transform-modules-commonjs', 23 | { 24 | allowTopLevelThis: true, 25 | }, 26 | ], 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "version": "independent", 6 | "publishConfig": { 7 | "access": "public", 8 | "registry": "https://registry.npmjs.org/" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saruni", 3 | "private": true, 4 | "license": "MIT", 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "prebuild": "yarn clean", 10 | "build": "lerna run build --npm-client=yarn", 11 | "clean": "rm -rf packages/*/dist", 12 | "lint:eslint": "eslint .", 13 | "prettier:check": "prettier --check .", 14 | "prettier:write": "prettier --write .", 15 | "verdaccio:up": "rm -rf ./tasks/.verdaccio && ./tasks/run-local-npm", 16 | "verdaccio:publish": "yarn build && ./tasks/publish-local", 17 | "release": "yarn build && lerna publish from-package", 18 | "version:patch": "lerna version patch" 19 | }, 20 | "prettier": "@tambium/prettier-config", 21 | "dependencies": {}, 22 | "devDependencies": { 23 | "@babel/core": "^7.9.6", 24 | "@babel/plugin-proposal-class-properties": "^7.8.3", 25 | "@babel/plugin-transform-modules-commonjs": "^7.9.6", 26 | "@babel/preset-env": "^7.9.6", 27 | "@babel/preset-typescript": "^7.9.0", 28 | "@tambium/eslint-plugin": "^1.0.15", 29 | "@tambium/prettier-config": "^1.0.2", 30 | "eslint": "^7.7.0", 31 | "lerna": "^3.20.2", 32 | "prettier": "^2.0.5", 33 | "typescript": "^3.9.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/api/.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: '../../babel.config.js' }; 2 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/api", 3 | "version": "0.0.9", 4 | "files": [ 5 | "dist" 6 | ], 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "types": "dist/index.d.ts", 11 | "module": "src/index.ts", 12 | "main": "dist/index.js", 13 | "scripts": { 14 | "build": "rm -rf dist && tsc", 15 | "serve": "yarn node dist/index.js", 16 | "dev": "yarn build && yarn serve", 17 | "build:watch": "nodemon --watch src --ext 'js,ts,tsx' --ignore dist --exec 'yarn dev'" 18 | }, 19 | "dependencies": { 20 | "@middy/core": "^1.0.0", 21 | "@middy/http-cors": "^1.0.0", 22 | "@middy/http-error-handler": "^1.0.0", 23 | "@middy/http-json-body-parser": "^1.0.0", 24 | "@middy/validator": "^1.0.0", 25 | "apollo-server-lambda": "2.16.1", 26 | "aws-lambda": "^1.0.6", 27 | "aws-sdk": "^2.718.0", 28 | "date-fns": "^2.14.0", 29 | "http-errors": "^1.7.3", 30 | "nodemailer": "^6.4.10", 31 | "uuid": "^8.1.0" 32 | }, 33 | "devDependencies": { 34 | "@babel/cli": "^7.10.1", 35 | "@babel/core": "^7.10.1", 36 | "@types/aws-sdk": "^2.7.0", 37 | "@types/http-errors": "^1.6.3", 38 | "@types/nodemailer": "^6.4.0", 39 | "@types/uuid": "^8.0.0" 40 | }, 41 | "peerDependencies": { 42 | "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" 43 | }, 44 | "gitHead": "046fe47d958dc1f468ef558ff224446f20601c7b" 45 | } 46 | -------------------------------------------------------------------------------- /packages/api/src/corsOptions.ts: -------------------------------------------------------------------------------- 1 | interface ICorsOptions { 2 | origin?: string; 3 | origins?: string[]; 4 | headers?: string; 5 | credentials?: boolean; 6 | maxAge?: string; 7 | cacheControl?: string; 8 | } 9 | 10 | const credentialsOrigin = process.env.WEB_DOMAIN || 'http://localhost:3000'; 11 | 12 | export const baseOptions: ICorsOptions = { 13 | credentials: false, 14 | headers: 15 | 'Content-Type, X-Amz-Date, Authorization, X-Api-Key, X-Amz-Security-Token, X-Amz-User-Agent', 16 | origin: '*', 17 | }; 18 | 19 | export const credentialsOptions: ICorsOptions = { 20 | credentials: true, 21 | headers: 22 | 'Content-Type, X-Amz-Date, Authorization, X-Api-Key, X-Amz-Security-Token, X-Amz-User-Agent', 23 | origin: credentialsOrigin, 24 | }; 25 | -------------------------------------------------------------------------------- /packages/api/src/createImageUpload.ts: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk'; 2 | import createError from 'http-errors'; 3 | import middy from '@middy/core'; 4 | import cors from '@middy/http-cors'; 5 | import httpErrorHandler from '@middy/http-error-handler'; 6 | import validator from '@middy/validator'; 7 | import jsonBodyParser from '@middy/http-json-body-parser'; 8 | import { v4 as uuidV4 } from 'uuid'; 9 | 10 | import type { 11 | APIGatewayEvent, 12 | Handler, 13 | APIGatewayProxyResultV2, 14 | } from 'aws-lambda'; 15 | 16 | import { credentialsOptions } from './corsOptions'; 17 | 18 | interface ImageUploadProperties { 19 | auth?: any; 20 | bucketName: string; 21 | } 22 | 23 | interface ImageUploadBody { 24 | body: { image: string; pathPrefix?: string }; 25 | } 26 | 27 | type ImageUploadEvent = Omit & ImageUploadBody; 28 | 29 | type ImageUploadLambda = Handler; 30 | 31 | export const createImageUpload = ({ 32 | auth = {}, 33 | bucketName, 34 | }: ImageUploadProperties) => { 35 | return middy(async (event) => { 36 | let path: string; 37 | let contentType: string; 38 | let body: Buffer; 39 | let extension: string; 40 | let location: string; 41 | 42 | try { 43 | const { image, pathPrefix } = event.body; 44 | 45 | body = Buffer.from( 46 | image.replace(/^data:image\/\w+;base64,/, ''), 47 | 'base64', 48 | ); 49 | 50 | contentType = image.match(/[^:]\w+\/[\w-+\d.]+(?=;|,)/)[0]; 51 | 52 | extension = contentType.split('/')[1]; 53 | 54 | if (pathPrefix) { 55 | path = `${pathPrefix}/${uuidV4()}.${extension}`; 56 | } else { 57 | path = `${uuidV4()}.${extension}`; 58 | } 59 | } catch { 60 | throw createError(422, 'Could not process image.'); 61 | } 62 | 63 | if (!contentType.includes('image')) { 64 | throw createError(422, 'File provided is not an image.'); 65 | } 66 | 67 | try { 68 | await new AWS.S3() 69 | .putObject({ 70 | Body: body, 71 | Bucket: bucketName, 72 | Key: path, 73 | ContentType: contentType, 74 | ContentEncoding: 'base64', 75 | }) 76 | .promise(); 77 | 78 | const { 79 | LocationConstraint, 80 | } = await new AWS.S3().getBucketLocation().promise(); 81 | 82 | const region = 83 | process.env.AWS_REGION || LocationConstraint || 'eu-west-1'; 84 | 85 | location = `https://${bucketName}.s3-${region}.amazonaws.com/${path}`; 86 | } catch { 87 | createError(500, 'Could not upload image.'); 88 | } 89 | 90 | return { 91 | statusCode: 201, 92 | body: JSON.stringify({ location }), 93 | }; 94 | }) 95 | .use(jsonBodyParser()) 96 | .use( 97 | validator({ 98 | inputSchema: { 99 | required: ['body'], 100 | type: 'object', 101 | properties: { 102 | body: { 103 | type: 'object', 104 | required: ['image'], 105 | properties: { 106 | image: { 107 | type: 'string', 108 | }, 109 | pathPrefix: { 110 | type: 'string', 111 | }, 112 | }, 113 | }, 114 | }, 115 | }, 116 | }), 117 | ) 118 | .use(auth) 119 | .use(httpErrorHandler()) 120 | .use(cors(credentialsOptions)); 121 | }; 122 | -------------------------------------------------------------------------------- /packages/api/src/createSendEmailVerification.ts: -------------------------------------------------------------------------------- 1 | import middy from '@middy/core'; 2 | import { add } from 'date-fns'; 3 | import createError from 'http-errors'; 4 | import { v4 as uuidV4 } from 'uuid'; 5 | 6 | import { sendMail } from './sendMail'; 7 | 8 | const between = (min: number, max: number): number => { 9 | return Math.floor(Math.random() * (max - min) + min); 10 | }; 11 | 12 | export const sendEmailVerification = ({ db }) => { 13 | // Check for the existence of required DB tables. 14 | if (!db.emailVerification) { 15 | throw new Error( 16 | 'Your database does not have an `EmailVerification` Model. Please create one.', 17 | ); 18 | } 19 | 20 | const handler = async (_event, context) => { 21 | const token = uuidV4(); 22 | 23 | const code = between(0, 9999); 24 | 25 | let result; 26 | 27 | try { 28 | result = await db.emailVerification.create({ 29 | data: { 30 | expiresAt: add(new Date(), { weeks: 1 }), 31 | token, 32 | code, 33 | user: { 34 | connect: { 35 | id: context.payload.userId, 36 | }, 37 | }, 38 | }, 39 | }); 40 | } catch { 41 | throw createError(500, 'Something went wrong.'); 42 | } 43 | 44 | try { 45 | sendMail(`http://localhost:3000/verify-email?token=${token}`, code); 46 | } catch { 47 | throw createError(500, 'Could not send email.'); 48 | } 49 | 50 | return { 51 | statusCode: 201, 52 | body: JSON.stringify({ emailVerification: result }), 53 | }; 54 | }; 55 | 56 | return middy(handler); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/api/src/createVerifyEmail.ts: -------------------------------------------------------------------------------- 1 | import { 2 | APIGatewayEvent, 3 | APIGatewayProxyResultV2, 4 | Context, 5 | Callback, 6 | } from 'aws-lambda'; 7 | import { isBefore } from 'date-fns'; 8 | import createError from 'http-errors'; 9 | 10 | interface VerifyEmailBody { 11 | body: { 12 | code?: number; 13 | token?: string; 14 | }; 15 | } 16 | 17 | interface JwtLambdaEvent { 18 | payload: { 19 | userId: number; 20 | }; 21 | } 22 | 23 | export type Handler = ( 24 | event: TEvent, 25 | context: Context & TContext, 26 | callback: Callback, 27 | ) => void | Promise; 28 | 29 | type VerifyEmailEvent = Omit & 30 | VerifyEmailBody & 31 | JwtLambdaEvent; 32 | 33 | interface JwtContext { 34 | payload: { userId: number }; 35 | } 36 | 37 | type VerifyEmailLambda = Handler< 38 | VerifyEmailEvent, 39 | APIGatewayProxyResultV2, 40 | JwtContext 41 | >; 42 | 43 | export const verifyEmail = ({ db }) => { 44 | const handler: VerifyEmailLambda = async (event, context) => { 45 | const token = event.body.token; 46 | const code = event.body.code; 47 | 48 | const userId = context.payload.userId; 49 | 50 | if (!token && !code) { 51 | throw new createError.BadRequest( 52 | 'Either token or code must be sent with the request.', 53 | ); 54 | } 55 | 56 | const user = await db.user.findOne({ 57 | where: { id: userId }, 58 | }); 59 | 60 | if (!user.emailVerified) { 61 | let result; 62 | 63 | if (token) { 64 | result = await db.emailVerification.findMany({ 65 | where: { token, userId }, 66 | orderBy: { expiresAt: 'desc' }, 67 | take: 1, 68 | }); 69 | 70 | if (result.length === 0) { 71 | throw createError( 72 | 400, 73 | `Email could not be verified. Please request a new email.`, 74 | ); 75 | } 76 | } else if (code) { 77 | result = await db.emailVerification.findMany({ 78 | where: { code, userId }, 79 | orderBy: { expiresAt: 'desc' }, 80 | take: 1, 81 | }); 82 | 83 | if (result.length === 0) { 84 | throw createError(400, `Incorrect code provided.`); 85 | } 86 | } 87 | 88 | const latest = result[0]; 89 | 90 | if (isBefore(new Date(), latest.expiresAt)) { 91 | await db.user.update({ 92 | where: { id: latest.userId }, 93 | data: { emailVerified: true }, 94 | }); 95 | 96 | await db.emailVerification.deleteMany({ 97 | where: { userId: latest.userId }, 98 | }); 99 | } 100 | } 101 | 102 | return { 103 | statusCode: 204, 104 | }; 105 | }; 106 | 107 | return handler; 108 | }; 109 | -------------------------------------------------------------------------------- /packages/api/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'apollo-server-lambda'; 2 | 3 | export * from './createSendEmailVerification'; 4 | export * from './createVerifyEmail'; 5 | export * from './createImageUpload'; 6 | export * from './corsOptions'; 7 | -------------------------------------------------------------------------------- /packages/api/src/sendMail.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | 3 | import aws from 'aws-sdk'; 4 | // import Mail from "nodemailer/lib/mailer"; 5 | 6 | async function setupNodeMailer() { 7 | // const nodemailer = require("nodemailer"); 8 | const testAccount = await nodemailer.createTestAccount(); 9 | 10 | // create reusable transporter object using the default SMTP transport 11 | const transporter = nodemailer.createTransport({ 12 | host: 'smtp.ethereal.email', 13 | port: 587, 14 | secure: false, 15 | auth: { 16 | user: testAccount.user, 17 | pass: testAccount.pass, 18 | }, 19 | }); 20 | return transporter; 21 | } 22 | 23 | export async function sendTemplatedEmail( 24 | template: aws.SES.SendTemplatedEmailRequest, 25 | ) { 26 | const environment = process.env.NODE_ENV; 27 | 28 | if (environment !== 'production') { 29 | const transporter = await setupNodeMailer(); 30 | } 31 | } 32 | 33 | export async function sendMail(url: string, code?: number) { 34 | // async..await is not allowed in global scope, must use a wrapper 35 | // Generate test SMTP service account from ethereal.email 36 | // Only needed if you don't have a real mail account for testing 37 | const testAccount = await nodemailer.createTestAccount(); 38 | 39 | // create reusable transporter object using the default SMTP transport 40 | const transporter = nodemailer.createTransport({ 41 | host: 'smtp.ethereal.email', 42 | port: 587, 43 | // true for 465, false for other ports 44 | secure: false, 45 | auth: { 46 | // generated ethereal user 47 | user: testAccount.user, 48 | // generated ethereal password 49 | pass: testAccount.pass, 50 | }, 51 | }); 52 | 53 | // send mail with defined transport object 54 | const info = await transporter.sendMail({ 55 | // sender address 56 | from: '"Fred Foo 👻" ', 57 | // list of receivers 58 | to: 'bar@example.com, baz@example.com', 59 | // Subject line 60 | subject: 'Hello', 61 | // plain text body 62 | text: 'Hello world?', 63 | // html body 64 | html: `Hello world? 65 | 66 | 67 | link is here 68 | 69 | 70 | the code is: ${code} 71 | 72 | 73 | 74 | `, 75 | }); 76 | 77 | console.log('Message sent: %s', info.messageId); 78 | // Message sent: 79 | 80 | // Preview only available when sending through an Ethereal account 81 | console.log('Preview URL: %s', nodemailer.getTestMessageUrl(info)); 82 | // Preview URL: https://ethereal.email/message/WaQKMgKddxQDoou... 83 | } 84 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "compilerOptions": { 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "moduleResolution": "node", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/auth", 3 | "version": "0.0.14", 4 | "files": [ 5 | "dist" 6 | ], 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "types": "dist/types/index.d.ts", 11 | "module": "src/index.ts", 12 | "main": "dist/module/index.js", 13 | "browser": "dist/browser/index.js", 14 | "scripts": { 15 | "build": "rm -rf dist && tsc && tsc --p tsconfig.browser.json && tsc --p tsconfig.types.json" 16 | }, 17 | "dependencies": { 18 | "@apollo/react-hooks": "^3.1.5", 19 | "@saruni/core": "^0.0.6", 20 | "apollo-cache-inmemory": "^1.6.6", 21 | "apollo-client": "^2.6.10", 22 | "apollo-link": "^1.2.14", 23 | "apollo-link-context": "^1.0.20", 24 | "apollo-link-error": "^1.1.13", 25 | "apollo-link-http": "^1.5.17", 26 | "cookie": "^0.4.1", 27 | "graphql-tag": "^2.10.3", 28 | "http-errors": "^1.7.3", 29 | "isomorphic-unfetch": "^3.0.0", 30 | "jsonwebtoken": "^8.5.1", 31 | "jwt-decode": "^2.2.0", 32 | "next": "^9.4.4" 33 | }, 34 | "devDependencies": { 35 | "@types/cookie": "^0.4.0", 36 | "@types/jsonwebtoken": "^8.5.0", 37 | "@types/jwt-decode": "^2.2.1", 38 | "aws-lambda": "^1.0.6" 39 | }, 40 | "gitHead": "046fe47d958dc1f468ef558ff224446f20601c7b", 41 | "peerDependencies": { 42 | "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0", 43 | "react": ">=16.13.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/auth/src/api/functions/createCookieHandler.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayEvent } from 'aws-lambda'; 2 | import { sign } from 'jsonwebtoken'; 3 | import createError from 'http-errors'; 4 | 5 | const createCookie = ( 6 | name: string, 7 | value: string, 8 | age = 60 ** 2 * 24 * 14, 9 | expires?: string, 10 | ) => { 11 | const domain = process.env.DOMAIN; 12 | const stage = process.env.STAGE; 13 | 14 | if (stage === 'prod') 15 | return `${name}=${value}; HttpOnly; Domain=${domain}; Secure; SameSite=Lax; Max-Age=${age}; ${ 16 | expires ? `Expires: ${expires};` : '' 17 | } path=/;`; 18 | 19 | return `${name}=${value}; HttpOnly; Max-Age=${age}; ${ 20 | expires ? `Expires: ${expires};` : '' 21 | } path=/;`; 22 | }; 23 | 24 | export const cookieManager = () => { 25 | if (!process.env.ACCESS_TOKEN_SECRET || !process.env.REFRESH_TOKEN_SECRET) { 26 | throw new Error( 27 | 'Please provide `ACCESS_TOKEN_SECRET` and `REFRESH_TOKEN_SECRET` in `.env`', 28 | ); 29 | } 30 | 31 | return async (event: APIGatewayEvent, context) => { 32 | const payload = context.payload; 33 | 34 | if (event.httpMethod === 'PUT') { 35 | const { exp, iat, ...rest } = payload; 36 | 37 | return { 38 | statusCode: 204, 39 | body: null, 40 | headers: { 41 | 'Set-Cookie': createCookie( 42 | 'jid', 43 | sign( 44 | { 45 | ...rest, 46 | exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, 47 | }, 48 | process.env.REFRESH_TOKEN_SECRET, 49 | ), 50 | ), 51 | }, 52 | }; 53 | } 54 | 55 | if (event.httpMethod === 'DELETE') { 56 | return { 57 | statusCode: 204, 58 | body: null, 59 | headers: { 60 | 'Set-Cookie': createCookie('jid', '', 0, new Date(0).toUTCString()), 61 | }, 62 | }; 63 | } 64 | 65 | throw createError(405, `Request method not supported.`); 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/auth/src/api/functions/createRefreshTokenHandler.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayEvent } from 'aws-lambda'; 2 | import cookie from 'cookie'; 3 | import createError from 'http-errors'; 4 | import { sign, verify } from 'jsonwebtoken'; 5 | 6 | export const refreshToken = () => { 7 | if (!process.env.ACCESS_TOKEN_SECRET || !process.env.REFRESH_TOKEN_SECRET) { 8 | throw new Error( 9 | 'Please provide `ACCESS_TOKEN_SECRET` and `REFRESH_TOKEN_SECRET` in `.env`', 10 | ); 11 | } 12 | 13 | return async (event: APIGatewayEvent) => { 14 | let payload: any; 15 | 16 | try { 17 | const { headers } = event; 18 | 19 | const header = headers.Cookie || headers.cookie; 20 | 21 | const { jid } = cookie.parse(header); 22 | 23 | payload = verify(jid, process.env.REFRESH_TOKEN_SECRET); 24 | } catch (error) { 25 | throw createError(401); 26 | } 27 | 28 | const { exp, iat, ...rest } = payload; 29 | 30 | return { 31 | statusCode: 200, 32 | body: JSON.stringify({ 33 | jwt: sign( 34 | { ...rest, exp: Math.floor(Date.now() / 1000) + 60 * 10 }, 35 | process.env.ACCESS_TOKEN_SECRET, 36 | ), 37 | }), 38 | }; 39 | }; 40 | }; 41 | // .use(httpErrorHandler()) 42 | // .use( 43 | // cors({ 44 | // credentials: true, 45 | // headers: 46 | // "Content-Type, X-Amz-Date, Authorization, X-Api-Key, X-Amz-Security-Token, X-Amz-User-Agent", 47 | // origin: "http://localhost:3000", 48 | // }) 49 | // ); 50 | 51 | // return handler; 52 | // }; 53 | -------------------------------------------------------------------------------- /packages/auth/src/api/functions/index.ts: -------------------------------------------------------------------------------- 1 | import { cookieManager } from './createCookieHandler'; 2 | import { refreshToken } from './createRefreshTokenHandler'; 3 | 4 | export { cookieManager, refreshToken }; 5 | -------------------------------------------------------------------------------- /packages/auth/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './functions'; 2 | export * from './utils'; 3 | export * from './strategies'; 4 | -------------------------------------------------------------------------------- /packages/auth/src/api/strategies/index.ts: -------------------------------------------------------------------------------- 1 | import { strategy as jsonwebtokenStrategy } from './jsonwebtoken'; 2 | 3 | export { jsonwebtokenStrategy }; 4 | -------------------------------------------------------------------------------- /packages/auth/src/api/strategies/jsonwebtoken/index.ts: -------------------------------------------------------------------------------- 1 | import { strategy } from './stategy'; 2 | 3 | export { strategy }; 4 | -------------------------------------------------------------------------------- /packages/auth/src/api/strategies/jsonwebtoken/stategy.ts: -------------------------------------------------------------------------------- 1 | import { verify } from 'jsonwebtoken'; 2 | 3 | const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; 4 | 5 | export const strategy = (context) => { 6 | if (!ACCESS_TOKEN_SECRET) { 7 | throw new Error( 8 | `Access token has not been provided in \`.env\`, which is required by the authentication strategy chosen in \`saruni.json\`.`, 9 | ); 10 | } 11 | 12 | const header = context.headers.authorization || context.headers.Authorization; 13 | const bearer = header?.split(' ')[1] || ''; 14 | 15 | const payload = verify(bearer, ACCESS_TOKEN_SECRET); 16 | return { ...context, payload }; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/auth/src/api/utils/createAccessToken.ts: -------------------------------------------------------------------------------- 1 | import { sign, SignOptions } from 'jsonwebtoken'; 2 | 3 | export const createAccessToken = ( 4 | payload: string | Buffer | object, 5 | options?: SignOptions, 6 | ): string => { 7 | return sign(payload, process.env.ACCESS_TOKEN_SECRET, options); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/auth/src/api/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { withAuthentication } from './withAuthentication'; 2 | import { createAccessToken } from './createAccessToken'; 3 | import { jwtMiddleware } from './middyJwt'; 4 | 5 | export { createAccessToken, jwtMiddleware, withAuthentication }; 6 | -------------------------------------------------------------------------------- /packages/auth/src/api/utils/middyJwt.ts: -------------------------------------------------------------------------------- 1 | import createError from 'http-errors'; 2 | import { verify } from 'jsonwebtoken'; 3 | 4 | import type { Context } from 'aws-lambda'; 5 | 6 | type JwtContext = Context & { payload: { userId: number } }; 7 | 8 | export const jwtMiddleware = () => { 9 | const middlewareObject = { 10 | before: (handler, next) => { 11 | try { 12 | const authHeader = 13 | (handler.event.headers.authorization as string) || 14 | (handler.event.headers.Authorization as string); 15 | 16 | const jwtToken = authHeader.split(' ')[1]; 17 | 18 | const payload = verify(jwtToken, process.env.ACCESS_TOKEN_SECRET) as { 19 | userId: number; 20 | }; 21 | 22 | (handler.context as JwtContext).payload = payload; 23 | return next(); 24 | } catch { 25 | return next(createError(401)); 26 | } 27 | }, 28 | }; 29 | 30 | return middlewareObject; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/auth/src/api/utils/withAuthentication.ts: -------------------------------------------------------------------------------- 1 | import { jsonwebtokenStrategy } from '../strategies'; 2 | 3 | // add the option for devs to provide their own strategy 4 | export const withAuthentication = (resolver) => ( 5 | parent, 6 | args, 7 | context, 8 | info, 9 | ) => { 10 | try { 11 | const contextWithPayload = jsonwebtokenStrategy(context); 12 | 13 | return resolver(parent, args, contextWithPayload, info); 14 | } catch { 15 | throw new Error('Authentication error!'); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /packages/auth/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './web'; 2 | export * from './api/utils'; 3 | 4 | export { cookieManager, refreshToken } from './api/functions'; 5 | -------------------------------------------------------------------------------- /packages/auth/src/web/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useApolloClient } from '@apollo/react-hooks'; 4 | import { getApiEndpoint } from '@saruni/core'; 5 | import { setContext } from 'apollo-link-context'; 6 | import jwtDecode from 'jwt-decode'; 7 | import { InMemoryCache } from 'apollo-cache-inmemory'; 8 | import { ApolloClient } from 'apollo-client'; 9 | import { ApolloLink } from 'apollo-link'; 10 | import { HttpLink } from 'apollo-link-http'; 11 | import { onError } from 'apollo-link-error'; 12 | import fetch from 'isomorphic-unfetch'; 13 | 14 | import { useRouter } from 'next/router'; 15 | 16 | const isServer = () => typeof window === 'undefined'; 17 | 18 | const isDev = () => process.env.NODE_ENV !== 'production'; 19 | 20 | let accessToken: string | undefined; 21 | 22 | export const getAccessToken = () => { 23 | return accessToken; 24 | }; 25 | 26 | export const setAccessToken = (token: string) => { 27 | accessToken = token; 28 | }; 29 | 30 | export const removeAccessToken = () => { 31 | accessToken = undefined; 32 | }; 33 | 34 | const isTokenValid = () => { 35 | const token = getAccessToken(); 36 | 37 | let isTokenValid = false; 38 | 39 | try { 40 | const { exp }: { exp: number } = jwtDecode(token); 41 | 42 | if (Date.now() < exp * 1000) { 43 | isTokenValid = true; 44 | } 45 | } catch (error) { 46 | throw new Error(`Unable to decode JWT token.`); 47 | } 48 | 49 | return isTokenValid; 50 | }; 51 | 52 | export const handleTokenRefresh = async () => { 53 | if (!isTokenValid()) { 54 | try { 55 | const result = await fetch(getApiEndpoint().refreshToken, { 56 | method: 'POST', 57 | credentials: 'include', 58 | headers: { 59 | 'content-type': 'application/json', 60 | }, 61 | }); 62 | 63 | const json = await result.json(); 64 | 65 | setAccessToken(json.jwt); 66 | } catch (error) { 67 | throw new Error(`Unable to create refresh token.`); 68 | } 69 | } 70 | }; 71 | 72 | export const refreshLink = setContext(async (_request, { headers }) => { 73 | await handleTokenRefresh(); 74 | 75 | return { 76 | headers: { 77 | ...headers, 78 | }, 79 | }; 80 | }); 81 | 82 | export const authLink = setContext(async (_request, { headers }) => { 83 | const token = getAccessToken(); 84 | 85 | if (token) { 86 | return { 87 | headers: { 88 | ...headers, 89 | authorization: token ? `bearer ${token}` : '', 90 | }, 91 | }; 92 | } 93 | 94 | return {}; 95 | }); 96 | 97 | const httpLink = new HttpLink({ 98 | uri: getApiEndpoint().graphql, 99 | credentials: 'include', 100 | fetch, 101 | }); 102 | 103 | const errorLink = onError(({ graphQLErrors, networkError }) => { 104 | console.log(graphQLErrors); 105 | console.log(networkError); 106 | }); 107 | 108 | export const AuthContext = React.createContext<{ 109 | isAuthenticated: boolean; 110 | loading: boolean; 111 | defaultRedirect: string; 112 | login: (...args: any[]) => void | Promise; 113 | signup: (...args: any[]) => void | Promise; 114 | logout: (...args: any[]) => void | Promise; 115 | }>({ 116 | defaultRedirect: '/', 117 | isAuthenticated: false, 118 | loading: false, 119 | login: () => {}, 120 | signup: () => {}, 121 | logout: () => {}, 122 | }); 123 | 124 | export const setToken = async (token) => { 125 | setAccessToken(token); 126 | 127 | await fetch(getApiEndpoint().cookieManager, { 128 | method: 'PUT', 129 | credentials: 'include', 130 | headers: { 131 | authorization: `bearer ${token}`, 132 | }, 133 | }); 134 | }; 135 | 136 | export const removeToken = async () => { 137 | await fetch(getApiEndpoint().cookieManager, { 138 | method: 'DELETE', 139 | credentials: 'include', 140 | headers: { 141 | authorization: `bearer ${getAccessToken()}`, 142 | }, 143 | }); 144 | 145 | removeAccessToken(); 146 | }; 147 | 148 | export const useJwt = () => { 149 | const client = useApolloClient(); 150 | 151 | return { 152 | setToken: async (token: string) => { 153 | try { 154 | await setToken(token); 155 | await client.resetStore(); 156 | } catch (error) { 157 | console.log(error); 158 | } 159 | }, 160 | removeToken: async () => { 161 | try { 162 | await removeToken(); 163 | await client.resetStore(); 164 | } catch (error) { 165 | console.log(error); 166 | } 167 | }, 168 | }; 169 | }; 170 | 171 | export const jwtClient = new ApolloClient({ 172 | ssrMode: false, 173 | link: ApolloLink.from([ 174 | ApolloLink.from([refreshLink, authLink, errorLink, httpLink]), 175 | ]), 176 | cache: new InMemoryCache(), 177 | }); 178 | 179 | export const useAuth = () => { 180 | const context = React.useContext(AuthContext); 181 | 182 | return context; 183 | }; 184 | 185 | export const privateRoute = ( 186 | Comp: React.FC, 187 | options?: { redirectTo?: string }, 188 | ) => { 189 | const PrivateRouteComponent: React.FC = (props) => { 190 | const router = useRouter(); 191 | 192 | const { loading, isAuthenticated } = React.useContext(AuthContext); 193 | 194 | React.useEffect(() => { 195 | if (!isAuthenticated && options.redirectTo && !loading) { 196 | router.replace(options.redirectTo); 197 | } 198 | }, [loading]); 199 | 200 | if (isServer() && isDev()) { 201 | return null; 202 | } 203 | 204 | if (loading && !isAuthenticated) return null; 205 | 206 | if (!isAuthenticated && options.redirectTo) return null; 207 | 208 | return ; 209 | }; 210 | 211 | return PrivateRouteComponent; 212 | }; 213 | 214 | export const useVerifyEmail = () => { 215 | const router = useRouter(); 216 | 217 | const { isAuthenticated } = useAuth(); 218 | 219 | const token = router?.query?.token; 220 | 221 | const [loading, setLoading] = React.useState(() => { 222 | return Boolean(token); 223 | }); 224 | 225 | const [done, setDone] = React.useState(() => false); 226 | 227 | const handler = React.useCallback(async (token) => { 228 | const fetchResult = await fetch(getApiEndpoint().verifyEmail, { 229 | method: 'PUT', 230 | body: JSON.stringify({ token }), 231 | headers: { 232 | 'content-type': 'application/json', 233 | authorization: `bearer ${getAccessToken()}`, 234 | }, 235 | }); 236 | 237 | if (!fetchResult.ok) { 238 | throw new Error(fetchResult.statusText); 239 | } 240 | 241 | setDone(true); 242 | setLoading(false); 243 | 244 | return true; 245 | }, []); 246 | 247 | const callback = React.useCallback(async () => { 248 | if (!token) return false; 249 | const firstToken: string = Array.isArray(token) ? token[0] : token; 250 | 251 | return handler(firstToken); 252 | }, [token]); 253 | 254 | const callbackWithCode = async (code: number) => { 255 | const fetchResult = await fetch(getApiEndpoint().verifyEmail, { 256 | method: 'PUT', 257 | body: JSON.stringify({ code }), 258 | // credentials: "include", 259 | headers: { 260 | 'content-type': 'application/json', 261 | authorization: `bearer ${getAccessToken()}`, 262 | }, 263 | }); 264 | 265 | return fetchResult; 266 | }; 267 | 268 | React.useEffect(() => { 269 | if (isAuthenticated) { 270 | callback(); 271 | } 272 | }, [callback, isAuthenticated]); 273 | 274 | return [{ done, loading }, callback, callbackWithCode]; 275 | }; 276 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["./src/api", "./src/strategies"], 4 | "include": ["./src/web"], 5 | "compilerOptions": { 6 | "jsx": "react", 7 | "outDir": "./dist/browser" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "declaration": true, 6 | "outDir": "./dist/module", 7 | "target": "ES3", 8 | "moduleResolution": "node", 9 | "module": "CommonJS", 10 | "sourceMap": true, 11 | "esModuleInterop": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "jsx": "react", 6 | "outDir": "./dist/types", 7 | "emitDeclarationOnly": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "node": true 9 | } 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/cli", 3 | "version": "0.0.16", 4 | "license": "Apache-2.0", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "bin": { 9 | "saruni": "./dist/index.js", 10 | "sr": "./dist/index.js" 11 | }, 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "module": "src/index.ts", 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "build": "rm -rf dist && babel --extensions \".js,.ts\" src --out-dir dist", 20 | "serve": "node dist/index.js", 21 | "dev": "yarn build && yarn serve", 22 | "release": "lerna publish from-package" 23 | }, 24 | "dependencies": { 25 | "@babel/cli": "^7.10.5", 26 | "@babel/core": "^7.11.0", 27 | "@babel/register": "^7.10.5", 28 | "@prisma/cli": "^2.5.0", 29 | "@saruni/config": "^0.0.7", 30 | "@saruni/internal": "^0.0.12", 31 | "aws-sdk": "^2.731.0", 32 | "axios": "^0.19.2", 33 | "chalk": "^4.0.0", 34 | "concurrently": "^5.2.0", 35 | "decompress": "^4.2.1", 36 | "execa": "^4.0.2", 37 | "findup-sync": "^4.0.0", 38 | "fs-extra": "^9.0.0", 39 | "jest": "^26.1.0", 40 | "listr": "^0.14.3", 41 | "rimraf": "^3.0.2", 42 | "terminal-link": "^2.1.1", 43 | "tmp": "^0.2.1", 44 | "typescript": "^3.9.7", 45 | "yargs": "^15.3.1" 46 | }, 47 | "devDependencies": { 48 | "@babel/preset-env": "^7.9.6", 49 | "@babel/preset-typescript": "^7.9.0", 50 | "@types/axios": "^0.14.0", 51 | "@types/concurrently": "^5.2.1", 52 | "@types/decompress": "^4.2.3", 53 | "@types/findup-sync": "^2.0.2", 54 | "@types/fs-extra": "^9.0.1", 55 | "@types/listr": "^0.14.2", 56 | "@types/node": "^14.0.5", 57 | "@types/rimraf": "^3.0.0", 58 | "@types/tmp": "^0.2.0", 59 | "@types/yargs": "^15.0.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/cli/src/commands/convert.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import execa from 'execa'; 3 | import fs from 'fs-extra'; 4 | import Listr from 'listr'; 5 | 6 | import { getPaths } from '@saruni/internal'; 7 | 8 | export const command = 'convert'; 9 | 10 | export const desc = 11 | 'converts a saruni project into a test project by replacing npm registry'; 12 | 13 | export const handler = async () => { 14 | try { 15 | await new Listr([ 16 | { 17 | title: `Create .yarnrc file and replacing registry`, 18 | task: async () => { 19 | await fs.writeFile( 20 | path.resolve(getPaths().base, '.yarnrc'), 21 | `registry "http://localhost:4873/"`, 22 | ); 23 | }, 24 | }, 25 | { 26 | title: `Deleting yarn.lock`, 27 | task: async () => { 28 | await execa('rm', ['-rf', 'yarn.lock']); 29 | }, 30 | }, 31 | { 32 | title: 'removing @saruni dependencies', 33 | task: async () => 34 | new Listr([ 35 | { 36 | title: 'worktree/package/api', 37 | task: async () => { 38 | await execa('yarn', ['remove', '@saruni/api'], { 39 | cwd: getPaths().api.base, 40 | }); 41 | }, 42 | }, 43 | { 44 | title: 'worktree', 45 | task: async () => { 46 | await execa( 47 | 'yarn', 48 | ['-W', 'remove', '@saruni/cli', '@saruni/dev-server'], 49 | { cwd: getPaths().base }, 50 | ); 51 | }, 52 | }, 53 | ]), 54 | }, 55 | { 56 | title: 'reinstalling @saruni dependencies', 57 | task: async () => 58 | new Listr([ 59 | { 60 | title: 'worktree/package/api', 61 | task: async () => { 62 | await execa('yarn', ['add', '@saruni/api'], { 63 | cwd: getPaths().api.base, 64 | }); 65 | }, 66 | }, 67 | { 68 | title: 'worktree', 69 | task: async () => { 70 | await execa( 71 | 'yarn', 72 | ['-W', 'add', '@saruni/cli', '@saruni/dev-server'], 73 | { cwd: getPaths().base }, 74 | ); 75 | }, 76 | }, 77 | ]), 78 | }, 79 | ]).run(); 80 | } catch (error) { 81 | console.log(error); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /packages/cli/src/commands/create-key.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import path from 'path'; 3 | import fs from 'fs-extra'; 4 | import { CommandBuilder } from 'yargs'; 5 | 6 | import { getPaths } from '@saruni/internal'; 7 | import execa from 'execa'; 8 | 9 | interface CreateKeyParams { 10 | name: string; 11 | stage: string; 12 | } 13 | 14 | export const command = 'create-key'; 15 | 16 | export const builder: CommandBuilder = (yargs) => { 17 | return yargs 18 | .option('stage', { 19 | default: 'dev', 20 | type: 'string', 21 | choices: ['prod', 'dev'], 22 | }) 23 | .option('name', { default: 'bastion-key', type: 'string' }); 24 | }; 25 | 26 | export const desc = 'creates a key with aws that can be used in ssh sessions'; 27 | 28 | export const handler = async (args: CreateKeyParams) => { 29 | const saruniJson = require(getPaths().saruni); 30 | 31 | const name = `${args.stage}-${args.name}`; 32 | const fileName = `${name}.pem`; 33 | 34 | const region = saruniJson.serverless[args.stage].region; 35 | const profile = saruniJson.serverless[args.stage].awsProfile; 36 | 37 | try { 38 | const hasKey = await fs.pathExists(`${args.name}.pem`); 39 | 40 | if (hasKey) { 41 | console.log(chalk.red(`The file ${fileName}.pem already exists.`)); 42 | console.log( 43 | chalk.yellow('It is advised to backup this file then delete it.'), 44 | ); 45 | 46 | process.exit(1); 47 | } 48 | 49 | const { stdout } = await execa(`aws`, [ 50 | 'ec2', 51 | 'create-key-pair', 52 | '--profile', 53 | profile, 54 | '--region', 55 | region, 56 | '--key-name', 57 | name, 58 | ]); 59 | 60 | const { KeyMaterial } = JSON.parse(stdout); 61 | 62 | await fs.writeFile(path.join(getPaths().base, fileName), KeyMaterial); 63 | 64 | await fs.chmod(path.join(getPaths().base, fileName), '700'); 65 | 66 | console.log(chalk.green(`Your key was created and saved as ${fileName}`)); 67 | } catch (error) { 68 | console.log(error); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /packages/cli/src/commands/db.ts: -------------------------------------------------------------------------------- 1 | import type { Argv } from 'yargs'; 2 | 3 | export const command = 'db '; 4 | export const aliases = ['database']; 5 | 6 | export const desc = 'Database commands.'; 7 | 8 | export const builder = (yargs: Argv) => { 9 | return yargs 10 | .option('stage', { 11 | default: 'local', 12 | type: 'string', 13 | choices: ['test', 'prod', 'dev', 'local'], 14 | }) 15 | .commandDir('./dbCommands') 16 | .demandCommand(); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/cli/src/commands/dbCommands/generate.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | 3 | import { getPaths } from '@saruni/internal'; 4 | 5 | export const command = 'generate'; 6 | 7 | export const desc = 'Creates the `PrismaClient` object.'; 8 | 9 | export const handler = async () => { 10 | const { stdout, stderr } = await execa('npx', ['prisma', 'generate'], { 11 | cwd: getPaths().api.base, 12 | }); 13 | 14 | console.log(stdout, stderr); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/cli/src/commands/dbCommands/initialize.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | 3 | import { getPaths } from '@saruni/internal'; 4 | 5 | export const command = 'init'; 6 | 7 | export const desc = 'Initializes database.'; 8 | 9 | export const handler = async () => { 10 | const { stdout } = await execa('npx', ['prisma', 'init'], { 11 | cwd: getPaths().api.base, 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/cli/src/commands/dbCommands/migrate.ts: -------------------------------------------------------------------------------- 1 | import type { Argv } from 'yargs'; 2 | 3 | export const command = 'migrate '; 4 | 5 | export const desc = 'Migration commands.'; 6 | 7 | export const builder = (yargs: Argv) => { 8 | yargs.commandDir('./migrateCommands').demandCommand(); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/cli/src/commands/dbCommands/migrateCommands/save.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | 3 | import { getPaths } from '@saruni/internal'; 4 | 5 | export const command = 'save'; 6 | 7 | export const desc = 8 | 'Saves a migration that defines the steps necessary to update the current schema.'; 9 | 10 | export const handler = async () => { 11 | const { stdout, stderr } = await execa( 12 | 'npx', 13 | ['prisma', 'migrate', 'save', '--experimental'], 14 | { 15 | cwd: getPaths().api.prisma, 16 | stdio: 'inherit', 17 | }, 18 | ); 19 | 20 | console.log(stdout, stderr); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/cli/src/commands/dbCommands/migrateCommands/up.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | 3 | import { getPaths } from '@saruni/internal'; 4 | 5 | export const command = 'up'; 6 | 7 | export const desc = 'Migrate the database up to a specific state.'; 8 | 9 | export const handler = async (args) => { 10 | switch (args.stage) { 11 | case 'dev': 12 | process.env.DATABASE_URL = process.env.DATABASE_URL_DEV; 13 | break; 14 | 15 | case 'prod': 16 | process.env.DATABASE_URL = process.env.DATABASE_URL_PROD; 17 | break; 18 | 19 | case 'test': 20 | process.env.DATABASE_URL = process.env.DATABASE_URL_TEST; 21 | break; 22 | 23 | default: 24 | break; 25 | } 26 | 27 | return execa('npx', ['prisma', 'migrate', 'up', '--experimental'], { 28 | cwd: getPaths().api.base, 29 | stdio: 'inherit', 30 | env: { 31 | DATABASE_URL: process.env.DATABASE_URL, 32 | }, 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/cli/src/commands/dbCommands/seed.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import babelRequireHook from '@babel/register'; 3 | import { getPaths } from '@saruni/internal'; 4 | import { magenta } from 'chalk'; 5 | 6 | export const command = 'seed'; 7 | 8 | export const desc = 'Creates entities within the database.'; 9 | 10 | // export const builder: CommandBuilder = (yargs) => { 11 | // return yargs.option("stage", { 12 | // default: "local", 13 | // type: "string", 14 | // choices: ["test", "prod", "dev", "local"], 15 | // }); 16 | // }; 17 | 18 | babelRequireHook({ 19 | extends: path.join(getPaths().api.base, '.babelrc.js'), 20 | extensions: ['.js', '.ts'], 21 | only: [path.resolve(getPaths().api.db)], 22 | ignore: ['node_modules'], 23 | cache: false, 24 | }); 25 | 26 | export const handler = async (args: { 27 | stage: 'dev' | 'test' | 'prod' | 'local'; 28 | }) => { 29 | switch (args.stage) { 30 | case 'dev': 31 | process.env.DATABASE_URL = process.env.DATABASE_URL_DEV; 32 | break; 33 | 34 | case 'prod': 35 | process.env.DATABASE_URL = process.env.DATABASE_URL_PROD; 36 | break; 37 | 38 | case 'test': 39 | process.env.DATABASE_URL = process.env.DATABASE_URL_TEST; 40 | break; 41 | 42 | default: 43 | break; 44 | } 45 | 46 | const { seed } = require(getPaths().api.seedFile); 47 | 48 | try { 49 | console.log(magenta('Start seeding the data into the database...\n')); 50 | 51 | await seed(); 52 | 53 | console.log(magenta('Seeding done.\n')); 54 | } catch { 55 | console.log('Something went wrong while seeding.\n'); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /packages/cli/src/commands/deploy.ts: -------------------------------------------------------------------------------- 1 | import { getPaths } from '@saruni/internal'; 2 | import execa from 'execa'; 3 | import Listr from 'listr'; 4 | import path from 'path'; 5 | import rimraf from 'rimraf'; 6 | import { CommandBuilder } from 'yargs'; 7 | 8 | export const command = 'deploy'; 9 | 10 | export const builder: CommandBuilder = (yargs) => { 11 | return yargs 12 | .option('stage', { 13 | default: 'dev', 14 | type: 'string', 15 | choices: ['dev', 'prod'], 16 | }) 17 | .option('service', { 18 | default: 'graphql', 19 | choices: ['resources', 'graphql', 'auth', 'web', 'domain'], 20 | }); 21 | }; 22 | 23 | export const desc = 'deploys the services found in the services folder'; 24 | 25 | const saruniJson = require(getPaths().saruni); 26 | 27 | export const handler = async (args) => { 28 | const region = saruniJson.serverless[args.stage].region; 29 | const profile = saruniJson.serverless[args.stage].awsProfile; 30 | 31 | if (args.service === 'resources') { 32 | await execa( 33 | 'sls', 34 | ['--aws-profile', profile, 'deploy', `--stage=${args.stage}`], 35 | { 36 | cwd: path.join(getPaths().base, 'packages/api/src/resources'), 37 | stdio: 'inherit', 38 | }, 39 | ); 40 | } 41 | 42 | if (args.service === 'domain') { 43 | await execa( 44 | 'sls', 45 | [ 46 | '--aws-profile', 47 | saruniJson.serverless.prod.awsProfile, 48 | 'create_domain', 49 | `--stage=prod`, 50 | ], 51 | { 52 | cwd: path.join(getPaths().base, 'packages/api/src/services/graphql'), 53 | stdio: 'inherit', 54 | }, 55 | ); 56 | } 57 | 58 | if (args.service === 'graphql') { 59 | await execa( 60 | 'sls', 61 | ['--aws-profile', profile, 'deploy', `--stage=${args.stage}`], 62 | { 63 | cwd: path.join(getPaths().base, 'packages/api/src/services/graphql'), 64 | stdio: 'inherit', 65 | }, 66 | ); 67 | } 68 | 69 | if (args.service === 'auth') { 70 | await execa( 71 | 'sls', 72 | ['--aws-profile', profile, 'deploy', `--stage=${args.stage}`], 73 | { 74 | cwd: path.join(getPaths().base, 'packages/api/src/services/auth'), 75 | stdio: 'inherit', 76 | }, 77 | ); 78 | } 79 | 80 | if (args.service === 'web') { 81 | try { 82 | await new Listr([ 83 | { 84 | title: `Preparing frontend`, 85 | task: async () => 86 | new Listr([ 87 | { 88 | title: 'Clearing build directories', 89 | task: () => { 90 | rimraf.sync(path.join(getPaths().web.base, 'out')); 91 | }, 92 | }, 93 | { 94 | title: 'Building Next.js production build', 95 | task: async () => { 96 | await execa('yarn', ['build'], { 97 | cwd: getPaths().web.base, 98 | env: { STAGE: args.stage }, 99 | }); 100 | }, 101 | }, 102 | { 103 | title: 'Creating a static build', 104 | task: async () => { 105 | await execa('yarn', ['export'], { 106 | cwd: getPaths().web.base, 107 | env: { 108 | STAGE: args.stage, 109 | }, 110 | }); 111 | }, 112 | }, 113 | { 114 | title: 'Uploading to S3', 115 | task: async () => { 116 | await execa( 117 | 'aws', 118 | [ 119 | 's3', 120 | '--profile', 121 | saruniJson.serverless[args.stage].awsProfile, 122 | 'sync', 123 | 'out', 124 | `s3://${ 125 | saruniJson.serverless[args.stage].frontendS3Bucket 126 | }`, 127 | '--delete', 128 | ], 129 | { cwd: getPaths().web.base }, 130 | ); 131 | }, 132 | }, 133 | { 134 | title: 'Invalidating cloudfront cache', 135 | task: async () => { 136 | await execa( 137 | 'aws', 138 | [ 139 | 'cloudfront', 140 | '--profile', 141 | saruniJson.serverless[args.stage].awsProfile, 142 | 'create-invalidation', 143 | '--distribution-id', 144 | saruniJson.serverless[args.state].distId, 145 | '--paths', 146 | '*', 147 | ], 148 | { cwd: getPaths().web.base }, 149 | ); 150 | }, 151 | }, 152 | { 153 | title: 'Invalidating cloudfront cache', 154 | task: async () => { 155 | await execa( 156 | `AWS_PROFILE=${ 157 | saruniJson.serverless[args.stage].awsProfile 158 | }`, 159 | [ 160 | 'aws', 161 | 'cloudfront', 162 | 'create-invalidation', 163 | '--distribution-id', 164 | 'E1W56ST7R2SIRK', 165 | '--paths', 166 | '*', 167 | ], 168 | { cwd: getPaths().web.base }, 169 | ); 170 | }, 171 | }, 172 | ]), 173 | }, 174 | ]).run(); 175 | } catch (error) { 176 | console.log(error); 177 | } 178 | } 179 | }; 180 | -------------------------------------------------------------------------------- /packages/cli/src/commands/dev.ts: -------------------------------------------------------------------------------- 1 | import concurrently from 'concurrently'; 2 | import { getPaths } from '@saruni/internal'; 3 | import { CommandBuilder } from 'yargs'; 4 | 5 | const saruniJson = require(getPaths().saruni); 6 | 7 | export const command = 'dev'; 8 | 9 | export const desc = 'Start development servers.'; 10 | 11 | export const builder: CommandBuilder = (yargs) => { 12 | return yargs.option('cloud', { default: false, type: 'boolean' }); 13 | }; 14 | 15 | export const handler = async (args: { cloud: boolean }) => { 16 | let nextCommand = `yarn dev`; 17 | 18 | if (saruniJson.devServerPort.web !== '3000') { 19 | nextCommand = `npx next dev -p ${saruniJson.devServerPort.web}`; 20 | } 21 | 22 | if (args.cloud === true) { 23 | return concurrently([ 24 | { 25 | command: `cd ${getPaths().web.base} && USE_CLOUD=true ${nextCommand}`, 26 | }, 27 | ]); 28 | } 29 | 30 | return concurrently([ 31 | { 32 | command: 'yarn ds', 33 | }, 34 | { 35 | command: `cd ${getPaths().web.base} && ${nextCommand}`, 36 | }, 37 | { 38 | command: `cd ${getPaths().api.base} && npx prisma generate --watch`, 39 | }, 40 | ]); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/cli/src/commands/gen.ts: -------------------------------------------------------------------------------- 1 | import type { Argv } from 'yargs'; 2 | 3 | export const command = 'gen '; 4 | export const aliases = ['generate']; 5 | 6 | export const desc = 'Generate commands.'; 7 | 8 | export const builder = (yargs: Argv) => { 9 | yargs.commandDir('./genCommands').demandCommand(); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/cli/src/commands/genCommands/emails.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import { remove } from 'fs-extra'; 3 | import { getPaths } from '@saruni/internal'; 4 | 5 | export const command = 'emails'; 6 | export const aliases = ['email']; 7 | 8 | export const desc = 'Compile email templates with babel and tsc.'; 9 | 10 | export const handler = async () => { 11 | await remove(getPaths().static.generatedEmails); 12 | 13 | await execa( 14 | 'babel', 15 | ['--out-dir', './generated', '--extensions', '.ts,.tsx', './src'], 16 | { 17 | cwd: getPaths().static.emails, 18 | }, 19 | ); 20 | 21 | await execa( 22 | 'tsc', 23 | ['--outDir', './generated', '--rootDir', './src', '-p', './tsconfig.json'], 24 | { 25 | cwd: getPaths().static.emails, 26 | }, 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/cli/src/commands/genCommands/graphql.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import Listr from 'listr'; 3 | 4 | import { getPaths } from '@saruni/internal'; 5 | 6 | export const command = 'graphql'; 7 | 8 | export const aliases = ['gql']; 9 | 10 | export const desc = 'Generate code from your GraphQL schema and operations.'; 11 | 12 | export const handler = async () => { 13 | try { 14 | await new Listr([ 15 | { 16 | title: `Generating GraphQL code.`, 17 | task: async () => { 18 | await execa('yarn', ['gen'], { cwd: getPaths().web.base }); 19 | }, 20 | }, 21 | ]).run(); 22 | } catch (error) { 23 | console.log(error); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /packages/cli/src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import axios from 'axios'; 3 | import decompress from 'decompress'; 4 | import execa from 'execa'; 5 | import fs from 'fs-extra'; 6 | import Listr from 'listr'; 7 | import tmp from 'tmp'; 8 | 9 | import { getPaths } from '@saruni/internal'; 10 | 11 | export const command = 'init'; 12 | 13 | export const desc = 'Inits Saruni project'; 14 | 15 | const latestReleaseZipFile = async (releaseUrl: string) => { 16 | const response = await axios.get(releaseUrl); 17 | return response.data.zipball_url; 18 | }; 19 | 20 | const downloadFile = async (sourceUrl: string, targetFile: string) => { 21 | const writer = fs.createWriteStream(targetFile); 22 | const response = await axios.get(sourceUrl, { 23 | responseType: 'stream', 24 | }); 25 | response.data.pipe(writer); 26 | 27 | return new Promise((resolve, reject) => { 28 | writer.on('finish', resolve); 29 | writer.on('error', reject); 30 | }); 31 | }; 32 | 33 | const getServerlessResources = async (flavour = 'jwt') => { 34 | const url = await latestReleaseZipFile( 35 | 'https://api.github.com/repos/tambium/sls-resources/releases/latest', 36 | ); 37 | 38 | const tmpDownloadPath = tmp.tmpNameSync({ 39 | prefix: 'sls', 40 | postfix: '.zip', 41 | }); 42 | 43 | await downloadFile(url, tmpDownloadPath); 44 | 45 | await fs.ensureDir(path.resolve(getPaths().base, 'tmp')); 46 | 47 | await fs.ensureDir(getPaths().sls.resources.base); 48 | 49 | await decompress(tmpDownloadPath, path.resolve(getPaths().base, 'tmp'), { 50 | strip: 1, 51 | }); 52 | 53 | await fs.copy( 54 | path.resolve(getPaths().base, 'tmp', `${flavour}/resource`), 55 | getPaths().sls.resources.base, 56 | ); 57 | 58 | await fs.copy( 59 | path.resolve(getPaths().base, 'tmp', `${flavour}/serverless.yml`), 60 | getPaths().sls.yml, 61 | ); 62 | 63 | await fs.copy( 64 | path.resolve(getPaths().base, 'tmp', `${flavour}/webpack.config.js`), 65 | path.resolve(getPaths().api.base, 'webpack.config.js'), 66 | ); 67 | 68 | await fs.remove(path.resolve(getPaths().base, 'tmp', `${flavour}/resource`)); 69 | }; 70 | 71 | export const handler = async () => { 72 | try { 73 | await new Listr([ 74 | // { 75 | // title: `Init git repo`, 76 | // task: async () => { 77 | // await execa("git", ["init"]); 78 | // }, 79 | // }, 80 | // { 81 | // title: `create .env`, 82 | // task: async () => { 83 | // await execa("git", ["init"]); 84 | // }, 85 | // }, 86 | { 87 | title: `Set up the serverless framewor`, 88 | task: async () => { 89 | return new Listr([ 90 | { 91 | title: 'Downloading serverless resources', 92 | task: () => getServerlessResources(), 93 | }, 94 | { 95 | title: 'Installing sls dependencies', 96 | task: async () => { 97 | await execa( 98 | 'yarn', 99 | [ 100 | '-W', 101 | 'add', 102 | 'serverless-webpack', 103 | 'serverless-pseudo-parameters', 104 | ], 105 | { cwd: getPaths().base }, 106 | ); 107 | 108 | await execa('yarn', ['add', '-D', 'babel-loader'], { 109 | cwd: getPaths().api.base, 110 | }); 111 | }, 112 | }, 113 | ]); 114 | }, 115 | }, 116 | ]).run(); 117 | } catch (error) { 118 | console.log(error); 119 | } 120 | }; 121 | -------------------------------------------------------------------------------- /packages/cli/src/commands/repull.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import execa from 'execa'; 3 | import fs from 'fs-extra'; 4 | import Listr from 'listr'; 5 | 6 | import { getPaths, getSaruniPackages } from '@saruni/internal'; 7 | 8 | export const command = 'repull'; 9 | 10 | export const desc = 11 | 'pulls the latest published @saruni packages from local verdaccio registry'; 12 | 13 | export const handler = async () => { 14 | try { 15 | const { api, root, web } = await getSaruniPackages(); 16 | 17 | await new Listr([ 18 | { 19 | title: `Checking if ".yarnrc" is present`, 20 | task: async () => { 21 | const yarnrc = await fs.readFile( 22 | path.resolve(getPaths().base, '.yarnrc'), 23 | 'utf8', 24 | ); 25 | 26 | if (!yarnrc) { 27 | throw new Error( 28 | '.yarnrc does not exist. Packages will be pulled from the npm registry instead of verdaccio.', 29 | ); 30 | } 31 | 32 | if (!yarnrc.includes(`registry "http://localhost:4873/"`)) { 33 | throw new Error( 34 | '.yarnrc does not point to the local verdaccio registry', 35 | ); 36 | } 37 | }, 38 | }, 39 | { 40 | title: 'removing @saruni dependencies', 41 | task: async () => 42 | new Listr([ 43 | { 44 | title: 'worktree/packages/api', 45 | task: async () => { 46 | if (api.length > 0) 47 | await execa('yarn', ['remove', ...api], { 48 | cwd: getPaths().api.base, 49 | }); 50 | }, 51 | }, 52 | { 53 | title: 'worktree/packages/web', 54 | task: async () => { 55 | if (web.length > 0) 56 | await execa('yarn', ['remove', ...web], { 57 | cwd: getPaths().web.base, 58 | }); 59 | }, 60 | }, 61 | { 62 | title: 'worktree/root', 63 | task: async () => { 64 | if (root.length > 0) 65 | await execa('yarn', ['-W', 'remove', ...root], { 66 | cwd: getPaths().base, 67 | }); 68 | }, 69 | }, 70 | ]), 71 | }, 72 | { 73 | title: 'reinstalling @saruni dependencies', 74 | task: async () => 75 | new Listr([ 76 | { 77 | title: 'worktree/packages/api', 78 | task: async () => { 79 | if (api.length > 0) 80 | await execa('yarn', ['add', ...api], { 81 | cwd: getPaths().api.base, 82 | }); 83 | }, 84 | }, 85 | { 86 | title: 'worktree/packages/web', 87 | task: async () => { 88 | if (web.length > 0) 89 | await execa('yarn', ['add', ...web], { 90 | cwd: getPaths().web.base, 91 | }); 92 | }, 93 | }, 94 | { 95 | title: 'worktree/root', 96 | task: async () => { 97 | if (root.length > 0) 98 | await execa('yarn', ['-W', 'add', ...root], { 99 | cwd: getPaths().base, 100 | }); 101 | }, 102 | }, 103 | ]), 104 | }, 105 | ]).run(); 106 | } catch (error) { 107 | console.log(error); 108 | } 109 | }; 110 | -------------------------------------------------------------------------------- /packages/cli/src/commands/serve.ts: -------------------------------------------------------------------------------- 1 | import type { Argv } from 'yargs'; 2 | 3 | export const command = 'serve '; 4 | 5 | export const desc = 'Serve commands.'; 6 | 7 | export const builder = (yargs: Argv) => { 8 | yargs.commandDir('./serveCommands').demandCommand(); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/cli/src/commands/serveCommands/emails.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import { getPaths } from '@saruni/internal'; 3 | 4 | export const command = 'emails'; 5 | export const aliases = ['email']; 6 | 7 | export const desc = 'Serve generated HTML emails.'; 8 | 9 | export const handler = async () => { 10 | /** Serve contents of `generated/emails` directory. */ 11 | await execa('yarn', ['saruni-serve-emails'], { 12 | cwd: getPaths().static.base, 13 | stdio: 'inherit', 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/cli/src/commands/test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { getPaths } from '@saruni/internal'; 4 | import execa from 'execa'; 5 | import { run } from 'jest'; 6 | import { CommandBuilder } from 'yargs'; 7 | import babelRequireHook from '@babel/register'; 8 | 9 | babelRequireHook({ 10 | extends: path.join(getPaths().api.base, '.babelrc.js'), 11 | extensions: ['.js', '.ts'], 12 | only: [path.resolve(getPaths().api.db)], 13 | ignore: ['node_modules'], 14 | cache: false, 15 | }); 16 | 17 | export const command = 'test'; 18 | 19 | export const desc = 'Runs Jest with the project based setup.'; 20 | 21 | export const builder: CommandBuilder = (yargs) => { 22 | return yargs 23 | .option('watchAll', { 24 | default: false, 25 | type: 'boolean', 26 | }) 27 | .option('side', { 28 | default: ['api', 'web'], 29 | type: 'array', 30 | choices: ['api', 'web'], 31 | }); 32 | }; 33 | 34 | export const handler = async (args) => { 35 | try { 36 | process.env.DATABASE_URL = process.env.DATABASE_URL_TEST; 37 | 38 | const { db } = require(getPaths().api.db); 39 | 40 | if (args.side.find('api')) { 41 | await db.$queryRaw(`DROP SCHEMA IF EXISTS "public" CASCADE`); 42 | 43 | await db.$disconnect(); 44 | 45 | await execa('npx', ['prisma', 'migrate', 'up', '--experimental'], { 46 | cwd: getPaths().api.base, 47 | env: { DATABASE_URL: process.env.DATABASE_URL_TEST }, 48 | }); 49 | 50 | await execa('yarn', ['sr', 'db', 'seed'], { 51 | cwd: getPaths().api.base, 52 | env: { DATABASE_URL: process.env.DATABASE_URL_TEST }, 53 | }); 54 | } 55 | 56 | const testCommand = [ 57 | `--config=${require.resolve('@saruni/config/dist/index.js')}`, 58 | ]; 59 | 60 | if (args.watchAll) { 61 | testCommand.push('--watchAll'); 62 | } 63 | 64 | if (args.side.length === 1) { 65 | testCommand.push(`--projects="/packages/${args.side[0]}"`); 66 | } 67 | 68 | await run(testCommand); 69 | } catch (error) { 70 | console.log(error); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /packages/cli/src/commands/tunnel.ts: -------------------------------------------------------------------------------- 1 | import { getPaths } from '@saruni/internal'; 2 | import execa from 'execa'; 3 | import { CommandBuilder } from 'yargs'; 4 | 5 | export const command = 'tunnel'; 6 | 7 | export const builder: CommandBuilder = (yargs) => { 8 | return yargs.option('stage', { 9 | default: 'dev', 10 | type: 'string', 11 | choices: ['dev', 'prod'], 12 | }); 13 | }; 14 | 15 | export const desc = 16 | 'Creates an SSH tunnel using your bastion-key to the AWS RDS instance.'; 17 | 18 | const saruniJson = require(getPaths().saruni); 19 | 20 | export const handler = async (args) => { 21 | // const region = saruniJson.serverless[args.stage].region; 22 | // const profile = saruniJson.serverless[args.stage].awsProfile; 23 | const rdsPort = 24 | args.stage === 'dev' ? process.env.RDS_PORT_DEV : process.env.RDS_PORT_PROD; 25 | const ec2Domain = 26 | args.stage === 'dev' 27 | ? process.env.EC2_DOMAIN_DEV 28 | : process.env.EC2_DOMAIN_PROD; 29 | const keyName = saruniJson.serverless[args.stage].bastionKeyName; 30 | 31 | await execa( 32 | 'ssh', 33 | [ 34 | '-L', 35 | `2222:${rdsPort}:5432`, 36 | '-i', 37 | `${args.stage}-${keyName}.pem`, 38 | `ec2-user@${ec2Domain}`, 39 | ], 40 | { 41 | cwd: getPaths().base, 42 | stdio: 'inherit', 43 | }, 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import yargs from 'yargs'; 3 | 4 | yargs.commandDir('./commands').scriptName('saruni').demandCommand().argv; 5 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "moduleResolution": "node", 9 | "declaration": false, 10 | "composite": false, 11 | "removeComments": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "noImplicitThis": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "allowSyntheticDefaultImports": true, 21 | "emitDecoratorMetadata": true, 22 | "experimentalDecorators": true, 23 | "skipLibCheck": true, 24 | // "baseUrl": ".", 25 | "rootDir": "src", 26 | "esModuleInterop": true 27 | }, 28 | 29 | "exclude": ["node_modules"], 30 | "include": ["./src/**/*.tsx", "./src/**/*.ts"] 31 | // "references": [{ "path": "../common" }] 32 | } 33 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/config", 3 | "version": "0.0.7", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "module": "src/index.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "scripts": { 14 | "build": "rm -rf dist && mkdir dist && cp -R src/. dist" 15 | }, 16 | "dependencies": { 17 | "@babel/register": "^7.10.5", 18 | "@saruni/internal": "^0.0.12", 19 | "chalk": "^4.1.0", 20 | "dotenv": "^8.2.0", 21 | "execa": "^4.0.3" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^14.0.5", 25 | "typescript": "^3.9.3" 26 | }, 27 | "gitHead": "046fe47d958dc1f468ef558ff224446f20601c7b" 28 | } 29 | -------------------------------------------------------------------------------- /packages/config/src/index.js: -------------------------------------------------------------------------------- 1 | const { getPaths } = require('@saruni/internal'); 2 | 3 | module.exports = { 4 | verbose: true, 5 | rootDir: getPaths().base, 6 | projects: ['/packages/api', '/packages/web'], 7 | }; 8 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/core", 3 | "version": "0.0.6", 4 | "license": "MIT", 5 | "files": [ 6 | "dist" 7 | ], 8 | "publishConfig": { 9 | "access": "public" 10 | }, 11 | "types": "dist/index.d.ts", 12 | "module": "src/index.ts", 13 | "main": "dist/index.js", 14 | "dependencies": { 15 | "url-join": "^4.0.1" 16 | }, 17 | "scripts": { 18 | "build": "rm -rf dist && tsc" 19 | }, 20 | "devDependencies": { 21 | "@types/url-join": "^4.0.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/src/getApiEndpoint/index.ts: -------------------------------------------------------------------------------- 1 | import url from 'url-join'; 2 | 3 | const IMAGE_UPLOAD = 'imageUpload'; 4 | const GRAPHQL = 'graphql'; 5 | const REFRESH_TOKEN = 'refreshToken'; 6 | const COOKIE_MANAGER = 'cookieManager'; 7 | const VERIFY_EMAIL = 'verifyEmail'; 8 | const SEND_EMAIL_VERIFICATION = 'sendEmailVerification'; 9 | 10 | export const getApiEndpoint = () => { 11 | const baseUrl = process.env.API_ENDPOINT || 'http://localhost:4000'; 12 | 13 | return { 14 | imageUpload: url(baseUrl, IMAGE_UPLOAD), 15 | graphql: url(baseUrl, GRAPHQL), 16 | refreshToken: url(baseUrl, REFRESH_TOKEN), 17 | cookieManager: url(baseUrl, COOKIE_MANAGER), 18 | verifyEmail: url(baseUrl, VERIFY_EMAIL), 19 | sendEmailVerification: url(baseUrl, SEND_EMAIL_VERIFICATION), 20 | }; 21 | }; 22 | 23 | export const getCustomApiEndpoint = (resource: string): string => { 24 | const baseUrl = process.env.API_ENDPOINT || 'http://localhost:4000'; 25 | 26 | return url(baseUrl, resource); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getApiEndpoint'; 2 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "compilerOptions": { 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "moduleResolution": "node", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/create-saruni-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-saruni-app", 3 | "version": "0.0.8", 4 | "bin": { 5 | "create-saruni-app": "./dist/create-saruni-app.js" 6 | }, 7 | "files": [ 8 | "dist" 9 | ], 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "scripts": { 14 | "build": "rm -rf dist && babel --extensions \".js,.ts\" src --out-dir dist", 15 | "serve": "node dist/create-saruni-app.js", 16 | "dev": "yarn build && yarn serve", 17 | "prepublishOnly": "yarn build" 18 | }, 19 | "dependencies": { 20 | "axios": "^0.19.2", 21 | "chalk": "^4.0.0", 22 | "check-node-version": "^4.0.3", 23 | "decompress": "^4.2.1", 24 | "execa": "^4.0.1", 25 | "listr": "^0.14.3", 26 | "tmp": "^0.2.1" 27 | }, 28 | "devDependencies": { 29 | "@babel/cli": "^7.8.4", 30 | "@babel/core": "^7.9.6", 31 | "@babel/preset-env": "^7.9.6", 32 | "@babel/preset-typescript": "^7.9.0" 33 | }, 34 | "gitHead": "046fe47d958dc1f468ef558ff224446f20601c7b" 35 | } 36 | -------------------------------------------------------------------------------- /packages/create-saruni-app/src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "node": true 9 | } 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /packages/create-saruni-app/src/create-saruni-app.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import axios from 'axios'; 6 | import chalk from 'chalk'; 7 | import checkNodeVersion from 'check-node-version'; 8 | import decompress from 'decompress'; 9 | import execa from 'execa'; 10 | import Listr from 'listr'; 11 | import tmp from 'tmp'; 12 | 13 | const RELEASE_URL = 14 | 'https://api.github.com/repos/tambium/create-saruni-app/releases/latest'; 15 | 16 | const latestReleaseZipFile = async () => { 17 | const response = await axios.get(RELEASE_URL); 18 | return response.data.zipball_url; 19 | }; 20 | 21 | const downloadFile = async (sourceUrl, targetFile) => { 22 | const writer = fs.createWriteStream(targetFile); 23 | const response = await axios.get(sourceUrl, { 24 | responseType: 'stream', 25 | }); 26 | response.data.pipe(writer); 27 | 28 | return new Promise((resolve, reject) => { 29 | writer.on('finish', resolve); 30 | writer.on('error', reject); 31 | }); 32 | }; 33 | 34 | const targetDir = String(process.argv.slice(2)).replace(/,/g, '-'); 35 | if (!targetDir) { 36 | console.error('Please specify the project directory'); 37 | console.log( 38 | ` ${chalk.cyan('yarn create saruni-app')} ${chalk.green( 39 | '', 40 | )}`, 41 | ); 42 | console.log(); 43 | console.log('For example:'); 44 | console.log( 45 | ` ${chalk.cyan('yarn create saruni-app')} ${chalk.green('my-saruni-app')}`, 46 | ); 47 | process.exit(1); 48 | } 49 | 50 | const newAppDir = path.resolve(process.cwd(), targetDir); 51 | const appDirExists = fs.existsSync(newAppDir); 52 | 53 | if (appDirExists && fs.readdirSync(newAppDir).length > 0) { 54 | console.error( 55 | `The project directory you specified (${chalk.green( 56 | newAppDir, 57 | )}) already exists and is not empty. Please try again with a different project directory.`, 58 | ); 59 | process.exit(1); 60 | } 61 | 62 | const createProjectTasks = ({ newAppDir }) => { 63 | const tmpDownloadPath = tmp.tmpNameSync({ 64 | prefix: 'saruni', 65 | postfix: '.zip', 66 | }); 67 | 68 | return [ 69 | { 70 | title: `Creating a new Saruni app in ${chalk.green(newAppDir)}.`, 71 | task: () => { 72 | fs.mkdirSync(newAppDir, { recursive: true }); 73 | }, 74 | }, 75 | { 76 | title: 'Downloading latest release', 77 | task: async () => { 78 | const url = await latestReleaseZipFile(); 79 | return downloadFile(url, tmpDownloadPath); 80 | }, 81 | }, 82 | { 83 | title: 'Extracting latest release', 84 | task: () => decompress(tmpDownloadPath, newAppDir, { strip: 1 }), 85 | }, 86 | ]; 87 | }; 88 | 89 | const installNodeModulesTasks = ({ newAppDir }) => { 90 | return [ 91 | { 92 | title: 'Checking node and yarn compatibility', 93 | task: () => { 94 | return new Promise((resolve, reject) => { 95 | import(path.join(newAppDir, 'package.json')) 96 | .then(({ engines }) => { 97 | checkNodeVersion(engines, (_error, result) => { 98 | if (result.isSatisfied) { 99 | return resolve(); 100 | } 101 | 102 | const errors = Object.keys(result.versions).map((name) => { 103 | const { version, wanted } = result.versions[name]; 104 | return `${name} ${wanted} required, but you have ${version}.`; 105 | }); 106 | return reject(new Error(errors.join('\n'))); 107 | }); 108 | }) 109 | .catch((rejected) => reject(rejected)); 110 | }); 111 | }, 112 | }, 113 | { 114 | title: 'Installing packages. This might take a couple of minutes.', 115 | task: () => { 116 | return execa('yarn install', { 117 | shell: true, 118 | cwd: newAppDir, 119 | }); 120 | }, 121 | }, 122 | ]; 123 | }; 124 | 125 | const initializeProjectTasks = ({ newAppDir }) => { 126 | return [ 127 | { 128 | title: 'Initializing Git repository.', 129 | task: async () => { 130 | try { 131 | await execa('git', ['init'], { cwd: newAppDir }); 132 | } catch (error) { 133 | console.log(error); 134 | } 135 | }, 136 | }, 137 | { 138 | title: 'Adding changes to staging area.', 139 | task: async () => { 140 | try { 141 | await execa('git', ['add', '.'], { cwd: newAppDir }); 142 | } catch (error) { 143 | console.log(error); 144 | } 145 | }, 146 | }, 147 | { 148 | title: 'Capturing changes.', 149 | task: async () => { 150 | try { 151 | await execa( 152 | 'git', 153 | ['commit', '-m', 'Initialize project using Create Saruni App'], 154 | { cwd: newAppDir }, 155 | ); 156 | } catch (error) { 157 | console.log(error); 158 | } 159 | }, 160 | }, 161 | ]; 162 | }; 163 | 164 | new Listr([ 165 | { 166 | title: 'Creating Saruni app', 167 | task: () => new Listr(createProjectTasks({ newAppDir })), 168 | }, 169 | { 170 | title: 'Installing packages', 171 | task: () => new Listr(installNodeModulesTasks({ newAppDir })), 172 | }, 173 | { 174 | title: 'Initializing project', 175 | task: () => new Listr(initializeProjectTasks({ newAppDir })), 176 | }, 177 | ]) 178 | .run() 179 | .then(() => { 180 | console.log(); 181 | console.log(`Success! We've created your app in ${newAppDir}`); 182 | console.log(); 183 | console.log( 184 | "Inside that directory you can run `yarn sr dev` to start the development server." 185 | ); 186 | }) 187 | .catch((error) => { 188 | console.log(); 189 | console.log(error); 190 | process.exit(1); 191 | }); 192 | -------------------------------------------------------------------------------- /packages/dev-server/.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: '../../babel.config.js' }; 2 | -------------------------------------------------------------------------------- /packages/dev-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/dev-server", 3 | "version": "0.0.13", 4 | "files": [ 5 | "dist" 6 | ], 7 | "bin": { 8 | "ds": "./dist/index.js" 9 | }, 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "types": "dist/index.d.ts", 14 | "module": "src/index.ts", 15 | "main": "dist/index.js", 16 | "scripts": { 17 | "build": "rm -rf dist && babel --extensions \".js,.ts\" src --out-dir dist --plugins @babel/plugin-transform-modules-commonjs", 18 | "serve": "yarn node dist/index.js", 19 | "dev": "yarn build && yarn serve", 20 | "build:watch": "nodemon --watch src --ext 'js,ts,tsx' --ignore dist --exec 'yarn dev'", 21 | "release": "lerna publish from-package" 22 | }, 23 | "devDependencies": { 24 | "@babel/cli": "^7.8.4", 25 | "@babel/core": "^7.9.6", 26 | "@babel/plugin-proposal-class-properties": "^7.8.3", 27 | "@babel/plugin-transform-modules-commonjs": "^7.9.6", 28 | "@babel/preset-env": "^7.9.6", 29 | "@babel/preset-typescript": "^7.9.0", 30 | "@types/aws-lambda": "^8.10.51", 31 | "@types/body-parser": "^1.19.0", 32 | "@types/chokidar": "^2.1.3", 33 | "@types/cors": "^2.8.6", 34 | "@types/execa": "^2.0.0", 35 | "@types/express": "^4.17.6", 36 | "@types/node": "^14.0.5", 37 | "@types/require-dir": "^1.0.1" 38 | }, 39 | "dependencies": { 40 | "@babel/plugin-proposal-decorators": "^7.10.1", 41 | "@babel/register": "^7.10.5", 42 | "@saruni/internal": "^0.0.12", 43 | "body-parser": "^1.19.0", 44 | "chalk": "^4.1.0", 45 | "chokidar": "^3.4.0", 46 | "cors": "^2.8.5", 47 | "execa": "^4.0.2", 48 | "express": "^4.17.1", 49 | "fs-extra": "^9.0.1", 50 | "nodemon": "^2.0.4", 51 | "qs": "^6.9.4", 52 | "require-dir": "^1.2.0" 53 | }, 54 | "gitHead": "046fe47d958dc1f468ef558ff224446f20601c7b" 55 | } 56 | -------------------------------------------------------------------------------- /packages/dev-server/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import path from 'path'; 3 | import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; 4 | import type { Request, Response } from 'express'; 5 | import babelRequireHook from '@babel/register'; 6 | import bodyParser from 'body-parser'; 7 | import chalk from 'chalk'; 8 | import chokidar from 'chokidar'; 9 | import cors from 'cors'; 10 | import execa from 'execa'; 11 | import express from 'express'; 12 | import fs from 'fs-extra'; 13 | import qs from 'qs'; 14 | import requireDir from 'require-dir'; 15 | 16 | import { getPaths } from '@saruni/internal'; 17 | 18 | const saruniJson = require(getPaths().saruni); 19 | 20 | const CORS_SAFE_LIST = [ 21 | `http://localhost:${saruniJson.devServerPort.web}`, 22 | `http://localhost:${saruniJson.devServerPort.api}`, 23 | ]; 24 | 25 | process.env.STAGE = 'local'; 26 | 27 | babelRequireHook({ 28 | extends: path.join(getPaths().api.base, '.babelrc.js'), 29 | extensions: ['.js', '.ts'], 30 | only: [path.resolve(getPaths().api.base)], 31 | ignore: ['node_modules'], 32 | cache: false, 33 | }); 34 | 35 | const parseBody = (rawBody: string | Buffer) => { 36 | if (typeof rawBody === 'string') { 37 | return { body: rawBody, isBase64Encoded: false }; 38 | } 39 | 40 | if (rawBody instanceof Buffer) { 41 | return { body: rawBody.toString('base64'), isBase64Encoded: true }; 42 | } 43 | 44 | return { body: '', isBase64Encoded: false }; 45 | }; 46 | 47 | const lambdaEventForExpressRequest = ( 48 | request: Request, 49 | ): APIGatewayProxyEvent => { 50 | return { 51 | httpMethod: request.method, 52 | headers: request.headers, 53 | path: request.path, 54 | queryStringParameters: qs.parse(request.url.split(/\?(.+)/)[1]), 55 | requestContext: { 56 | identity: { 57 | sourceIp: request.ip, 58 | }, 59 | }, 60 | // adds `body` and `isBase64Encoded` 61 | ...parseBody(request.body), 62 | } as APIGatewayProxyEvent; 63 | }; 64 | 65 | const expressResponseForLambdaResult = ( 66 | expressResFn: Response, 67 | lambdaResult: APIGatewayProxyResult, 68 | ) => { 69 | const { statusCode = 200, headers, body = '' } = lambdaResult; 70 | if (headers) { 71 | Object.keys(headers).forEach((headerName) => { 72 | const headerValue: any = headers[headerName]; 73 | expressResFn.setHeader(headerName, headerValue); 74 | }); 75 | } 76 | expressResFn.statusCode = statusCode; 77 | // The AWS lambda docs specify that the response object must be 78 | // compatible with `JSON.stringify`, but the type definition specifices that 79 | // it must be a string. 80 | return expressResFn.end( 81 | typeof body === 'string' ? body : JSON.stringify(body), 82 | ); 83 | }; 84 | 85 | const expressResponseForLambdaError = ( 86 | expressResFn: Response, 87 | error: Error, 88 | ) => { 89 | console.error(error); 90 | 91 | expressResFn.status(500).send(error); 92 | }; 93 | 94 | const app = express(); 95 | 96 | app.use( 97 | bodyParser.text({ 98 | type: ['text/*', 'application/json', 'multipart/form-data'], 99 | limit: '50mb', 100 | }), 101 | ); 102 | 103 | app.use(bodyParser.json({ limit: '50mb' })); 104 | 105 | app.use(bodyParser.raw({ type: '*/*', limit: '50mb' })); 106 | 107 | app.use( 108 | cors({ 109 | credentials: true, 110 | origin: (origin, callback) => { 111 | if (CORS_SAFE_LIST.indexOf(origin!) !== -1 || !origin) { 112 | return callback(null, true); 113 | } else { 114 | return callback(new Error('Not allowed by CORS.')); 115 | } 116 | }, 117 | }), 118 | ); 119 | 120 | const graphqlWatcher = chokidar.watch(getPaths().web.graphql); 121 | 122 | let isGeneratingGraphqlFiles = false; 123 | 124 | graphqlWatcher.on('ready', () => { 125 | graphqlWatcher.on('change', async () => { 126 | try { 127 | if (!isGeneratingGraphqlFiles) { 128 | console.log( 129 | chalk.green('GraphQL schema files changed. Generating new files...'), 130 | ); 131 | 132 | isGeneratingGraphqlFiles = true; 133 | 134 | await execa('yarn', ['gen'], { cwd: getPaths().web.base }); 135 | 136 | console.log(chalk.green('New files have been generated for apollo.')); 137 | } 138 | } catch (error) { 139 | console.log(error); 140 | } finally { 141 | isGeneratingGraphqlFiles = false; 142 | } 143 | }); 144 | }); 145 | 146 | const WATCHER_IGNORE_EXTENSIONS = ['.db']; 147 | 148 | const apiWatcher = chokidar.watch(getPaths().api.base, { 149 | ignored: (file: string) => 150 | file.includes('node_modules') || 151 | WATCHER_IGNORE_EXTENSIONS.some((ext) => file.endsWith(ext)), 152 | }); 153 | 154 | const importFreshFunctions = async (functionsPath) => { 155 | Object.keys(require.cache).forEach((key) => { 156 | delete require.cache[key]; 157 | }); 158 | 159 | const services = await fs.readdir(functionsPath); 160 | 161 | return services 162 | .filter((item) => item !== '.keep') 163 | .map((item) => { 164 | return requireDir(path.resolve(functionsPath, item), { 165 | extensions: ['.js', '.ts'], 166 | }); 167 | }) 168 | .reduce((sum, item) => { 169 | return { ...sum, ...item }; 170 | }, {}); 171 | }; 172 | 173 | let functions; 174 | 175 | apiWatcher.on('ready', async () => { 176 | functions = await importFreshFunctions(path.resolve(getPaths().api.services)); 177 | 178 | apiWatcher.on('all', async (event) => { 179 | if (/add/.test(event)) { 180 | console.log('New file added. Rebuilding...'); 181 | functions = await importFreshFunctions( 182 | path.resolve(getPaths().api.services), 183 | ); 184 | console.log('New functions deployed.'); 185 | } 186 | 187 | if (/change/.test(event)) { 188 | console.log('Code change detected. Rebuilding...'); 189 | functions = await importFreshFunctions( 190 | path.resolve(getPaths().api.services), 191 | ); 192 | console.log('New functions deployed.'); 193 | } 194 | 195 | if (/unlink/.test(event)) { 196 | console.log('Some file deleted. Rebuilding...'); 197 | functions = await importFreshFunctions( 198 | path.resolve(getPaths().api.services), 199 | ); 200 | console.log('New functions deployed.'); 201 | } 202 | }); 203 | }); 204 | 205 | app.get('/', (_req, res) => { 206 | res.send(` 207 | 208 | 209 |
The following functions are available:
210 |
    211 | ${Object.entries(functions).map(([key]) => { 212 | return `
  • ${key}
  • `; 213 | })} 214 |
215 | 216 | 217 | `); 218 | }); 219 | 220 | const handlerCallback = (expressResFn: Response) => ( 221 | error: Error, 222 | lambdaResult: APIGatewayProxyResult, 223 | ) => { 224 | if (error) { 225 | return expressResponseForLambdaError(expressResFn, error); 226 | } 227 | return expressResponseForLambdaResult(expressResFn, lambdaResult); 228 | }; 229 | 230 | function isPromise(val: any): val is Promise { 231 | return val && val.then && typeof val.then === 'function'; 232 | } 233 | 234 | app.all('/:functionName', async (req, res) => { 235 | const fn = functions[req.params.functionName]; 236 | 237 | const event = lambdaEventForExpressRequest(req); 238 | 239 | const context = {}; 240 | 241 | if (fn && fn.handler && typeof fn.handler === 'function') { 242 | try { 243 | const lambdaPromise = fn.handler(event, context, handlerCallback(res)) as 244 | | APIGatewayProxyResult 245 | | Promise; 246 | 247 | if (isPromise(lambdaPromise)) { 248 | const result = await lambdaPromise; 249 | 250 | expressResponseForLambdaResult(res, result); 251 | } 252 | } catch (error) { 253 | expressResponseForLambdaError(res, error); 254 | } 255 | } else { 256 | res 257 | .status(500) 258 | .send('`handler` is not a function or the file does not exist.'); 259 | } 260 | }); 261 | 262 | app.listen(saruniJson.devServerPort.api, () => { 263 | console.log(`API server started on port: ${saruniJson.devServerPort.api}`); 264 | }); 265 | -------------------------------------------------------------------------------- /packages/email/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/email", 3 | "version": "0.0.6", 4 | "bin": { 5 | "saruni-serve-emails": "./dist/commands/serve.js" 6 | }, 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "module": "src/index.ts", 10 | "files": [ 11 | "dist" 12 | ], 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "scripts": { 17 | "build": "rm -rf dist && tsc" 18 | }, 19 | "dependencies": { 20 | "@saruni/internal": "^0.0.12", 21 | "chalk": "^4.1.0", 22 | "express": "^4.17.1", 23 | "fs-extra": "^9.0.1", 24 | "juice": "^7.0.0", 25 | "react": "^16.13.1", 26 | "react-dom": "^16.13.1", 27 | "terminal-link": "^2.1.1" 28 | }, 29 | "devDependencies": { 30 | "@tambium/typescript-configs": "^1.0.2", 31 | "@types/express": "^4.17.7", 32 | "@types/fs-extra": "^9.0.1", 33 | "@types/react": "^16.9.44", 34 | "@types/react-dom": "^16.9.8" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/email/src/commands/serve.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import path from 'path'; 3 | import express from 'express'; 4 | import chalk from 'chalk'; 5 | import terminalLink from 'terminal-link'; 6 | import { getPaths } from '@saruni/internal'; 7 | 8 | const server = express(); 9 | const PORT = 2000; 10 | 11 | (async () => { 12 | server.use( 13 | '/', 14 | express.static(path.resolve(getPaths().static.generatedEmails)), 15 | ); 16 | 17 | server.listen(PORT, async () => { 18 | console.log( 19 | `${chalk.green(`● saruni:serve:emails`)} listening -- url: ${chalk.green( 20 | terminalLink(`'localhost:${PORT}'`, `http://localhost:${PORT}`), 21 | )}`, 22 | ); 23 | }); 24 | })(); 25 | -------------------------------------------------------------------------------- /packages/email/src/index.ts: -------------------------------------------------------------------------------- 1 | export { createStatic } from './utils'; 2 | -------------------------------------------------------------------------------- /packages/email/src/utils/create-static.ts: -------------------------------------------------------------------------------- 1 | import ReactDOMServer from 'react-dom/server'; 2 | import juice from 'juice'; 3 | 4 | export const createStatic = (element: JSX.Element) => { 5 | return juice(ReactDOMServer.renderToStaticMarkup(element)); 6 | }; 7 | -------------------------------------------------------------------------------- /packages/email/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { createStatic } from './create-static'; 2 | -------------------------------------------------------------------------------- /packages/email/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tambium/typescript-configs/base.tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "outDir": "./dist", 6 | "types": ["node"] 7 | }, 8 | "include": ["./src"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/internal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/internal", 3 | "version": "0.0.12", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "module": "src/index.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "scripts": { 14 | "build": "rm -rf dist && tsc" 15 | }, 16 | "dependencies": { 17 | "dotenv": "^8.2.0", 18 | "findup-sync": "^4.0.0", 19 | "fs-extra": "^9.0.1" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^14.0.5", 23 | "typescript": "^3.9.3" 24 | }, 25 | "gitHead": "046fe47d958dc1f468ef558ff224446f20601c7b" 26 | } 27 | -------------------------------------------------------------------------------- /packages/internal/src/env/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import { getPaths } from '../paths'; 3 | 4 | config({ path: getPaths().env }); 5 | -------------------------------------------------------------------------------- /packages/internal/src/error/authentication.ts: -------------------------------------------------------------------------------- 1 | export class AuthenticationError extends Error { 2 | constructor() { 3 | super('Not authenticated'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/internal/src/error/authorization.ts: -------------------------------------------------------------------------------- 1 | export class AuthorizationError extends Error {} 2 | -------------------------------------------------------------------------------- /packages/internal/src/error/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthenticationError } from './authentication'; 2 | export { AuthorizationError } from './authorization'; 3 | -------------------------------------------------------------------------------- /packages/internal/src/index.ts: -------------------------------------------------------------------------------- 1 | import './env'; 2 | 3 | export * from './paths'; 4 | export * from './error'; 5 | -------------------------------------------------------------------------------- /packages/internal/src/paths/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import findUp from 'findup-sync'; 3 | import { readFile } from 'fs-extra'; 4 | 5 | const CONFIG_FILE_NAME = 'saruni.json'; 6 | const DOT_ENV = '.env'; 7 | const JEST_CONFIG = 'jest.config.js'; 8 | const SERVERLESS_YML = 'serverless.yml'; 9 | 10 | const PATH_API_DIR = 'packages/api'; 11 | const PATH_API_DIR_DB = 'packages/api/src/db'; 12 | const PATH_API_DIR_GRAPHQL = 'packages/api/src/graphql'; 13 | const PATH_API_DIR_PRISMA = 'packages/api/prisma'; 14 | const PATH_API_DIR_PRISMA_SCHEMA = 'packages/api/prisma/schema.prisma'; 15 | const PATH_API_DIR_SERVICES = 'packages/api/src/services'; 16 | const PATH_API_DIR_SRC = 'packages/api/src'; 17 | const PATH_STATIC_DIR = 'packages/static'; 18 | const PATH_STATIC_GENERATED_EMAILS = 'packages/static/emails/generated'; 19 | const PATH_STATIC_EMAILS_DIR = 'packages/static/emails'; 20 | const PATH_WEB_DIR = 'packages/web'; 21 | const PATH_WEB_DIR_COMPONENTS = 'packages/web/src/components'; 22 | const PATH_WEB_DIR_GRAPHQL = 'packages/web/src/graphql'; 23 | const PATH_WEB_DIR_LAYOUTS = 'packages/web/src/layouts'; 24 | const PATH_WEB_DIR_PAGES = 'packages/web/src/pages'; 25 | const PATH_WEB_DIR_SRC = 'packages/web/src'; 26 | const PATH_WEB_DIR_VIEWS = 'packages/web/src/views'; 27 | const PATH_SERVERLESS_DIR_RESOURCES = 'packages/api/src/resources'; 28 | 29 | async function filterSaruniDepsFromPackageJson(path) { 30 | const { dependencies } = JSON.parse(await readFile(path, 'utf8')); 31 | 32 | return Object.keys(dependencies).filter((depName) => 33 | depName.includes('@saruni'), 34 | ); 35 | } 36 | 37 | /** 38 | * Search the parent directories for the Saruni configuration file. 39 | */ 40 | export const getConfigPath = (): string => { 41 | const configPath = findUp(CONFIG_FILE_NAME); 42 | if (!configPath) { 43 | throw new Error( 44 | `Could not find a "${CONFIG_FILE_NAME}" file, are you sure you're in a Saruni project?`, 45 | ); 46 | } 47 | return configPath; 48 | }; 49 | 50 | /** 51 | * The Saruni config file is used as an anchor for the base directory of a project. 52 | */ 53 | export const getBaseDir = (configPath: string = getConfigPath()): string => { 54 | return path.dirname(configPath); 55 | }; 56 | 57 | /** 58 | * Path constants that are relevant to a Saruni project. 59 | */ 60 | export const getPaths = (BASE_DIR: string = getBaseDir()) => { 61 | return { 62 | base: BASE_DIR, 63 | saruni: path.join(BASE_DIR, CONFIG_FILE_NAME), 64 | env: path.join(BASE_DIR, DOT_ENV), 65 | sls: { 66 | base: path.join(BASE_DIR), 67 | yml: path.join(BASE_DIR, SERVERLESS_YML), 68 | resources: { 69 | base: path.join(BASE_DIR, PATH_SERVERLESS_DIR_RESOURCES), 70 | }, 71 | }, 72 | packagejson: path.join(BASE_DIR, 'package.json'), 73 | api: { 74 | base: path.join(BASE_DIR, PATH_API_DIR), 75 | db: path.join(BASE_DIR, PATH_API_DIR_DB), 76 | graphql: path.join(BASE_DIR, PATH_API_DIR_GRAPHQL), 77 | prisma: path.join(BASE_DIR, PATH_API_DIR_PRISMA), 78 | prismaSchema: path.join(BASE_DIR, PATH_API_DIR_PRISMA_SCHEMA), 79 | services: path.join(BASE_DIR, PATH_API_DIR_SERVICES), 80 | src: path.join(BASE_DIR, PATH_API_DIR_SRC), 81 | packagejson: path.join(BASE_DIR, PATH_API_DIR, 'package.json'), 82 | seedFile: path.join(BASE_DIR, PATH_API_DIR_DB, 'seed.ts'), 83 | jestConfig: path.join(BASE_DIR, PATH_API_DIR, JEST_CONFIG), 84 | }, 85 | static: { 86 | base: path.join(BASE_DIR, PATH_STATIC_DIR), 87 | emails: path.join(BASE_DIR, PATH_STATIC_EMAILS_DIR), 88 | generatedEmails: path.join(BASE_DIR, PATH_STATIC_GENERATED_EMAILS), 89 | }, 90 | web: { 91 | base: path.join(BASE_DIR, PATH_WEB_DIR), 92 | components: path.join(BASE_DIR, PATH_WEB_DIR_COMPONENTS), 93 | layouts: path.join(BASE_DIR, PATH_WEB_DIR_LAYOUTS), 94 | graphql: path.join(BASE_DIR, PATH_WEB_DIR_GRAPHQL), 95 | pages: path.join(BASE_DIR, PATH_WEB_DIR_PAGES), 96 | src: path.join(BASE_DIR, PATH_WEB_DIR_SRC), 97 | views: path.join(BASE_DIR, PATH_WEB_DIR_VIEWS), 98 | packagejson: path.join(BASE_DIR, PATH_WEB_DIR, 'package.json'), 99 | }, 100 | }; 101 | }; 102 | 103 | export const getSaruniPackages = async () => { 104 | return { 105 | root: await filterSaruniDepsFromPackageJson(getPaths().packagejson), 106 | api: await filterSaruniDepsFromPackageJson(getPaths().api.packagejson), 107 | web: await filterSaruniDepsFromPackageJson(getPaths().web.packagejson), 108 | }; 109 | }; 110 | -------------------------------------------------------------------------------- /packages/internal/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "compilerOptions": { 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "moduleResolution": "node", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/test", 3 | "version": "0.0.8", 4 | "files": [ 5 | "dist" 6 | ], 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "types": "dist/index.d.ts", 11 | "module": "src/index.ts", 12 | "main": "dist/index.js", 13 | "license": "MIT", 14 | "scripts": { 15 | "build": "rm -rf dist && tsc" 16 | }, 17 | "dependencies": { 18 | "@babel/register": "^7.10.5", 19 | "@prisma/client": "^2.5.0", 20 | "@saruni/api": "latest", 21 | "@saruni/internal": "^0.0.12", 22 | "msw": "^0.20.5" 23 | }, 24 | "devDependencies": { 25 | "@types/jest": "^26.0.4" 26 | }, 27 | "peerDependencies": { 28 | "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0", 29 | "jest": "^26.1.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/test/src/ApiTestContext/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import babelRequireHook from '@babel/register'; 3 | import { makeExecutableSchema } from '@saruni/api'; 4 | import { getPaths } from '@saruni/internal'; 5 | import { graphql, ExecutionResult } from 'graphql'; 6 | import type { PrismaClient } from '@prisma/client'; 7 | 8 | babelRequireHook({ 9 | extends: path.join(getPaths().api.base, '.babelrc.js'), 10 | extensions: ['.js', '.ts'], 11 | only: [path.resolve(getPaths().api.graphql)], 12 | ignore: ['node_modules'], 13 | cache: false, 14 | }); 15 | 16 | const { resolvers, typeDefs } = require(getPaths().api.graphql); 17 | 18 | interface TestContext { 19 | db: PrismaClient; 20 | executeGraphql: ( 21 | source: string, 22 | options?: { 23 | variables?: any; 24 | context?: any; 25 | }, 26 | ) => Promise; 27 | setGraphQLContext: (context: any) => void; 28 | } 29 | 30 | export const createApiTestContext = (db: PrismaClient): TestContext => { 31 | let mainContext = {}; 32 | 33 | const schema = makeExecutableSchema({ typeDefs, resolvers }); 34 | 35 | beforeAll(async () => {}); 36 | 37 | afterAll(async () => { 38 | // await db.queryRaw(`DROP SCHEMA IF EXISTS "public" CASCADE`); 39 | 40 | await db.$disconnect(); 41 | }); 42 | 43 | async function executeGraphql( 44 | source: string, 45 | options?: { variables: any; context: any }, 46 | ) { 47 | const extendedOptions: { variableValues?: any; contextValue } = { 48 | contextValue: mainContext, 49 | }; 50 | 51 | if (options?.variables) { 52 | extendedOptions.variableValues = options.variables; 53 | } 54 | 55 | if (options?.context) { 56 | extendedOptions.contextValue = { ...mainContext, ...options.context }; 57 | } 58 | 59 | const result = await graphql({ 60 | source, 61 | schema, 62 | ...extendedOptions, 63 | }); 64 | 65 | return result; 66 | } 67 | 68 | return { 69 | db, 70 | executeGraphql, 71 | setGraphQLContext: (ctx) => { 72 | mainContext = ctx; 73 | }, 74 | }; 75 | }; 76 | -------------------------------------------------------------------------------- /packages/test/src/WebTestContext/index.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandlersList } from 'msw/lib/types/setupWorker/glossary'; 2 | import { setupServer } from 'msw/node'; 3 | 4 | export const createWebTestContext = (handlers: RequestHandlersList) => { 5 | const worker = setupServer(...handlers); 6 | 7 | beforeAll(() => { 8 | worker.listen(); 9 | }); 10 | 11 | afterAll(() => { 12 | worker.close(); 13 | }); 14 | 15 | return { worker }; 16 | }; 17 | 18 | export const mockNextRouter = () => { 19 | return jest.mock('next/router', () => { 20 | const push = jest.fn(); 21 | 22 | const useRouter = jest.fn().mockImplementation(() => { 23 | return { 24 | push, 25 | }; 26 | }); 27 | return { 28 | useRouter, 29 | }; 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/test/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ApiTestContext'; 2 | export * from './WebTestContext'; 3 | -------------------------------------------------------------------------------- /packages/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "compilerOptions": { 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "moduleResolution": "node", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@saruni/web", 3 | "version": "0.0.11", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "files": [ 8 | "dist" 9 | ], 10 | "types": "dist/index.d.ts", 11 | "module": "src/index.ts", 12 | "main": "dist/index.js", 13 | "scripts": { 14 | "build": "rm -rf dist && tsc" 15 | }, 16 | "gitHead": "046fe47d958dc1f468ef558ff224446f20601c7b", 17 | "dependencies": { 18 | "@apollo/react-hooks": "^3.1.5", 19 | "@saruni/core": "^0.0.6", 20 | "@types/react-dom": "^16.9.8", 21 | "apollo-cache-inmemory": "^1.6.6", 22 | "apollo-client": "^2.6.10", 23 | "apollo-link": "^1.2.14", 24 | "apollo-link-error": "^1.1.13", 25 | "apollo-link-http": "^1.5.17", 26 | "graphql-tag": "^2.10.3", 27 | "isomorphic-unfetch": "^3.0.0", 28 | "next": "^9.4.4" 29 | }, 30 | "peerDependencies": { 31 | "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0", 32 | "react": ">=16.13.0" 33 | }, 34 | "devDependencies": { 35 | "@types/react": "^16.9.35" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/web/src/Apollo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ApolloProvider } from '@apollo/react-hooks'; 3 | import { getApiEndpoint } from '@saruni/core'; 4 | import { InMemoryCache, NormalizedCacheObject } from 'apollo-cache-inmemory'; 5 | import { ApolloClient } from 'apollo-client'; 6 | import { ApolloLink } from 'apollo-link'; 7 | import { HttpLink } from 'apollo-link-http'; 8 | import { onError } from 'apollo-link-error'; 9 | import fetch from 'isomorphic-unfetch'; 10 | 11 | interface GenerateApiProviderOptions { 12 | apolloClient?: ApolloClient; 13 | } 14 | 15 | export const generateApiProvider = (options?: GenerateApiProviderOptions) => { 16 | const { apolloClient } = options ?? {}; 17 | 18 | const httpLink = new HttpLink({ 19 | uri: getApiEndpoint().graphql, 20 | credentials: 'include', 21 | fetch, 22 | }); 23 | 24 | const errorLink = onError(({ graphQLErrors, networkError }) => { 25 | console.log(graphQLErrors); 26 | console.log(networkError); 27 | }); 28 | 29 | const client = new ApolloClient({ 30 | ssrMode: false, 31 | link: ApolloLink.from([ApolloLink.from([errorLink, httpLink])]), 32 | cache: new InMemoryCache(), 33 | }); 34 | 35 | const ApiProvider: React.FC = (props) => { 36 | return ( 37 | 38 | {props.children} 39 | 40 | ); 41 | }; 42 | 43 | return ApiProvider; 44 | }; 45 | -------------------------------------------------------------------------------- /packages/web/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useQuery, 3 | useMutation, 4 | useSubscription, 5 | useApolloClient, 6 | } from '@apollo/react-hooks'; 7 | export { default as gql } from 'graphql-tag'; 8 | 9 | export * from './Apollo'; 10 | 11 | export * from '@saruni/core'; 12 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "outDir": "./dist" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tasks/publish-local: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # This script republishes our packages to your local npm 5 | # registry (http://localhost:4873). 6 | # 7 | # Usage: 8 | # Publish a single package: ./tasks/local-publish ./packages/dev-server 9 | # Publish all the packages: ./tasks/local-publish 10 | 11 | if ! lsof -Pi :4873 -sTCP:LISTEN -t >/dev/null; then 12 | echo "Error: Verdaccio is not listening on port 4873, start it with './tasks/run-verdaccio'" 13 | exit 1 14 | fi 15 | 16 | 17 | if [ -z "$1" ] 18 | then 19 | # Publish all the packages 20 | for d in packages/*/ ; do 21 | ( cd "$d" && npm unpublish --tag dev --registry http://localhost:4873/ --force && npm publish --tag dev --registry http://localhost:4873/ --force ) 22 | done 23 | else 24 | # Publish a single package 25 | ( cd "$1" && npm unpublish --tag dev --registry http://localhost:4873/ --force && npm publish --tag dev --registry http://localhost:4873/ --force ) 26 | fi -------------------------------------------------------------------------------- /tasks/run-local-npm: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | verdaccio --config tasks/verdaccio.yml -------------------------------------------------------------------------------- /tasks/verdaccio.yml: -------------------------------------------------------------------------------- 1 | # path to a directory with all packages 2 | storage: ./.verdaccio 3 | 4 | web: 5 | title: Verdaccio 6 | 7 | auth: 8 | htpasswd: 9 | file: ./htpasswd 10 | uplinks: 11 | npmjs: 12 | url: https://registry.npmjs.org/ 13 | packages: 14 | '@saruni/*': 15 | access: $all 16 | publish: $all 17 | unpublish: $all 18 | 'create-saruni-app': 19 | access: $all 20 | publish: $all 21 | unpublish: $all 22 | '@*/*': 23 | # scoped packages 24 | access: $all 25 | publish: $all 26 | unpublish: $all 27 | proxy: npmjs 28 | '**': 29 | # allow all users (including non-authenticated users) to read and 30 | # publish all packages 31 | # 32 | # you can specify usernames/groupnames (depending on your auth plugin) 33 | # and three keywords: "$all", "$anonymous", "$authenticated" 34 | access: $all 35 | publish: $all 36 | unpublish: $all 37 | proxy: npmjs 38 | 39 | # You can specify HTTP/1.1 server keep alive timeout in seconds for incoming connections. 40 | # A value of 0 makes the http server behave similarly to Node.js versions prior to 8.0.0, which did not have a keep-alive timeout. 41 | # WORKAROUND: Through given configuration you can workaround following issue https://github.com/verdaccio/verdaccio/issues/301. Set to 0 in case 60 is not enough. 42 | server: 43 | keepAliveTimeout: 60 44 | 45 | middlewares: 46 | audit: 47 | enabled: true 48 | 49 | logs: 50 | - { type: stdout, format: pretty, level: http } 51 | --------------------------------------------------------------------------------