From c857f5859ac126c9c06378040c34fb3b9b920778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Mon, 13 Jan 2025 16:58:17 -0800 Subject: [PATCH] feat: new crystallize cli --- .github/dependabot.yml | 2 +- .github/workflows/cli-ci.yaml | 40 +++ .github/workflows/crystallize-cli-ci.yaml | 2 +- components/cli/.commitlintrc.json | 26 ++ .../cli/.github/PULL_REQUEST_TEMPLATE.md | 11 + components/cli/.github/issue_template.md | 9 + .../.github/workflows/auto-close-issue.yaml | 21 ++ .../cli/.github/workflows/auto-close-pr.yaml | 23 ++ components/cli/.github/workflows/release.yaml | 52 ++++ components/cli/.gitignore | 2 + components/cli/.prettierignore | 2 + components/cli/.prettierrc.json | 10 + components/cli/.vscode/settings.json | 59 ++++ components/cli/LICENSE | 21 ++ components/cli/Makefile | 38 +++ components/cli/README.md | 53 ++++ components/cli/bun.lockb | Bin 0 -> 105949 bytes components/cli/package.json | 46 ++++ components/cli/shim.ts | 7 + .../cli/src/command/install-boilerplate.tsx | 83 ++++++ components/cli/src/command/login.tsx | 38 +++ .../cli/src/command/run-mass-operation.tsx | 122 ++++++++ components/cli/src/command/whoami.tsx | 27 ++ components/cli/src/content/boilerplates.ts | 67 +++++ components/cli/src/content/static-tips.ts | 9 + .../src/core/create-credentials-retriever.ts | 94 +++++++ components/cli/src/core/create-flysystem.ts | 86 ++++++ components/cli/src/core/create-logger.ts | 121 ++++++++ components/cli/src/core/create-runner.ts | 34 +++ components/cli/src/core/create-s3-uploader.ts | 22 ++ components/cli/src/core/di.ts | 115 ++++++++ .../actions/download-project.tsx | 49 ++++ .../actions/execute-recipes.tsx | 129 +++++++++ .../install-boilerplate/create-store.ts | 165 +++++++++++ .../install-boilerplate.journey.tsx | 68 +++++ .../questions/select-boilerplate.tsx | 42 +++ .../questions/select-tenant.tsx | 134 +++++++++ components/cli/src/core/styles.ts | 6 + components/cli/src/domain/contracts/bus.ts | 14 + .../domain/contracts/credential-retriever.ts | 15 + .../cli/src/domain/contracts/fly-system.ts | 12 + components/cli/src/domain/contracts/logger.ts | 18 ++ .../contracts/models/authenticated-user.ts | 13 + .../domain/contracts/models/boilerplate.ts | 9 + .../domain/contracts/models/credentials.ts | 4 + .../cli/src/domain/contracts/models/tenant.ts | 3 + .../cli/src/domain/contracts/models/tip.ts | 5 + .../cli/src/domain/contracts/s3-uploader.ts | 4 + .../domain/use-cases/create-clean-tenant.ts | 79 ++++++ .../use-cases/download-boilerplate-archive.ts | 40 +++ .../cli/src/domain/use-cases/fetch-tips.ts | 101 +++++++ .../domain/use-cases/run-mass-operation.ts | 120 ++++++++ .../use-cases/setup-boilerplate-project.ts | 100 +++++++ components/cli/src/index.ts | 68 +++++ .../src/ui/components/boilerplate-choice.tsx | 24 ++ components/cli/src/ui/components/markdown.tsx | 21 ++ components/cli/src/ui/components/messages.tsx | 33 +++ components/cli/src/ui/components/select.tsx | 90 ++++++ .../src/ui/components/setup-credentials.tsx | 260 ++++++++++++++++++ components/cli/src/ui/components/spinner.tsx | 29 ++ components/cli/src/ui/components/success.tsx | 46 ++++ components/cli/src/ui/components/tips.tsx | 45 +++ components/cli/tsconfig.json | 28 ++ components/manifest.json | 6 +- 64 files changed, 3019 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/cli-ci.yaml create mode 100644 components/cli/.commitlintrc.json create mode 100644 components/cli/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 components/cli/.github/issue_template.md create mode 100644 components/cli/.github/workflows/auto-close-issue.yaml create mode 100644 components/cli/.github/workflows/auto-close-pr.yaml create mode 100644 components/cli/.github/workflows/release.yaml create mode 100644 components/cli/.gitignore create mode 100644 components/cli/.prettierignore create mode 100644 components/cli/.prettierrc.json create mode 100644 components/cli/.vscode/settings.json create mode 100644 components/cli/LICENSE create mode 100644 components/cli/Makefile create mode 100644 components/cli/README.md create mode 100755 components/cli/bun.lockb create mode 100644 components/cli/package.json create mode 100644 components/cli/shim.ts create mode 100644 components/cli/src/command/install-boilerplate.tsx create mode 100644 components/cli/src/command/login.tsx create mode 100644 components/cli/src/command/run-mass-operation.tsx create mode 100644 components/cli/src/command/whoami.tsx create mode 100644 components/cli/src/content/boilerplates.ts create mode 100644 components/cli/src/content/static-tips.ts create mode 100644 components/cli/src/core/create-credentials-retriever.ts create mode 100644 components/cli/src/core/create-flysystem.ts create mode 100644 components/cli/src/core/create-logger.ts create mode 100644 components/cli/src/core/create-runner.ts create mode 100644 components/cli/src/core/create-s3-uploader.ts create mode 100644 components/cli/src/core/di.ts create mode 100644 components/cli/src/core/journeys/install-boilerplate/actions/download-project.tsx create mode 100644 components/cli/src/core/journeys/install-boilerplate/actions/execute-recipes.tsx create mode 100644 components/cli/src/core/journeys/install-boilerplate/create-store.ts create mode 100644 components/cli/src/core/journeys/install-boilerplate/install-boilerplate.journey.tsx create mode 100644 components/cli/src/core/journeys/install-boilerplate/questions/select-boilerplate.tsx create mode 100644 components/cli/src/core/journeys/install-boilerplate/questions/select-tenant.tsx create mode 100644 components/cli/src/core/styles.ts create mode 100644 components/cli/src/domain/contracts/bus.ts create mode 100644 components/cli/src/domain/contracts/credential-retriever.ts create mode 100644 components/cli/src/domain/contracts/fly-system.ts create mode 100644 components/cli/src/domain/contracts/logger.ts create mode 100644 components/cli/src/domain/contracts/models/authenticated-user.ts create mode 100644 components/cli/src/domain/contracts/models/boilerplate.ts create mode 100644 components/cli/src/domain/contracts/models/credentials.ts create mode 100644 components/cli/src/domain/contracts/models/tenant.ts create mode 100644 components/cli/src/domain/contracts/models/tip.ts create mode 100644 components/cli/src/domain/contracts/s3-uploader.ts create mode 100644 components/cli/src/domain/use-cases/create-clean-tenant.ts create mode 100644 components/cli/src/domain/use-cases/download-boilerplate-archive.ts create mode 100644 components/cli/src/domain/use-cases/fetch-tips.ts create mode 100644 components/cli/src/domain/use-cases/run-mass-operation.ts create mode 100644 components/cli/src/domain/use-cases/setup-boilerplate-project.ts create mode 100644 components/cli/src/index.ts create mode 100644 components/cli/src/ui/components/boilerplate-choice.tsx create mode 100644 components/cli/src/ui/components/markdown.tsx create mode 100644 components/cli/src/ui/components/messages.tsx create mode 100644 components/cli/src/ui/components/select.tsx create mode 100644 components/cli/src/ui/components/setup-credentials.tsx create mode 100644 components/cli/src/ui/components/spinner.tsx create mode 100644 components/cli/src/ui/components/success.tsx create mode 100644 components/cli/src/ui/components/tips.tsx create mode 100644 components/cli/tsconfig.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 756535a..453ee18 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - package-ecosystem: "npm" - directory: "/components/crystallize-cli" + directory: "/components/cli" schedule: interval: "weekly" diff --git a/.github/workflows/cli-ci.yaml b/.github/workflows/cli-ci.yaml new file mode 100644 index 0000000..dc36408 --- /dev/null +++ b/.github/workflows/cli-ci.yaml @@ -0,0 +1,40 @@ +name: Crystallize CLI + +on: + push: + branches: ["main"] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build-and-test: + name: 🏗️ Build and Test + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: false + + - name: ⎔ Setup bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: 📥 Download deps + working-directory: components/cli + run: bun install --frozen-lockfile + + - name: 🔍 Valid commit message + working-directory: components/cli + if: ${{ github.event_name == 'pull_request' }} + run: bun commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + + - name: 💄 Prettier + working-directory: components/cli + run: bun prettier --check . + + - name: 📲 Test the builds + working-directory: components/cli + run: make build diff --git a/.github/workflows/crystallize-cli-ci.yaml b/.github/workflows/crystallize-cli-ci.yaml index acba5f4..6ab6660 100644 --- a/.github/workflows/crystallize-cli-ci.yaml +++ b/.github/workflows/crystallize-cli-ci.yaml @@ -1,4 +1,4 @@ -name: Crystallize CLI +name: Crystallize CLI (Legacy) on: push: diff --git a/components/cli/.commitlintrc.json b/components/cli/.commitlintrc.json new file mode 100644 index 0000000..d95f39d --- /dev/null +++ b/components/cli/.commitlintrc.json @@ -0,0 +1,26 @@ +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "header-max-length": [0, "never", 100], + "body-max-line-length": [2, "always", 400], + "type-enum": [ + 2, + "always", + [ + "build", + "chore", + "ci", + "docs", + "feature", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", + "wip" + ] + ] + } +} diff --git a/components/cli/.github/PULL_REQUEST_TEMPLATE.md b/components/cli/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ec99679 --- /dev/null +++ b/components/cli/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +Thanks for your pull request! We love contributions. + +However, this repository is what we call a "subtree split": a read-only copy of one directory of the main repository. It enables developers to depend on specific repository. + +If you want to contribute, you should instead open a pull request on the main repository: + +https://github.com/CrystallizeAPI/tools + +Thank you for your contribution! + +PS: if you haven't already, please add tests. diff --git a/components/cli/.github/issue_template.md b/components/cli/.github/issue_template.md new file mode 100644 index 0000000..4ecda20 --- /dev/null +++ b/components/cli/.github/issue_template.md @@ -0,0 +1,9 @@ +Thanks for reporting an issue! We love feedback. + +However, this repository is what we call a "subtree split": a read-only copy of one directory of the main repository. It enables developers to depend on specific repository. + +If you want to report or contribute, you should instead open your issue on the main repository: + +https://github.com/CrystallizeAPI/tools + +Thank you for your contribution! diff --git a/components/cli/.github/workflows/auto-close-issue.yaml b/components/cli/.github/workflows/auto-close-issue.yaml new file mode 100644 index 0000000..ad82143 --- /dev/null +++ b/components/cli/.github/workflows/auto-close-issue.yaml @@ -0,0 +1,21 @@ +on: + issues: + types: [opened, edited] + +jobs: + autoclose: + runs-on: ubuntu-latest + steps: + - name: Close Issue + uses: peter-evans/close-issue@v1 + with: + comment: | + Thanks for reporting an issue! We love feedback. + + However, this repository is what we call a "subtree split": a read-only copy of one directory of the main repository. It enables developers to depend on specific repository. + + If you want to report or contribute, you should instead open your issue on the main repository: + + https://github.com/CrystallizeAPI/tools + + Thank you for your contribution! diff --git a/components/cli/.github/workflows/auto-close-pr.yaml b/components/cli/.github/workflows/auto-close-pr.yaml new file mode 100644 index 0000000..a720228 --- /dev/null +++ b/components/cli/.github/workflows/auto-close-pr.yaml @@ -0,0 +1,23 @@ +on: + pull_request: + types: [opened, edited, reopened] + +jobs: + autoclose: + runs-on: ubuntu-latest + steps: + - name: Close Pull Request + uses: peter-evans/close-pull@v1 + with: + comment: | + Thanks for your pull request! We love contributions. + + However, this repository is what we call a "subtree split": a read-only copy of one directory of the main repository. It enables developers to depend on specific repository. + + If you want to contribute, you should instead open a pull request on the main repository: + + https://github.com/CrystallizeAPI/tools + + Thank you for your contribution! + + PS: if you haven't already, please add tests. diff --git a/components/cli/.github/workflows/release.yaml b/components/cli/.github/workflows/release.yaml new file mode 100644 index 0000000..5b6165a --- /dev/null +++ b/components/cli/.github/workflows/release.yaml @@ -0,0 +1,52 @@ +on: + push: + tags: + - '*' + +name: Release a New Version + +jobs: + releaseandpublish: + name: Release on Github + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: false + + - name: ⎔ Setup bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: 📥 Download deps + working-directory: components/cli + run: bun install --frozen-lockfile + + - name: 🔨 Compiling the different versions + working-directory: components/cli + run: make build-all + + - name: 🏷 Create GitHub Release + run: | + TAG_MESSAGE=$(gh api repos/${{ github.repository }}/git/tags/${GITHUB_SHA} --jq '.message' || git log -1 --pretty=format:%B $GITHUB_SHA) + TAG_NAME=$(gh api repos/${{ github.repository }}/git/tags/${GITHUB_SHA} --jq '.tag') + gh release create "${TAG_NAME}" --title "Release ${TAG_NAME}" --notes "${TAG_MESSAGE}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 🚀 Upload Assets + run: | + ASSET_PLATFORMS=("bun-linux-x64 bun-linux-arm64 bun-windows-x64 bun-darwin-x64 bun-darwin-arm64") + for platform in "${ASSET_PLATFORMS[@]}"; do + if [ -f "crystallize-$platform" ]; then + gh release upload "${GITHUB_REF_NAME} ($platform)" "crystallize-$platform" --clobber + echo "Uploaded file for platform $platform" + else + echo "File for platform $platform not found, skipping." + fi + done + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/components/cli/.gitignore b/components/cli/.gitignore new file mode 100644 index 0000000..c66a58a --- /dev/null +++ b/components/cli/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +crystallize diff --git a/components/cli/.prettierignore b/components/cli/.prettierignore new file mode 100644 index 0000000..a56a7ef --- /dev/null +++ b/components/cli/.prettierignore @@ -0,0 +1,2 @@ +node_modules + diff --git a/components/cli/.prettierrc.json b/components/cli/.prettierrc.json new file mode 100644 index 0000000..6efc071 --- /dev/null +++ b/components/cli/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "tabWidth": 4, + "semi": true, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120 +} diff --git a/components/cli/.vscode/settings.json b/components/cli/.vscode/settings.json new file mode 100644 index 0000000..b75aca6 --- /dev/null +++ b/components/cli/.vscode/settings.json @@ -0,0 +1,59 @@ +{ + "editor.detectIndentation": false, + "editor.tabSize": 4, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "typescript.suggest.paths": true, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/node_modules": true, + "**/.contentlayer": true, + "**/build": true, + "**/dist": true, + "**/.next": true, + "**/.astro": true, + "**/.turbo": true, + "**/.wrangler": true, + "**/.cache": true, + "**/.react-router": true + }, + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/*/**": true, + "**/dist/*/**": true, + "**/build/*/**": true, + "**/.astro/*/**": true, + "**/.wrangler/*/**": true, + "**/coverage/*/**": true, + "**/tmp/*/**": true, + "**/.nx/*/**": true, + "**/.run/*/**": true + }, + "todo-tree.general.tags": ["@todo:", "@fixme:", "@bug:"], + "todo-tree.general.statusBar": "top three", + "todo-tree.tree.groupedByTag": true, + "todo-tree.general.statusBarClickBehaviour": "reveal", + "todo-tree.general.tagGroups": { + "TODOs": ["@todo:"], + "BUGs": ["@fixme:", "@bug:"] + }, + "todo-tree.highlights.customHighlight": { + "TODOs": { + "icon": "flame", + "foreground": "#ff9900" + }, + "BUGs": { + "icon": "bug", + "foreground": "#ff0000" + } + }, + "files.associations": { + "**/web/**/*.css": "tailwindcss" + } +} diff --git a/components/cli/LICENSE b/components/cli/LICENSE new file mode 100644 index 0000000..2566570 --- /dev/null +++ b/components/cli/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2025 Crystallize, https://crystallize.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/components/cli/Makefile b/components/cli/Makefile new file mode 100644 index 0000000..7869be8 --- /dev/null +++ b/components/cli/Makefile @@ -0,0 +1,38 @@ +# === Makefile Helper === + +# Styles +YELLOW=$(shell echo "\033[00;33m") +RED=$(shell echo "\033[00;31m") +RESTORE=$(shell echo "\033[0m") + +.DEFAULT_GOAL := list + +.PHONY: list +list: + @echo "******************************" + @echo "${YELLOW}Available targets${RESTORE}:" + @grep -E '^[a-zA-Z-]+:.*?## .*$$' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " ${YELLOW}%-15s${RESTORE} > %s\n", $$1, $$2}' + @echo "${RED}==============================${RESTORE}" + +.PHONY: codeclean +codeclean: ## Code Clean + @bun prettier --write . + +.PHONY: build +build: ## Build + @bun build --bundle src/index.ts --outfile crystallize.js --target=bun + @bun shim.ts + @bun build --compile --minify crystallize.js --outfile crystallize + @rm crystallize.js + @rm -f ./.*.bun-build + + +.PHONY: build-all +build-all: + @bun build --bundle src/index.ts --outfile crystallize.js --target=bun + @bun shim.ts + for target in bun-linux-x64 bun-linux-arm64 bun-windows-x64 bun-darwin-x64 bun-darwin-arm64; do \ + bun build --compile --minify crystallize.js --outfile crystallize-$$target --target=$$target; \ + done + @rm crystallize.js + @rm -f ./.*.bun-build diff --git a/components/cli/README.md b/components/cli/README.md new file mode 100644 index 0000000..e75de0c --- /dev/null +++ b/components/cli/README.md @@ -0,0 +1,53 @@ +# Crystallize CLI + +--- + +This repository is what we call a "subtree split": a read-only copy of one directory of the main repository. + +If you want to report or contribute, you should do it on the main repository: https://github.com/CrystallizeAPI/tools + +--- + +## Help + +```bash +npx @crystallize/cli-next@latest --help +``` + +## Features + +### Install a new project based on a Boilerplate + +```bash +npx @crystallize/cli-next@latest install ~/my-projects/my-ecommerce +``` + +This will create a new folder, download the boilerplate and `npm install` it. + +```bash +npx @crystallize/cli-next@latest install ~/my-projects/my-ecommerce --bootstrap-tenant +``` + +This will do the same as the previous command but it will create a new Tenant with clean data from the Boilerplate. + +### Dump a tenant + +```bash +npx @crystallize/cli-next@latest dump ~/my-projects/mydumpfolder tenantIdentifier +``` + +This is dumping a Tenant. + +### Install a new project based on a Boilerplate + +```bash +npx @crystallize/cli-next@latest import ~/my-projects/mydumpfolder/spec.json aNewTenant +``` + +This is importing a Tenant based on a dump + +### More Options + +```bash +npx @crystallize/cli-next@latest --help +``` diff --git a/components/cli/bun.lockb b/components/cli/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..d83bd087eafc8d01520261c6806ffaf2b0800f57 GIT binary patch literal 105949 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 aeO1?%D~Wv37xeNIPJdy(DEQy-zyAa9xU_Zv literal 0 HcmV?d00001 diff --git a/components/cli/package.json b/components/cli/package.json new file mode 100644 index 0000000..a416907 --- /dev/null +++ b/components/cli/package.json @@ -0,0 +1,46 @@ +{ + "name": "@crystallize/cli", + "version": "5.0.0", + "description": "Crystallize CLI", + "module": "src/index.ts", + "repository": "https://github.com/CrystallizeAPI/crystallize-cli", + "license": "MIT", + "contributors": [ + "Sébastien Morel " + ], + "type": "module", + "peerDependencies": { + "typescript": "^5.7.2" + }, + "devDependencies": { + "@commitlint/cli": "^19.6.1", + "@commitlint/config-conventional": "^19.6.0", + "@types/bun": "latest", + "@types/marked": "^6.0.0", + "@types/marked-terminal": "^6.1.1", + "@types/react": "^18", + "@types/signale": "^1.4.7", + "react-devtools-core": "^6.0.1", + "prettier": "^3.4.2" + }, + "dependencies": { + "@crystallize/js-api-client": "^4.1.0", + "@crystallize/schema": "^3.0.2", + "awilix": "^12.0.4", + "cli-spinners": "^3.2.0", + "commander": "^13.0.0", + "ink": "^5.1.0", + "ink-link": "^4.1.0", + "ink-text-input": "^6.0.0", + "jotai": "^2.11.0", + "json-to-graphql-query": "^2.3.0", + "marked": "^15.0.6", + "marked-terminal": "^7.2.1", + "meow": "^13.2.0", + "missive.js": "^0.5.0", + "picocolors": "^1.1.1", + "react": "^18", + "signale": "^1.4.0", + "tar": "^7.4.3" + } +} diff --git a/components/cli/shim.ts b/components/cli/shim.ts new file mode 100644 index 0000000..af7b872 --- /dev/null +++ b/components/cli/shim.ts @@ -0,0 +1,7 @@ +// Workaround to compile properly the yoga.wasm... +const bin = Bun.file('crystallize.js'); +let content = await new Response(bin).text(); +const pattern = /var Yoga = await initYoga\(await E\(_\(import\.meta\.url\)\.resolve\("\.\/yoga\.wasm"\)\)\);/g; +const replacement = `import Yoga from 'yoga-wasm-web/dist/yoga.wasm';`; +content = content.replace(pattern, replacement); +await Bun.write('crystallize.js', content); diff --git a/components/cli/src/command/install-boilerplate.tsx b/components/cli/src/command/install-boilerplate.tsx new file mode 100644 index 0000000..3bda9bb --- /dev/null +++ b/components/cli/src/command/install-boilerplate.tsx @@ -0,0 +1,83 @@ +import { Newline, render } from 'ink'; +import { Box, Text } from 'ink'; +import { InstallBoilerplateJourney } from '../core/journeys/install-boilerplate/install-boilerplate.journey'; +import { Argument, Command, Option } from 'commander'; +import { boilerplates } from '../content/boilerplates'; +import type { FlySystem } from '../domain/contracts/fly-system'; +import type { InstallBoilerplateStore } from '../core/journeys/install-boilerplate/create-store'; +import { Provider } from 'jotai'; +import type { CredentialRetriever } from '../domain/contracts/credential-retriever'; +import type { Logger } from '../domain/contracts/logger'; +import type { QueryBus } from '../domain/contracts/bus'; + +type Deps = { + logLevels: ('info' | 'debug')[]; + flySystem: FlySystem; + installBoilerplateCommandStore: InstallBoilerplateStore; + credentialsRetriever: CredentialRetriever; + logger: Logger; + queryBus: QueryBus; +}; + +export const createInstallBoilerplateCommand = ({ + logger, + flySystem, + installBoilerplateCommandStore, + credentialsRetriever, + queryBus, + logLevels, +}: Deps): Command => { + const command = new Command('install-boilerplate'); + command.description('Install a boilerplate into a folder.'); + command.addArgument(new Argument('', 'The folder to install the boilerplate into.')); + command.addArgument(new Argument('[tenant-identifier]', 'The tenant identifier to use.')); + command.addArgument(new Argument('[boilerplate-identifier]', 'The boilerplate identifier to use.')); + command.addOption(new Option('-b, --bootstrap-tenant', 'Bootstrap the tenant with initial data.')); + + command.action(async (...args) => { + const [folder, tenantIdentifier, boilerplateIdentifier, flags] = args; + logger.setBuffered(true); + await flySystem.createDirectoryOrFail( + folder, + `Please provide an empty folder to install the boilerplate into.`, + ); + const boilerplate = boilerplates.find((boiler) => boiler.identifier === boilerplateIdentifier); + const { storage, atoms } = installBoilerplateCommandStore; + + storage.set(atoms.setFolderAtom, folder); + + if (boilerplate) { + storage.set(atoms.setBoilerplateAtom, boilerplate); + } + + if (tenantIdentifier) { + storage.set(atoms.setTenantAtom, { identifier: tenantIdentifier }); + } + + storage.set(atoms.setBootstrapTenantAtom, !!flags.bootstrapTenant); + storage.set(atoms.setVerbosity, logLevels.length > 0); + + logger.log('Starting install boilerplate journey.'); + const { waitUntilExit } = render( + + + + Hi you, let's make something awesome! + + + + + + , + { + exitOnCtrlC: true, + }, + ); + await waitUntilExit(); + }); + return command; +}; diff --git a/components/cli/src/command/login.tsx b/components/cli/src/command/login.tsx new file mode 100644 index 0000000..5d9b01c --- /dev/null +++ b/components/cli/src/command/login.tsx @@ -0,0 +1,38 @@ +import type { CredentialRetriever } from '../domain/contracts/credential-retriever'; +import type { Logger } from '../domain/contracts/logger'; +import { Command } from 'commander'; +import { Box, render } from 'ink'; +import { SetupCredentials } from '../ui/components/setup-credentials'; +import type { PimAuthenticatedUser } from '../domain/contracts/models/authenticated-user'; + +type Deps = { + credentialsRetriever: CredentialRetriever; + logger: Logger; +}; + +export const createLoginCommand = ({ logger, credentialsRetriever }: Deps): Command => { + const command = new Command('login'); + command.description('Check and propose to setup your Crystallize credentials.'); + command.action(async () => { + logger.setBuffered(true); + const { waitUntilExit, unmount } = render( + + + { + logger.log(`Setting up credentials for ${authenticatedUser.email}`); + unmount(); + }} + /> + + , + { + exitOnCtrlC: true, + }, + ); + await waitUntilExit(); + }); + logger.flush(); + return command; +}; diff --git a/components/cli/src/command/run-mass-operation.tsx b/components/cli/src/command/run-mass-operation.tsx new file mode 100644 index 0000000..4c7ff9e --- /dev/null +++ b/components/cli/src/command/run-mass-operation.tsx @@ -0,0 +1,122 @@ +import { Argument, Command } from 'commander'; +import type { Logger } from '../domain/contracts/logger'; +import type { CommandBus } from '../domain/contracts/bus'; +import type { Operation, Operations } from '@crystallize/schema/mass-operation'; +import type { CredentialRetriever } from '../domain/contracts/credential-retriever'; +import { createClient } from '@crystallize/js-api-client'; +import pc from 'picocolors'; +import { ZodError } from 'zod'; + +type Deps = { + logger: Logger; + commandBus: CommandBus; + credentialsRetriever: CredentialRetriever; +}; + +export const createRunMassOperationCommand = ({ logger, commandBus, credentialsRetriever }: Deps): Command => { + const command = new Command('run-mass-operation'); + command.description('Upload and start an Mass Operation Task in your tenant.'); + command.addArgument(new Argument('', 'The tenant identifier to use.')); + command.addArgument(new Argument('', 'The file that contains the Operations.')); + command.option('--token_id ', 'Your access token id.'); + command.option('--token_secret ', 'Your access token secret.'); + command.option('--legacy-spec', 'Use legacy spec format.'); + + command.action(async (...args) => { + const [tenantIdentifier, file, flags] = args; + let operationsContent: Operations; + if (flags.legacySpec) { + logger.warn(`Using legacy spec... Converting to operations file...`); + const spec = await Bun.file(file).json(); + operationsContent = { + version: '0.0.1', + operations: (spec.shapes || []).map((shape: any): Operation => { + return { + intent: 'shape/upsert', + identifier: shape.identifier, + type: shape.type, + name: shape.name, + components: shape.components.map((component: any) => { + return { + id: component.id, + name: component.name, + description: '', + type: component.type, + config: component.config || {}, + }; + }), + }; + }), + }; + } else { + operationsContent = await Bun.file(file).json(); + logger.note(`Operations file found.`); + } + + try { + const credentials = await credentialsRetriever.getCredentials({ + token_id: flags.token_id, + token_secret: flags.token_secret, + }); + const authenticatedUser = await credentialsRetriever.checkCredentials(credentials); + if (!authenticatedUser) { + throw new Error( + 'Credentials are invalid. Please run `crystallize login` to setup your credentials or provide correct credentials.', + ); + } + const intent = commandBus.createCommand('RunMassOperation', { + tenantIdentifier, + operations: operationsContent, + credentials, + }); + const { result } = await commandBus.dispatch(intent); + + let startedTask = result?.task; + if (!startedTask) { + throw new Error('Task not started. Please check the logs for more information.'); + } + + const crystallizeClient = createClient({ + tenantIdentifier, + accessTokenId: credentials.ACCESS_TOKEN_ID, + accessTokenSecret: credentials.ACCESS_TOKEN_SECRET, + }); + + logger.info(`Now, Waiting for task to complete...`); + while (startedTask.status !== 'complete') { + logger.info(`Task status: ${pc.yellow(startedTask.status)}`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const get = await crystallizeClient.nextPimApi(getMassOperationBulkTask, { id: startedTask.id }); + if (get.bulkTask.error) { + throw new Error(get.data.bulkTask.error); + } + startedTask = get.bulkTask; + } + logger.success(`Task completed successfully. Task ID: ${pc.yellow(startedTask.id)}`); + } catch (error) { + if (error instanceof ZodError) { + for (const issue of error.issues) { + logger.error(`[${issue.path.join('.')}]: ${issue.message}`); + } + process.exit(1); + } + throw error; + } + }); + return command; +}; + +const getMassOperationBulkTask = `#graphql +query GET($id:ID!) { + bulkTask(id:$id) { + ... on BulkTaskMassOperation { + id + type + status + key + } + ... on BasicError { + error: message + } + } +}`; diff --git a/components/cli/src/command/whoami.tsx b/components/cli/src/command/whoami.tsx new file mode 100644 index 0000000..bb793e6 --- /dev/null +++ b/components/cli/src/command/whoami.tsx @@ -0,0 +1,27 @@ +import type { CredentialRetriever } from '../domain/contracts/credential-retriever'; +import type { Logger } from '../domain/contracts/logger'; +import { Command } from 'commander'; + +type Deps = { + credentialsRetriever: CredentialRetriever; + logger: Logger; +}; + +export const createWhoAmICommand = ({ logger, credentialsRetriever }: Deps): Command => { + const command = new Command('whoami'); + command.description('Check your Crystallize credentials are valid.'); + command.action(async () => { + try { + const credentials = await credentialsRetriever.getCredentials(); + const authenticatedUser = await credentialsRetriever.checkCredentials(credentials); + if (authenticatedUser) { + logger.success(`You are authenticated as ${authenticatedUser.email}`); + } else { + logger.warn('Credentials are invalid. Please run `crystallize login` to setup your credentials.'); + } + } catch { + logger.warn('No credentials found. Please run `crystallize login` to setup your credentials.'); + } + }); + return command; +}; diff --git a/components/cli/src/content/boilerplates.ts b/components/cli/src/content/boilerplates.ts new file mode 100644 index 0000000..43f27a4 --- /dev/null +++ b/components/cli/src/content/boilerplates.ts @@ -0,0 +1,67 @@ +import type { Boilerplate } from '../domain/contracts/models/boilerplate'; + +export const boilerplates: Boilerplate[] = [ + { + identifier: 'furniture-remix', + name: 'Furniture Remix Run', + baseline: 'Complete ecommerce using Remix Run', + description: 'React, SSR, Ecommerce with basket & checkout, promotions, Payments (many), etc.', + demo: 'https://furniture.superfast.store', + blueprint: 'frntr-blueprint', + git: 'https://github.com/CrystallizeAPI/furniture-remix', + }, + { + identifier: 'furniture-nextjs', + name: 'Furniture Next.js', + baseline: 'Complete ecommerce using Next.js', + description: 'React, SSR, Ecommerce with basket & checkout, promotions, Payments (many), etc.', + demo: 'https://furniture.superfast.store', + blueprint: 'frntr-blueprint', + git: 'https://github.com/CrystallizeAPI/furniture-nextjs', + }, + { + identifier: 'product-configurator', + name: 'Next.js Product Configurator', + baseline: 'Product Configurator boilerplate using Next.js', + description: 'Ecommerce product configurator with basket & checkout.', + demo: 'https://product-configurator.superfast.shop/', + blueprint: 'product-configurator', + git: 'https://github.com/CrystallizeAPI/product-configurator', + }, + { + identifier: 'dounut-remix', + name: 'Dounut Remix', + baseline: 'Ecommerce boilerplate using Remix', + description: 'Minimal eCommerce boilerplate built using Remix, Tailwind, and Crystallize.', + demo: 'https://dounot.milliseconds.live/', + blueprint: 'dounot', + git: 'https://github.com/CrystallizeAPI/product-storytelling-examples', + }, + { + identifier: 'dounut-svelte', + name: 'Dounut Svelte', + baseline: 'Ecommerce boilerplate using SvelteKit', + description: 'Minimal eCommerce boilerplate built using SvelteKit, Houdini, Tailwind, and Crystallize.', + demo: 'https://dounut-svelte.vercel.app/', + blueprint: 'dounot', + git: 'https://github.com/CrystallizeAPI/dounut-svelte', + }, + { + identifier: 'dounut-astro', + name: 'Dounut Astro', + baseline: 'Ecommerce boilerplate using Astro', + description: 'Minimal eCommerce boilerplate built using Astro, Tailwind, and Crystallize.', + demo: 'https://dounut-astro.vercel.app/', + blueprint: 'dounot', + git: 'https://github.com/CrystallizeAPI/dounut-astro', + }, + { + identifier: 'conference', + name: 'Conference Next.js', + baseline: 'Conference website using Next.js', + description: 'Conference website boilerplate using Next.js and Crystallize.', + demo: 'https://conference.superfast.shop/', + blueprint: 'conference-boilerplate', + git: 'https://github.com/CrystallizeAPI/conference-boilerplate', + }, +]; diff --git a/components/cli/src/content/static-tips.ts b/components/cli/src/content/static-tips.ts new file mode 100644 index 0000000..d3f7866 --- /dev/null +++ b/components/cli/src/content/static-tips.ts @@ -0,0 +1,9 @@ +import type { Tip } from '../domain/contracts/models/tip'; + +export const staticTips: Tip[] = [ + { + title: 'Want to learn more about Crystallize? Check out', + url: 'https://crystallize.com/learn', + type: '', + }, +]; diff --git a/components/cli/src/core/create-credentials-retriever.ts b/components/cli/src/core/create-credentials-retriever.ts new file mode 100644 index 0000000..8472283 --- /dev/null +++ b/components/cli/src/core/create-credentials-retriever.ts @@ -0,0 +1,94 @@ +import { createClient } from '@crystallize/js-api-client'; +import type { CredentialRetriever, CredentialRetrieverOptions } from '../domain/contracts/credential-retriever'; +import type { PimCredentials } from '../domain/contracts/models/credentials'; +import type { FlySystem } from '../domain/contracts/fly-system'; +import os from 'os'; + +type Deps = { + options?: CredentialRetrieverOptions; + fallbackFile: string; + flySystem: FlySystem; +}; +export const createCredentialsRetriever = ({ options, fallbackFile, flySystem }: Deps): CredentialRetriever => { + const getCredentials = async (rOptions?: CredentialRetrieverOptions) => { + if (rOptions?.token_id && rOptions?.token_secret) { + return { + ACCESS_TOKEN_ID: rOptions.token_id, + ACCESS_TOKEN_SECRET: rOptions.token_secret, + }; + } + + if (options?.token_id && options?.token_secret) { + return { + ACCESS_TOKEN_ID: options.token_id, + ACCESS_TOKEN_SECRET: options.token_secret, + }; + } + + if (Bun.env.CRYSTALLIZE_ACCESS_TOKEN_ID && Bun.env.CRYSTALLIZE_ACCESS_TOKEN_SECRET) { + return { + ACCESS_TOKEN_ID: Bun.env.CRYSTALLIZE_ACCESS_TOKEN_ID, + ACCESS_TOKEN_SECRET: Bun.env.CRYSTALLIZE_ACCESS_TOKEN_SECRET, + }; + } + + if (!(await Bun.file(fallbackFile).exists())) { + throw new Error('No file credentials found: ' + fallbackFile); + } + + try { + const { ACCESS_TOKEN_ID, ACCESS_TOKEN_SECRET } = await Bun.file(fallbackFile).json(); + + if (ACCESS_TOKEN_ID && ACCESS_TOKEN_SECRET) { + return { + ACCESS_TOKEN_ID, + ACCESS_TOKEN_SECRET, + }; + } + } catch (error) { + throw new Error('No credentials found. File is malformed.'); + } + throw new Error('No credentials found. File is missing ACCESS_TOKEN_ID or ACCESS_TOKEN_SECRET.'); + }; + + const checkCredentials = async (credentials: PimCredentials) => { + const apiClient = createClient({ + tenantIdentifier: '', + accessTokenId: credentials.ACCESS_TOKEN_ID, + accessTokenSecret: credentials.ACCESS_TOKEN_SECRET, + }); + const result = await apiClient + .pimApi('{ me { id firstName lastName email tenants { tenant { id identifier name } } } }') + .catch(() => {}); + return result?.me ?? undefined; + }; + + const removeCredentials = async () => { + await flySystem.removeFile(fallbackFile); + }; + + const saveCredentials = async (credentials: PimCredentials) => { + await flySystem.makeDirectory(`${os.homedir()}/.crystallize`); + await flySystem.saveFile(fallbackFile, JSON.stringify(credentials)); + }; + + const fetchAvailableTenantIdentifier = async (credentials: PimCredentials, identifier: string) => { + const apiClient = createClient({ + tenantIdentifier: '', + accessTokenId: credentials.ACCESS_TOKEN_ID, + accessTokenSecret: credentials.ACCESS_TOKEN_SECRET, + }); + const result = await apiClient.pimApi( + `query { tenant { suggestIdentifier ( desired: "${identifier}" ) { suggestion } } }`, + ); + return result.tenant?.suggestIdentifier?.suggestion || identifier; + }; + + return { + getCredentials, + checkCredentials, + removeCredentials, + saveCredentials, + fetchAvailableTenantIdentifier, + }; +}; diff --git a/components/cli/src/core/create-flysystem.ts b/components/cli/src/core/create-flysystem.ts new file mode 100644 index 0000000..6d2816a --- /dev/null +++ b/components/cli/src/core/create-flysystem.ts @@ -0,0 +1,86 @@ +import { readdir, mkdir, unlink, writeFile } from 'node:fs/promises'; +import type { FlySystem } from '../domain/contracts/fly-system'; +import type { Logger } from '../domain/contracts/logger'; + +type Deps = { + logger: Logger; +}; +export const createFlySystem = ({ logger }: Deps): FlySystem => { + const isDirectoryEmpty = async (path: string): Promise => { + try { + const files = await readdir(path); + return files.length === 0; + } catch { + return true; + } + }; + + const makeDirectory = async (path: string): Promise => { + logger.debug(`Creating directory: ${path}`); + mkdir(path, { recursive: true }); + return true; + }; + + const createDirectoryOrFail = async (path: string, message: string): Promise => { + if (!path || path.length === 0) { + throw new Error(message); + } + if (!(await isDirectoryEmpty(path))) { + throw new Error(`The folder "${path}" is not empty.`); + } + await makeDirectory(path); + return true; + }; + + const isFileExists = async (path: string): Promise => { + const file = Bun.file(path); + return await file.exists(); + }; + + const loadFile = async (path: string): Promise => { + const file = Bun.file(path); + return await file.text(); + }; + + const loadJsonFile = async (path: string): Promise => { + const file = Bun.file(path); + return await file.json(); + }; + + const removeFile = async (path: string): Promise => { + logger.debug(`Removing file: ${path}`); + return await unlink(path); + }; + + const saveFile = async (path: string, content: string): Promise => { + logger.debug(`Writing Content in file: ${path}`); + await writeFile(path, content); + }; + + const replaceInFile = async (path: string, keyValues: { search: string; replace: string }[]): Promise => { + const file = Bun.file(path); + const content = await file.text(); + const newContent = keyValues.reduce((memo: string, keyValue: { search: string; replace: string }) => { + return memo.replace(keyValue.search, keyValue.replace); + }, content); + await saveFile(path, newContent); + }; + + const saveResponse = async (path: string, response: Response): Promise => { + logger.debug(`Writing Response in file: ${path}`); + await Bun.write(path, response); + }; + + return { + isDirectoryEmpty, + makeDirectory, + createDirectoryOrFail, + isFileExists, + loadFile, + loadJsonFile, + removeFile, + saveFile, + saveResponse, + replaceInFile, + }; +}; diff --git a/components/cli/src/core/create-logger.ts b/components/cli/src/core/create-logger.ts new file mode 100644 index 0000000..2312653 --- /dev/null +++ b/components/cli/src/core/create-logger.ts @@ -0,0 +1,121 @@ +import Signale from 'signale'; +import type { Logger } from '../domain/contracts/logger'; +import pc from 'picocolors'; + +export const createLogger = (scope: string, levels: Array<'info' | 'debug'>): Logger => { + let isBuffered = false; + const buffer: Array<{ type: string; args: unknown[] }> = []; + const signale = new Signale.Signale({ + logLevel: 'info', // we always display the maximum level of Signal and filter later + interactive: false, + scope, + config: { + displayTimestamp: true, + displayDate: true, + displayScope: false, + displayLabel: false, + }, + }); + + const log = (type: string, ...args: any[]) => { + if (isBuffered) { + buffer.push({ type, args }); + return; + } + switch (type) { + case 'info': + if (levels.includes('info')) { + signale.info(...args); + } + break; + case 'debug': + if (levels.includes('debug')) { + signale.debug(...args); + } + break; + case 'log': + if (levels.includes('debug')) { + signale.log(...args); + } + break; + case 'warn': + signale.warn(...args); + break; + case 'error': + signale.error(...args); + break; + case 'fatal': + signale.fatal(...args); + break; + case 'success': + if (levels.includes('info')) { + signale.success(...args); + } + break; + case 'start': + if (levels.includes('info')) { + signale.start(...args); + } + break; + case 'note': + if (levels.includes('info')) { + signale.note(...args); + } + break; + case 'await': + if (levels.includes('info')) { + signale.await(...args); + } + break; + case 'complete': + if (levels.includes('info')) { + signale.complete(...args); + } + break; + case 'pause': + if (levels.includes('info')) { + signale.pause(...args); + } + break; + case 'pending': + if (levels.includes('info')) { + signale.pending(...args); + } + break; + case 'watch': + if (levels.includes('info')) { + signale.watch(...args); + } + break; + default: + throw new Error(`Invalid log type: ${type}`); + } + }; + + return { + setBuffered: (buffered: boolean) => { + isBuffered = buffered; + }, + flush: () => { + isBuffered = false; + if (buffer.length > 0) { + log('debug', pc.bold(`Some logs were collected while in Buffered Mode.`)); + buffer.forEach(({ type, args }) => log(type, ...args)); + } + }, + success: (...args: unknown[]) => log('success', ...args), + error: (...args: unknown[]) => log('error', ...args), + warn: (...args: unknown[]) => log('warn', ...args), + start: (...args: unknown[]) => log('start', ...args), + pause: (...args: unknown[]) => log('pause', ...args), + info: (...args: unknown[]) => log('info', ...args), + debug: (...args: unknown[]) => log('debug', ...args), + note: (...args: unknown[]) => log('note', ...args), + fatal: (...args: unknown[]) => log('fatal', ...args), + complete: (...args: unknown[]) => log('complete', ...args), + log: (...args: unknown[]) => log('log', ...args), + await: (...args: unknown[]) => log('await', ...args), + pending: (...args: unknown[]) => log('pending', ...args), + watch: (...args: unknown[]) => log('watch', ...args), + }; +}; diff --git a/components/cli/src/core/create-runner.ts b/components/cli/src/core/create-runner.ts new file mode 100644 index 0000000..1054327 --- /dev/null +++ b/components/cli/src/core/create-runner.ts @@ -0,0 +1,34 @@ +export const createRunner = + () => + async ( + command: string[], + onStdOut?: (data: Buffer) => void, + onStdErr?: (data: Buffer) => void, + ): Promise => { + const proc = Bun.spawn(command, { + stdout: 'pipe', + stderr: 'pipe', + }); + const stdOutReader = proc.stdout.getReader(); + const stdErrReader = proc.stderr.getReader(); + + const readStream = async (reader: ReadableStreamDefaultReader, on?: (data: Buffer) => void) => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = new TextDecoder().decode(value); + if (on) { + on(Buffer.from(text, 'utf-8')); + } + } + }; + + const stdOutPromise = readStream(stdOutReader, onStdOut); + const stdErrPromise = readStream(stdErrReader, onStdErr); + + await proc.exited; + await Promise.all([stdOutPromise, stdErrPromise]); + return proc.exitCode || 1; + }; + +export type Runner = ReturnType; diff --git a/components/cli/src/core/create-s3-uploader.ts b/components/cli/src/core/create-s3-uploader.ts new file mode 100644 index 0000000..93a7b78 --- /dev/null +++ b/components/cli/src/core/create-s3-uploader.ts @@ -0,0 +1,22 @@ +import type { S3Uploader } from '../domain/contracts/s3-uploader'; + +export const createS3Uploader = (): S3Uploader => { + return async (payload, fileContent) => { + const formData: FormData = new FormData(); + payload.fields.forEach((field: { name: string; value: string }) => { + formData.append(field.name, field.value); + }); + const arrayBuffer = Buffer.from(fileContent).buffer; + const buffer = Buffer.from(arrayBuffer); + formData.append('file', new Blob([buffer])); + await fetch(payload.url, { + method: 'POST', + headers: { + Accept: 'application/json', + }, + body: formData, + }); + const key = formData.get('key') as string; + return key; + }; +}; diff --git a/components/cli/src/core/di.ts b/components/cli/src/core/di.ts new file mode 100644 index 0000000..0c93232 --- /dev/null +++ b/components/cli/src/core/di.ts @@ -0,0 +1,115 @@ +import { asFunction, asValue, createContainer, InjectionMode } from 'awilix'; +import type { Logger } from '../domain/contracts/logger'; +import { createCommandBus, createQueryBus, type LoggerInterface } from 'missive.js'; +import type { CommandBus, CommandDefinitions, QueryBus, QueryDefinitions } from '../domain/contracts/bus'; +import { createInstallBoilerplateCommand } from '../command/install-boilerplate'; +import type { Command } from 'commander'; +import { createLogger } from './create-logger'; +import type { FlySystem } from '../domain/contracts/fly-system'; +import { createFlySystem } from './create-flysystem'; +import { createCreateCleanTenantHandler } from '../domain/use-cases/create-clean-tenant'; +import { createDownloadBoilerplateArchiveHandler } from '../domain/use-cases/download-boilerplate-archive'; +import { createFetchTipsHandler } from '../domain/use-cases/fetch-tips'; +import { createCredentialsRetriever } from './create-credentials-retriever'; +import type { CredentialRetriever } from '../domain/contracts/credential-retriever'; +import { createSetupBoilerplateProjectHandler } from '../domain/use-cases/setup-boilerplate-project'; +import { createInstallBoilerplateCommandStore } from './journeys/install-boilerplate/create-store'; +import { createRunner } from './create-runner'; +import { createLoginCommand } from '../command/login'; +import { createWhoAmICommand } from '../command/whoami'; +import { createS3Uploader } from './create-s3-uploader'; +import os from 'os'; +import { createRunMassOperationHandler } from '../domain/use-cases/run-mass-operation'; +import { createRunMassOperationCommand } from '../command/run-mass-operation'; + +const buildServices = () => { + const logLevels = ( + `${Bun.env.LOG_LEVELS}` === 'no-output' ? [] : ['info', ...`${Bun.env.LOG_LEVELS}`.split(',')] + ) as ('info' | 'debug')[]; + const logger = createLogger('cli', logLevels); + const container = createContainer<{ + logLevels: ('info' | 'debug')[]; + logger: Logger; + queryBus: QueryBus; + commandBus: CommandBus; + flySystem: FlySystem; + credentialsRetriever: CredentialRetriever; + runner: ReturnType; + s3Uploader: ReturnType; + // use cases + createCleanTenant: ReturnType; + downloadBoilerplateArchive: ReturnType; + fetchTips: ReturnType; + setupBoilerplateProject: ReturnType; + runMassOperation: ReturnType; + // stores + installBoilerplateCommandStore: ReturnType; + // commands + installBoilerplateCommand: Command; + loginCommand: Command; + whoAmICommand: Command; + runMassOperationCommand: Command; + }>({ + injectionMode: InjectionMode.PROXY, + strict: true, + }); + container.register({ + logLevels: asValue(logLevels), + logger: asValue(logger), + queryBus: asFunction(() => createQueryBus()).singleton(), + commandBus: asFunction(() => createCommandBus()).singleton(), + flySystem: asFunction(createFlySystem).singleton(), + credentialsRetriever: asFunction(createCredentialsRetriever) + .inject(() => ({ + fallbackFile: `${os.homedir()}/.crystallize/credentials.json`, + options: undefined, + })) + .singleton(), + + runner: asFunction(createRunner).singleton(), + s3Uploader: asFunction(createS3Uploader).singleton(), + + // Use Cases + createCleanTenant: asFunction(createCreateCleanTenantHandler).singleton(), + downloadBoilerplateArchive: asFunction(createDownloadBoilerplateArchiveHandler).singleton(), + fetchTips: asFunction(createFetchTipsHandler).singleton(), + setupBoilerplateProject: asFunction(createSetupBoilerplateProjectHandler).singleton(), + runMassOperation: asFunction(createRunMassOperationHandler).singleton(), + // Stores + installBoilerplateCommandStore: asFunction(createInstallBoilerplateCommandStore).singleton(), + + // Commands + installBoilerplateCommand: asFunction(createInstallBoilerplateCommand).singleton(), + loginCommand: asFunction(createLoginCommand).singleton(), + whoAmICommand: asFunction(createWhoAmICommand).singleton(), + runMassOperationCommand: asFunction(createRunMassOperationCommand).singleton(), + }); + container.cradle.commandBus.register('CreateCleanTenant', container.cradle.createCleanTenant); + container.cradle.queryBus.register('DownloadBoilerplateArchive', container.cradle.downloadBoilerplateArchive); + container.cradle.queryBus.register('FetchTips', container.cradle.fetchTips); + container.cradle.commandBus.register('SetupBoilerplateProject', container.cradle.setupBoilerplateProject); + container.cradle.commandBus.register('RunMassOperation', container.cradle.runMassOperation); + + const proxyLogger: LoggerInterface = { + log: (...args) => logger.debug(...args), + error: (...args) => logger.debug(...args), + }; + container.cradle.queryBus.useLoggerMiddleware({ logger: proxyLogger }); + container.cradle.commandBus.useLoggerMiddleware({ logger: proxyLogger }); + return { + logger, + createCommand: container.cradle.commandBus.createCommand, + dispatchCommand: container.cradle.commandBus.dispatch, + createQuery: container.cradle.queryBus.createQuery, + dispatchQuery: container.cradle.queryBus.dispatch, + runner: container.cradle.runner, + commands: [ + container.cradle.installBoilerplateCommand, + container.cradle.loginCommand, + container.cradle.whoAmICommand, + container.cradle.runMassOperationCommand, + ], + }; +}; +const services = buildServices(); +export const { logger, createCommand, createQuery, dispatchCommand, dispatchQuery, commands } = services; diff --git a/components/cli/src/core/journeys/install-boilerplate/actions/download-project.tsx b/components/cli/src/core/journeys/install-boilerplate/actions/download-project.tsx new file mode 100644 index 0000000..dbd7ef6 --- /dev/null +++ b/components/cli/src/core/journeys/install-boilerplate/actions/download-project.tsx @@ -0,0 +1,49 @@ +import { Box, Text } from 'ink'; +import { useEffect } from 'react'; +import { colors } from '../../../styles'; +import { Spinner } from '../../../../ui/components/spinner'; +import { createQuery, dispatchQuery } from '../../../di'; +import type { InstallBoilerplateStore } from '../create-store'; +import { useAtom } from 'jotai'; + +type DownloadProjectProps = { + store: InstallBoilerplateStore['atoms']; +}; +export const DownloadProject = ({ store }: DownloadProjectProps) => { + const [state] = useAtom(store.stateAtom); + const [, boilerplateDownloaded] = useAtom(store.setDownloadedAtom); + const [isWizardFullfilled] = useAtom(store.isWizardFullfilledAtom); + + useEffect(() => { + if (state.boilerplate) { + const query = createQuery('DownloadBoilerplateArchive', { + boilerplate: state.boilerplate, + destination: state.folder!, + }); + dispatchQuery(query).then(() => boilerplateDownloaded(true)); + } + }, []); + + if (state.isDownloaded) { + return ( + + The boilerplate has been successfully downloaded. + + ); + } + + if (!isWizardFullfilled) { + return null; + } + + return ( + <> + + + + Downloading the {state.boilerplate!.name} boilerplate... + + + + ); +}; diff --git a/components/cli/src/core/journeys/install-boilerplate/actions/execute-recipes.tsx b/components/cli/src/core/journeys/install-boilerplate/actions/execute-recipes.tsx new file mode 100644 index 0000000..ec86f29 --- /dev/null +++ b/components/cli/src/core/journeys/install-boilerplate/actions/execute-recipes.tsx @@ -0,0 +1,129 @@ +import { Box, Text } from 'ink'; +import { useEffect, useState } from 'react'; +import { colors } from '../../../styles'; +import { Spinner } from '../../../../ui/components/spinner'; +import { createCommand, dispatchCommand, logger } from '../../../di'; +import type { InstallBoilerplateStore } from '../create-store'; +import { useAtom } from 'jotai'; + +const feedbacks = [ + 'Fetching the dependencies...', + 'Still fetching...', + 'Unpacking...', + 'Preparing files for install...', + 'Installing...', + 'Still installing...', + 'Daydreaming...', + 'Growing that node_modules...', + 'Looking for car keys...', + 'Looking for the car...', +]; + +type ExecuteRecipesProps = { + store: InstallBoilerplateStore['atoms']; +}; +export const ExecuteRecipes = ({ store }: ExecuteRecipesProps) => { + const [state] = useAtom(store.stateAtom); + const [isWizardFullfilled] = useAtom(store.isWizardFullfilledAtom); + const [, startImport] = useAtom(store.startBoostrappingAtom); + const [, recipesDone] = useAtom(store.recipesDoneAtom); + const isVerbose = state.isVerbose; + const [feedbackIndex, setFeedbackIndex] = useState(0); + + useEffect(() => { + if (!state.folder || !state.tenant) { + return; + } + + (async () => { + const setupBoilerplateCommand = createCommand('SetupBoilerplateProject', { + folder: state.folder!, + credentials: state.credentials, + tenant: state.tenant!, + }); + const [setupResult, tenantResult] = await Promise.allSettled([ + dispatchCommand(setupBoilerplateCommand), + (async () => { + if (state.bootstrapTenant) { + const createTenantCommand = createCommand('CreateCleanTenant', { + tenant: state.tenant!, + credentials: state.credentials!, + }); + await dispatchCommand(createTenantCommand); + startImport(); + } + })(), + ]); + + if (setupResult.status === 'fulfilled') { + logger.debug('Setup boilerplate project succeeded:', setupResult.value.result); + recipesDone(setupResult.value.result?.output || ''); + } else { + logger.error('Setup boilerplate project failed:', setupResult.reason); + } + if (tenantResult.status === 'fulfilled') { + logger.debug('Tenant creation succeeded'); + } else if (tenantResult) { + logger.error('Tenant creation failed:', tenantResult.reason); + } + })(); + }, []); + + useEffect(() => { + let timer: ReturnType; + if (!isWizardFullfilled) { + timer = setTimeout(() => { + setFeedbackIndex((feedbackIndex + 1) % feedbacks.length); + }, 2000); + } + return () => { + clearTimeout(timer); + }; + }, [feedbackIndex]); + + if (state.isFullfilled) { + return ( + + Project has been installed. + + ); + } + return ( + <> + + + Setting up the project for you: {feedbacks[feedbackIndex]} + + {state.isBoostrapping && ( + <> + + + Importing tenant data + + + )} + {isVerbose && (state.trace.logs.length > 0 || state.trace.errors.length > 0) && ( + + {'Trace: '} + {state.trace.logs.slice(-10).map((line: string, index: number) => ( + + {line} + + ))} + {state.trace.errors.slice(-10).map((line: string, index: number) => ( + + {line} + + ))} + + )} + + ); +}; diff --git a/components/cli/src/core/journeys/install-boilerplate/create-store.ts b/components/cli/src/core/journeys/install-boilerplate/create-store.ts new file mode 100644 index 0000000..26843e8 --- /dev/null +++ b/components/cli/src/core/journeys/install-boilerplate/create-store.ts @@ -0,0 +1,165 @@ +import { atom, createStore } from 'jotai'; +import type { Boilerplate } from '../../../domain/contracts/models/boilerplate'; +import type { PimCredentials } from '../../../domain/contracts/models/credentials'; +import type { Tenant } from '../../../domain/contracts/models/tenant'; + +export const createInstallBoilerplateCommandStore = () => { + const stateAtom = atom({ + folder: undefined, + tenant: undefined, + boilerplate: undefined, + bootstrapTenant: false, + isDownloaded: false, + messages: [], + trace: { + errors: [], + logs: [], + }, + isFullfilled: false, + isBoostrapping: false, + readme: undefined, + isVerbose: false, + credentials: undefined, + }); + + const isWizardFullfilledAtom = atom((get) => { + const state = get(stateAtom); + if (state.bootstrapTenant && !state.credentials) { + return false; + } + return state.boilerplate !== undefined && state.tenant !== undefined; + }); + + const addTraceLogAtom = atom(null, (get, set, log: string) => { + const currentState = get(stateAtom); + set(stateAtom, { + ...currentState, + trace: { + ...currentState.trace, + logs: [...currentState.trace.logs, log], + }, + }); + }); + + const addTraceErrorAtom = atom(null, (get, set, error: string) => { + const currentState = get(stateAtom); + set(stateAtom, { + ...currentState, + trace: { + ...currentState.trace, + errors: [...currentState.trace.errors, error], + }, + }); + }); + + const addMessageAtom = atom(null, (get, set, message: string) => { + const currentState = get(stateAtom); + set(stateAtom, { + ...currentState, + messages: [...currentState.messages, message], + }); + }); + + const changeTenantAtom = atom(null, (get, set, newTenant: Tenant) => { + const currentState = get(stateAtom); + const currentTenant = currentState.tenant; + + if (currentTenant?.identifier === newTenant.identifier) { + return; // No changes needed + } + + set(stateAtom, { + ...currentState, + tenant: newTenant, + messages: [ + ...currentState.messages, + `We changed the asked tenant identifier from ${currentTenant?.identifier || 'undefined'} to ${newTenant.identifier}`, + ], + }); + }); + + const setFolderAtom = atom(null, (get, set, folder: string) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, folder }); + }); + + const setTenantAtom = atom(null, (get, set, tenant: Tenant) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, tenant }); + }); + + const setBoilerplateAtom = atom(null, (get, set, boilerplate: Boilerplate) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, boilerplate }); + }); + + const setBootstrapTenantAtom = atom(null, (get, set, bootstrapTenant: boolean) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, bootstrapTenant }); + }); + + const setDownloadedAtom = atom(null, (get, set, isDownloaded: boolean) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, isDownloaded }); + }); + + const recipesDoneAtom = atom(null, (get, set, readme: string) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, readme, isFullfilled: true }); + }); + + const setCredentialsAtom = atom(null, (get, set, credentials: PimCredentials) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, credentials }); + }); + + const setVerbosity = atom(null, (get, set, verbose: boolean) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, isVerbose: verbose }); + }); + + const startBoostrappingAtom = atom(null, (get, set) => { + const currentState = get(stateAtom); + set(stateAtom, { ...currentState, isBoostrapping: true }); + }); + + return { + atoms: { + stateAtom, + isWizardFullfilledAtom, + setFolderAtom, + setBoilerplateAtom, + setDownloadedAtom, + setBootstrapTenantAtom, + recipesDoneAtom, + setTenantAtom, + setCredentialsAtom, + setVerbosity, + startBoostrappingAtom, + addMessageAtom, + addTraceErrorAtom, + addTraceLogAtom, + changeTenantAtom, + }, + storage: createStore(), + }; +}; + +export type InstallBoilerplateStore = ReturnType; +export interface InstallBoilerplateState { + folder?: string; + tenant?: Tenant; + boilerplate?: Boilerplate; + bootstrapTenant: boolean; + isDownloaded: boolean; + messages: string[]; + trace: { + errors: string[]; + logs: string[]; + }; + isBoostrapping: boolean; + isFullfilled: boolean; + readme?: string; + isVerbose: boolean; + credentials?: PimCredentials; +} diff --git a/components/cli/src/core/journeys/install-boilerplate/install-boilerplate.journey.tsx b/components/cli/src/core/journeys/install-boilerplate/install-boilerplate.journey.tsx new file mode 100644 index 0000000..862a48c --- /dev/null +++ b/components/cli/src/core/journeys/install-boilerplate/install-boilerplate.journey.tsx @@ -0,0 +1,68 @@ +import { DownloadProject } from './actions/download-project'; +import { SelectBoilerplate } from './questions/select-boilerplate'; +import { SelectTenant } from './questions/select-tenant'; +import { ExecuteRecipes } from './actions/execute-recipes'; +import { Text } from 'ink'; +import { colors } from '../../styles'; +import { SetupCredentials } from '../../../ui/components/setup-credentials'; +import type { PimAuthenticatedUser } from '../../../domain/contracts/models/authenticated-user'; +import type { PimCredentials } from '../../../domain/contracts/models/credentials'; +import { Messages } from '../../../ui/components/messages'; +import { Tips } from '../../../ui/components/tips'; +import { Success } from '../../../ui/components/success'; +import type { InstallBoilerplateStore } from './create-store'; +import { useAtom } from 'jotai'; +import type { CredentialRetriever } from '../../../domain/contracts/credential-retriever'; +import type { QueryBus } from '../../../domain/contracts/bus'; + +type InstallBoilerplateJourneyProps = { + store: InstallBoilerplateStore['atoms']; + credentialsRetriever: CredentialRetriever; + queryBus: QueryBus; +}; +export const InstallBoilerplateJourney = ({ + store, + credentialsRetriever, + queryBus, +}: InstallBoilerplateJourneyProps) => { + const [state] = useAtom(store.stateAtom); + const [, changeTenant] = useAtom(store.changeTenantAtom); + const [, setCredentials] = useAtom(store.setCredentialsAtom); + const [isWizardFullfilled] = useAtom(store.isWizardFullfilledAtom); + + const fetchTips = async () => { + const query = queryBus.createQuery('FetchTips', {}); + const results = await queryBus.dispatch(query); + return results.result?.tips || []; + }; + return ( + <> + + Install will happen in directory: {state.folder} + + + {state.boilerplate && } + {state.boilerplate && state.tenant?.identifier && state.bootstrapTenant && !state.credentials && ( + { + credentialsRetriever + .fetchAvailableTenantIdentifier(credentials, state.tenant!.identifier) + .then((newIdentifier: string) => { + changeTenant({ + identifier: newIdentifier, + }); + setCredentials(credentials); + }); + }} + /> + )} + {isWizardFullfilled && } + {state.isDownloaded && } + + + {!isWizardFullfilled && } + {isWizardFullfilled && state.isFullfilled && {state.readme || ''}} + + ); +}; diff --git a/components/cli/src/core/journeys/install-boilerplate/questions/select-boilerplate.tsx b/components/cli/src/core/journeys/install-boilerplate/questions/select-boilerplate.tsx new file mode 100644 index 0000000..70fff2b --- /dev/null +++ b/components/cli/src/core/journeys/install-boilerplate/questions/select-boilerplate.tsx @@ -0,0 +1,42 @@ +import { Text } from 'ink'; +import { boilerplates } from '../../../../content/boilerplates'; +import type { Boilerplate } from '../../../../domain/contracts/models/boilerplate'; +import { BoilerplateChoice } from '../../../../ui/components/boilerplate-choice'; +import { colors } from '../../../styles'; +import { Select } from '../../../../ui/components/select'; +import type { InstallBoilerplateStore } from '../create-store'; +import { useAtom } from 'jotai'; + +type SelectBoilerplateProps = { + store: InstallBoilerplateStore['atoms']; +}; +export const SelectBoilerplate = ({ store }: SelectBoilerplateProps) => { + const [state] = useAtom(store.stateAtom); + const [, setBoilerplate] = useAtom(store.setBoilerplateAtom); + return ( + <> + {!state.boilerplate && Please select a boilerplate for your project} + {!state.boilerplate && ( + + options={boilerplates.map((boilerplate: Boilerplate) => { + return { + label: boilerplate.name, + value: boilerplate, + render: () => , + }; + })} + onSelect={(boilerplate: Boilerplate) => { + setBoilerplate(boilerplate); + }} + /> + )} + + {state.boilerplate && ( + + You are going to install the {state.boilerplate.name}{' '} + boilerplate. + + )} + + ); +}; diff --git a/components/cli/src/core/journeys/install-boilerplate/questions/select-tenant.tsx b/components/cli/src/core/journeys/install-boilerplate/questions/select-tenant.tsx new file mode 100644 index 0000000..33199e5 --- /dev/null +++ b/components/cli/src/core/journeys/install-boilerplate/questions/select-tenant.tsx @@ -0,0 +1,134 @@ +import { Box, Newline, Text } from 'ink'; +import Link from 'ink-link'; +import { UncontrolledTextInput } from 'ink-text-input'; +import { useState } from 'react'; +import { Select } from '../../../../ui/components/select'; +import type { Tenant } from '../../../../domain/contracts/models/tenant'; +import { colors } from '../../../styles'; +import type { InstallBoilerplateStore } from '../create-store'; +import { useAtom } from 'jotai'; + +type SelectTenantProps = { + store: InstallBoilerplateStore['atoms']; +}; +export const SelectTenant = ({ store }: SelectTenantProps) => { + const [state] = useAtom(store.stateAtom); + const [, setBootstrapTenant] = useAtom(store.setBootstrapTenantAtom); + const [, setTenant] = useAtom(store.setTenantAtom); + const [shouldAskForInput, askForInput] = useState(state.bootstrapTenant && !state.tenant); + return ( + <> + {!shouldAskForInput && !state.tenant && ( + <> + + Please select a Crystallize tenant + + + Don't have a tenant yet? Create one at {/*@ts-ignore*/} + https://crystallize.com/signup + + + + + options={[ + { + label: 'Our demo tenant', + value: { + identifier: state.boilerplate?.blueprint!, + boostrap: false, + }, + render: () => ( + <> + Our demo tenant ({state.boilerplate?.blueprint!}) + + Lots of demo data here already + + ), + }, + { + label: 'My own existing tenant', + value: { + identifier: '', + boostrap: false, + }, + render: () => ( + <> + My own existing tenant + + + Of course your tenant content model (shapes, items) must fit the + boilerplate. + + + ), + }, + { + label: 'Create a new tenant for me', + value: { + identifier: '', + boostrap: true, + }, + render: () => ( + <> + Create a new tenant for me + + + A tenant will be bootstrapped for you with boilerplate content. (same as + `install -b`) + + + ), + }, + ]} + onSelect={( + answer: Tenant & { + boostrap: boolean; + }, + ) => { + if (answer.identifier === '') { + setBootstrapTenant(answer.boostrap); + askForInput(true); + } else { + setTenant(answer); + } + }} + /> + + )} + + {shouldAskForInput && ( + <> + + + Enter a tenant identifier: + + { + setTenant({ identifier: tenant }); + askForInput(false); + }} + /> + + {state.bootstrapTenant && ( + <> + + If this tenant identifier is not available we'll pick a very close name for you. + + + )} + + )} + + {state.tenant && ( + + Using the Tenant with identifier: {state.tenant.identifier}. + + )} + + ); +}; diff --git a/components/cli/src/core/styles.ts b/components/cli/src/core/styles.ts new file mode 100644 index 0000000..4402d75 --- /dev/null +++ b/components/cli/src/core/styles.ts @@ -0,0 +1,6 @@ +export const colors = { + highlight: '#FFBD54', + warning: '#FFBD51', + info: '#A5FAE6', + error: '#FF728C', +}; diff --git a/components/cli/src/domain/contracts/bus.ts b/components/cli/src/domain/contracts/bus.ts new file mode 100644 index 0000000..dd86f7d --- /dev/null +++ b/components/cli/src/domain/contracts/bus.ts @@ -0,0 +1,14 @@ +import type { QueryBus as MissiveQueryBus, CommandBus as MissiveCommandBus } from 'missive.js'; +import type { CreateCleanTenantHandlerDefinition } from '../use-cases/create-clean-tenant'; +import type { DownloadBoilerplateArchiveHandlerDefinition } from '../use-cases/download-boilerplate-archive'; +import type { FetchTipsHandlerDefinition } from '../use-cases/fetch-tips'; +import type { SetupBoilerplateProjectHandlerDefinition } from '../use-cases/setup-boilerplate-project'; +import type { RunMassOperationHandlerDefinition } from '../use-cases/run-mass-operation'; + +export type QueryDefinitions = DownloadBoilerplateArchiveHandlerDefinition & FetchTipsHandlerDefinition; +export type QueryBus = MissiveQueryBus; + +export type CommandDefinitions = CreateCleanTenantHandlerDefinition & + SetupBoilerplateProjectHandlerDefinition & + RunMassOperationHandlerDefinition; +export type CommandBus = MissiveCommandBus; diff --git a/components/cli/src/domain/contracts/credential-retriever.ts b/components/cli/src/domain/contracts/credential-retriever.ts new file mode 100644 index 0000000..2251d85 --- /dev/null +++ b/components/cli/src/domain/contracts/credential-retriever.ts @@ -0,0 +1,15 @@ +import type { PimAuthenticatedUser } from './models/authenticated-user'; +import type { PimCredentials } from './models/credentials'; + +export type CredentialRetrieverOptions = { + token_id?: string; + token_secret?: string; +}; + +export type CredentialRetriever = { + getCredentials: (options?: CredentialRetrieverOptions) => Promise; + checkCredentials: (credentials: PimCredentials) => Promise; + removeCredentials: () => Promise; + saveCredentials: (credentials: PimCredentials) => Promise; + fetchAvailableTenantIdentifier: (credentials: PimCredentials, identifier: string) => Promise; +}; diff --git a/components/cli/src/domain/contracts/fly-system.ts b/components/cli/src/domain/contracts/fly-system.ts new file mode 100644 index 0000000..1468fd7 --- /dev/null +++ b/components/cli/src/domain/contracts/fly-system.ts @@ -0,0 +1,12 @@ +export type FlySystem = { + isDirectoryEmpty: (path: string) => Promise; + makeDirectory: (path: string) => Promise; + createDirectoryOrFail: (path: string, message: string) => Promise; + isFileExists: (path: string) => Promise; + loadFile: (path: string) => Promise; + loadJsonFile: (path: string) => Promise; + removeFile: (path: string) => Promise; + saveFile: (path: string, content: string) => Promise; + saveResponse: (path: string, response: Response) => Promise; + replaceInFile: (path: string, keyValues: { search: string; replace: string }[]) => Promise; +}; diff --git a/components/cli/src/domain/contracts/logger.ts b/components/cli/src/domain/contracts/logger.ts new file mode 100644 index 0000000..d80a529 --- /dev/null +++ b/components/cli/src/domain/contracts/logger.ts @@ -0,0 +1,18 @@ +export type Logger = { + setBuffered: (isBuffered: boolean) => void; + flush: () => void; + await: (...args: unknown[]) => void; + complete: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + fatal: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + note: (...args: unknown[]) => void; + pause: (...args: unknown[]) => void; + pending: (...args: unknown[]) => void; + start: (...args: unknown[]) => void; + success: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + watch: (...args: unknown[]) => void; + log: (...args: unknown[]) => void; +}; diff --git a/components/cli/src/domain/contracts/models/authenticated-user.ts b/components/cli/src/domain/contracts/models/authenticated-user.ts new file mode 100644 index 0000000..c85bcbf --- /dev/null +++ b/components/cli/src/domain/contracts/models/authenticated-user.ts @@ -0,0 +1,13 @@ +export type PimAuthenticatedUser = { + id: string; + firstName: string; + lastName: string; + email: string; + tenants: { + tenant: { + id: string; + identifier: string; + name: string; + }; + }[]; +}; diff --git a/components/cli/src/domain/contracts/models/boilerplate.ts b/components/cli/src/domain/contracts/models/boilerplate.ts new file mode 100644 index 0000000..a038cbe --- /dev/null +++ b/components/cli/src/domain/contracts/models/boilerplate.ts @@ -0,0 +1,9 @@ +export type Boilerplate = { + identifier: string; + name: string; + baseline: string; + description: string; + demo: string; + blueprint: string; + git: string; +}; diff --git a/components/cli/src/domain/contracts/models/credentials.ts b/components/cli/src/domain/contracts/models/credentials.ts new file mode 100644 index 0000000..d577a6c --- /dev/null +++ b/components/cli/src/domain/contracts/models/credentials.ts @@ -0,0 +1,4 @@ +export type PimCredentials = { + ACCESS_TOKEN_ID: string; + ACCESS_TOKEN_SECRET: string; +}; diff --git a/components/cli/src/domain/contracts/models/tenant.ts b/components/cli/src/domain/contracts/models/tenant.ts new file mode 100644 index 0000000..d37fc9f --- /dev/null +++ b/components/cli/src/domain/contracts/models/tenant.ts @@ -0,0 +1,3 @@ +export type Tenant = { + identifier: string; +}; diff --git a/components/cli/src/domain/contracts/models/tip.ts b/components/cli/src/domain/contracts/models/tip.ts new file mode 100644 index 0000000..47a9bb5 --- /dev/null +++ b/components/cli/src/domain/contracts/models/tip.ts @@ -0,0 +1,5 @@ +export type Tip = { + title: string; + url: string; + type: string; +}; diff --git a/components/cli/src/domain/contracts/s3-uploader.ts b/components/cli/src/domain/contracts/s3-uploader.ts new file mode 100644 index 0000000..0ee4523 --- /dev/null +++ b/components/cli/src/domain/contracts/s3-uploader.ts @@ -0,0 +1,4 @@ +export type S3Uploader = ( + payload: { url: string; fields: { name: string; value: string }[] }, + fileContent: string, +) => Promise; diff --git a/components/cli/src/domain/use-cases/create-clean-tenant.ts b/components/cli/src/domain/use-cases/create-clean-tenant.ts new file mode 100644 index 0000000..77f4fc9 --- /dev/null +++ b/components/cli/src/domain/use-cases/create-clean-tenant.ts @@ -0,0 +1,79 @@ +import { createClient } from '@crystallize/js-api-client'; +import { jsonToGraphQLQuery } from 'json-to-graphql-query'; +import type { Tenant } from '../contracts/models/tenant'; +import type { CommandHandlerDefinition, Envelope } from 'missive.js'; +import type { PimCredentials } from '../contracts/models/credentials'; + +type Deps = {}; +type Command = { + tenant: Tenant; + credentials: PimCredentials; +}; + +export type CreateCleanTenantHandlerDefinition = CommandHandlerDefinition< + 'CreateCleanTenant', + Command, + Awaited> +>; + +const handler = async ( + envelope: Envelope, + _: Deps, +): Promise<{ + id: string; + identifier: string; +}> => { + const { tenant, credentials } = envelope.message; + const client = createClient({ + tenantIdentifier: '', + accessTokenId: credentials.ACCESS_TOKEN_ID, + accessTokenSecret: credentials.ACCESS_TOKEN_SECRET, + }); + const createResult = await client.pimApi( + `mutation CREATE_TENANT ($identifier: String!, $name: String!) { + tenant { + create(input: { + identifier: $identifier, + isActive: true, + name: $name, + meta: { + key: "cli" + value: "yes" + } + }) { + id + identifier + } + } + }`, + { + name: tenant.identifier, + identifier: tenant.identifier, + }, + ); + const { id, identifier } = createResult.tenant.create; + const shapeIdentifiers = ['default-product', 'default-folder', 'default-document']; + const mutation = { + shape: shapeIdentifiers.reduce((memo: Record, shapeIdentifier: string) => { + const camelCaseIdentifier = shapeIdentifier.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); + return { + ...memo, + [camelCaseIdentifier]: { + __aliasFor: 'delete', + __args: { + identifier: shapeIdentifier, + tenantId: id, + }, + }, + }; + }, {}), + }; + const query = jsonToGraphQLQuery({ mutation }); + await client.pimApi(query); + return { + id, + identifier, + }; +}; + +export const createCreateCleanTenantHandler = (deps: Deps) => (command: Envelope) => handler(command, deps); diff --git a/components/cli/src/domain/use-cases/download-boilerplate-archive.ts b/components/cli/src/domain/use-cases/download-boilerplate-archive.ts new file mode 100644 index 0000000..2be23fa --- /dev/null +++ b/components/cli/src/domain/use-cases/download-boilerplate-archive.ts @@ -0,0 +1,40 @@ +import type { Envelope, QueryHandlerDefinition } from 'missive.js'; +import type { Boilerplate } from '../contracts/models/boilerplate'; +import { extract } from 'tar'; +import type { FlySystem } from '../contracts/fly-system'; +import os from 'os'; + +type Deps = { + flySystem: FlySystem; +}; +type Query = { + boilerplate: Boilerplate; + destination: string; +}; + +export type DownloadBoilerplateArchiveHandlerDefinition = QueryHandlerDefinition< + 'DownloadBoilerplateArchive', + Query, + Awaited> +>; + +const handler = async (envelope: Envelope, deps: Deps) => { + const tempDir = os.tmpdir(); + const uniqueId = Math.random().toString(36).substring(7); + + const { boilerplate, destination } = envelope.message; + const { flySystem } = deps; + const repo = boilerplate.git.replace('https://github.com/', ''); + const tarFileUrl = `https://github.com/${repo}/archive/master.tar.gz`; + const response = await fetch(tarFileUrl); + const tarFilePath = `${tempDir}/crystallize-boilerplate-archive-${uniqueId}.tar.gz`; + await flySystem.saveResponse(tarFilePath, response); + await extract({ + file: tarFilePath, + cwd: destination, + strip: 1, + }); + await flySystem.removeFile(tarFilePath); +}; + +export const createDownloadBoilerplateArchiveHandler = (deps: Deps) => (query: Envelope) => handler(query, deps); diff --git a/components/cli/src/domain/use-cases/fetch-tips.ts b/components/cli/src/domain/use-cases/fetch-tips.ts new file mode 100644 index 0000000..904d4d6 --- /dev/null +++ b/components/cli/src/domain/use-cases/fetch-tips.ts @@ -0,0 +1,101 @@ +import type { Envelope, QueryHandlerDefinition } from 'missive.js'; +import { createCatalogueFetcher, createClient } from '@crystallize/js-api-client'; +import type { Logger } from '../contracts/logger'; +import { staticTips } from '../../content/static-tips'; + +type Deps = { + logger: Logger; +}; +type Query = {}; + +export type FetchTipsHandlerDefinition = QueryHandlerDefinition< + 'FetchTips', + Query, + Awaited> +>; + +const handler = async (_: Envelope, deps: Deps) => { + const { logger } = deps; + const apiClient = createClient({ + tenantIdentifier: 'crystallize_marketing', + }); + const fetcher = createCatalogueFetcher(apiClient); + const tree = { + subtree: { + __args: { + first: 10, + }, + edges: { + node: { + name: true, + path: true, + }, + }, + }, + }; + const query = { + blogPosts: { + __aliasFor: 'catalogue', + __args: { + path: '/blog', + language: 'en', + }, + ...tree, + }, + comics: { + __aliasFor: 'catalogue', + __args: { + path: '/comics', + language: 'en', + }, + ...tree, + }, + }; + + try { + const data = await fetcher<{ + blogPosts: { + subtree: { + edges: { + node: { + name: string; + path: string; + }; + }[]; + }; + }; + comics: { + subtree: { + edges: { + node: { + name: string; + path: string; + }; + }[]; + }; + }; + }>(query); + return { + tips: [ + ...staticTips, + ...data.blogPosts.subtree.edges.map(({ node }: { node: { name: string; path: string } }) => ({ + title: node.name, + url: `https://crystallize.com${node.path}`, + type: 'blogPost', + })), + ...data.comics.subtree.edges.map(({ node }: { node: { name: string; path: string } }) => ({ + title: node.name, + url: `https://crystallize.com${node.path}`, + type: 'comic', + })), + ], + }; + } catch (error) { + logger.error('Failed to fetch tips', error); + return { + tips: staticTips, + }; + } +}; + +export const createFetchTipsHandler = (deps: Deps) => (query: Envelope) => handler(query, deps); diff --git a/components/cli/src/domain/use-cases/run-mass-operation.ts b/components/cli/src/domain/use-cases/run-mass-operation.ts new file mode 100644 index 0000000..a2ae00e --- /dev/null +++ b/components/cli/src/domain/use-cases/run-mass-operation.ts @@ -0,0 +1,120 @@ +import type { CommandHandlerDefinition, Envelope } from 'missive.js'; +import { OperationsSchema, type Operations } from '@crystallize/schema/mass-operation'; +import type { Logger } from '../contracts/logger'; +import pc from 'picocolors'; +import type { PimCredentials } from '../contracts/models/credentials'; +import { createClient } from '@crystallize/js-api-client'; +import type { S3Uploader } from '../contracts/s3-uploader'; + +type Deps = { + logger: Logger; + s3Uploader: S3Uploader; +}; + +type Command = { + tenantIdentifier: string; + operations: Operations; + credentials: PimCredentials; +}; + +export type RunMassOperationHandlerDefinition = CommandHandlerDefinition< + 'RunMassOperation', + Command, + Awaited> +>; + +const handler = async (envelope: Envelope, { logger, s3Uploader }: Deps) => { + const { tenantIdentifier, operations: operationsContent } = envelope.message; + + const crystallizeClient = createClient({ + tenantIdentifier, + accessTokenId: envelope.message.credentials.ACCESS_TOKEN_ID, + accessTokenSecret: envelope.message.credentials.ACCESS_TOKEN_SECRET, + }); + const operations = OperationsSchema.parse(operationsContent); + logger.debug( + `Operations file parsed successfully. ${pc.bold(pc.yellow(operations.operations.length))} operation(s) found.`, + ); + + const uniquId = Math.random().toString(36).substring(7); + const file = `mass-operation-${uniquId}.json`; + const register = await crystallizeClient.nextPimApi(generatePresignedUploadRequest, { file }); + + if (register.generatePresignedUploadRequest.error) { + throw new Error(register.generatePresignedUploadRequest.error); + } + const uploadRequest = register.generatePresignedUploadRequest; + logger.debug(`Upload request generated successfully.`); + + const key = await s3Uploader(uploadRequest, JSON.stringify(operationsContent)); + logger.debug(`File uploaded successfully to ${pc.yellow(key)}`); + + const create = await crystallizeClient.nextPimApi(createMassOperationBulkTask, { key }); + if (create.createMassOperationBulkTask.error) { + throw new Error(create.createMassOperationBulkTask.error); + } + const task = create.createMassOperationBulkTask; + logger.debug(`Task created successfully. Task ID: ${pc.yellow(task.id)}`); + + const start = await crystallizeClient.nextPimApi(startMassOperationBulkTask, { id: task.id }); + if (start.startMassOperationBulkTask.error) { + throw new Error(start.startMassOperationBulkTask.error); + } + const startedTask = start.startMassOperationBulkTask; + + return { + task: startedTask, + }; +}; + +export const createRunMassOperationHandler = (deps: Deps) => (command: Envelope) => handler(command, deps); + +const startMassOperationBulkTask = `#graphql +mutation START($id: ID!) { + startMassOperationBulkTask(id: $id) { + ... on BulkTaskMassOperation { + id + type + status + } + ... on BasicError { + error: message + } + } +}`; + +const createMassOperationBulkTask = `#graphql + mutation REGISTER($key: String!) { + createMassOperationBulkTask(input: {key: $key, autoStart: false}) { + ... on BulkTaskMassOperation { + id + status + type + } + ... on BasicError { + error: message + } + } + } +`; +const generatePresignedUploadRequest = `#graphql + mutation GET_URL($file: String!) { + generatePresignedUploadRequest( + filename: $file + contentType: "application/json" + type: MASS_OPERATIONS + ) { + ... on PresignedUploadRequest { + url + fields { + name + value + } + maxSize + lifetime + } + ... on BasicError { + error: message + } + } +}`; diff --git a/components/cli/src/domain/use-cases/setup-boilerplate-project.ts b/components/cli/src/domain/use-cases/setup-boilerplate-project.ts new file mode 100644 index 0000000..849b0ca --- /dev/null +++ b/components/cli/src/domain/use-cases/setup-boilerplate-project.ts @@ -0,0 +1,100 @@ +import type { CommandHandlerDefinition, Envelope } from 'missive.js'; +import type { FlySystem } from '../contracts/fly-system'; +import type { Tenant } from '../contracts/models/tenant'; +import type { PimCredentials } from '../contracts/models/credentials'; +import type { Logger } from '../contracts/logger'; +import type { Runner } from '../../core/create-runner'; +import type { InstallBoilerplateStore } from '../../core/journeys/install-boilerplate/create-store'; +import type { CredentialRetriever } from '../contracts/credential-retriever'; + +type Deps = { + flySystem: FlySystem; + logger: Logger; + runner: Runner; + installBoilerplateCommandStore: InstallBoilerplateStore; + credentialsRetriever: CredentialRetriever; +}; + +type Command = { + folder: string; + tenant: Tenant; + credentials?: PimCredentials; +}; + +export type SetupBoilerplateProjectHandlerDefinition = CommandHandlerDefinition< + 'SetupBoilerplateProject', + Command, + Awaited> +>; + +const handler = async (envelope: Envelope, deps: Deps) => { + const { flySystem, logger, runner, installBoilerplateCommandStore } = deps; + const { folder, tenant, credentials } = envelope.message; + const { storage, atoms } = installBoilerplateCommandStore; + const addTraceLog = (log: string) => storage.set(atoms.addTraceLogAtom, log); + const addTraceError = (log: string) => storage.set(atoms.addTraceErrorAtom, log); + + const finalCredentials = credentials || (await deps.credentialsRetriever.getCredentials()); + + logger.log(`Setting up boilerplate project in ${folder} for tenant ${tenant.identifier}`); + if (await flySystem.isFileExists(`${folder}/provisioning/clone/.env.dist`)) { + try { + await flySystem.replaceInFile(`${folder}/provisioning/clone/.env.dist`, [ + { + search: '##STOREFRONT_IDENTIFIER##', + replace: `storefront-${tenant.identifier}`, + }, + { + search: '##CRYSTALLIZE_TENANT_IDENTIFIER##', + replace: tenant.identifier, + }, + { + search: '##CRYSTALLIZE_ACCESS_TOKEN_ID##', + replace: finalCredentials?.ACCESS_TOKEN_ID || '', + }, + { + search: '##CRYSTALLIZE_ACCESS_TOKEN_SECRET##', + replace: finalCredentials?.ACCESS_TOKEN_SECRET || '', + }, + { + search: '##JWT_SECRET##', + replace: crypto.randomUUID(), + }, + ]); + } catch (e) { + logger.warn(`Could not replace values in provisioning/clone/.env.dist file from the boilerplate.`); + } + } else { + logger.warn(`Could not find provisioning/clone/.env.dist file from the boilerplate.`); + } + + let readme = 'cd appplication && npm run dev'; + if (await flySystem.isFileExists(`${folder}/provisioning/clone/success.md`)) { + try { + readme = await flySystem.loadFile(`${folder}/provisioning/clone/success.md`); + } catch (e) { + logger.warn( + `Could not load provisioning/clone/success.md file from the boilerplate. Using default readme.`, + ); + readme = 'cd appplication && npm run dev'; + } + } + logger.debug(`> .env.dist replaced.`); + await runner( + ['bash', `${folder}/provisioning/clone/setup.bash`], + (data) => { + logger.debug(data.toString()); + addTraceLog(data.toString()); + }, + (error) => { + logger.error(error.toString()); + addTraceError(error.toString()); + }, + ); + return { + output: readme, + }; +}; + +export const createSetupBoilerplateProjectHandler = (deps: Deps) => (command: Envelope) => + handler(command, deps); diff --git a/components/cli/src/index.ts b/components/cli/src/index.ts new file mode 100644 index 0000000..4e810c0 --- /dev/null +++ b/components/cli/src/index.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env bun + +import { Command } from 'commander'; +import packageJson from '../package.json'; +import pc from 'picocolors'; +import { commands, logger } from './core/di'; + +const program = new Command(); +program.version(packageJson.version); +program.name('crystallize'); + +const helpStyling = { + styleTitle: (str: string) => pc.bold(str), + styleCommandText: (str: string) => pc.cyan(str), + styleCommandDescription: (str: string) => pc.magenta(str), + styleDescriptionText: (str: string) => pc.italic(str), + styleOptionText: (str: string) => pc.green(str), + styleArgumentText: (str: string) => pc.yellow(str), + styleSubcommandText: (str: string) => pc.cyan(str), +}; +program.configureHelp(helpStyling); + +const logo: string = ` + /\\ + /\\/ \\ + _ / / \\ + / \\/ / \\ + \\ / / /__ + \\ / / / + \\ / / + \\ / / + \\___/__/ + + Crystallize CLI ${pc.italic(pc.yellow(packageJson.version))} +`; +program.addHelpText('beforeAll', pc.cyanBright(logo)); +program.description( + "Crystallize CLI helps you manage your Crystallize tenant(s) and improve your DX.\nWe've got your back(end)!\n 🤜✨🤛.", +); +commands.forEach((command) => { + command.configureHelp(helpStyling); + program.addCommand(command); +}); + +const logMemory = () => { + const used = process.memoryUsage(); + logger.debug( + `${pc.bold('Memory usage:')} ${Object.keys(used) + .map((key) => `${key} ${Math.round((used[key as keyof typeof used] / 1024 / 1024) * 100) / 100} MB`) + .join(', ')}`, + ); +}; + +try { + await program.parseAsync(process.argv); +} catch (exception) { + logger.flush(); + if (exception instanceof Error) { + logger.fatal(`[${pc.bold(exception.name)}] ${exception.message} `); + } else { + logger.fatal(`Unknown error.`); + } + logMemory(); + process.exit(1); +} +logger.flush(); +logMemory(); +process.exit(0); diff --git a/components/cli/src/ui/components/boilerplate-choice.tsx b/components/cli/src/ui/components/boilerplate-choice.tsx new file mode 100644 index 0000000..cf1d8d5 --- /dev/null +++ b/components/cli/src/ui/components/boilerplate-choice.tsx @@ -0,0 +1,24 @@ +import { Newline, Text } from 'ink'; +import Link from 'ink-link'; +import type { Boilerplate } from '../../domain/contracts/models/boilerplate'; + +type BoilerplateChoiceProps = { + boilerplate: Boilerplate; +}; +export const BoilerplateChoice = ({ boilerplate }: BoilerplateChoiceProps) => { + return ( + <> + {boilerplate.name} + + {boilerplate.baseline} + + {boilerplate.description} + + + + Demo: {boilerplate.demo} + + + + ); +}; diff --git a/components/cli/src/ui/components/markdown.tsx b/components/cli/src/ui/components/markdown.tsx new file mode 100644 index 0000000..b242d5f --- /dev/null +++ b/components/cli/src/ui/components/markdown.tsx @@ -0,0 +1,21 @@ +import pc from 'picocolors'; +import { marked } from 'marked'; +import { Text } from 'ink'; +import { markedTerminal, type TerminalRendererOptions } from 'marked-terminal'; + +export type Props = TerminalRendererOptions & { + children: string; +}; + +export default function Markdown({ children, ...options }: Props) { + marked.use( + // @ts-ignore + markedTerminal({ + firstHeading: pc.bold, + link: pc.underline, + href: pc.underline, + ...options, + }), + ); + return {(marked.parse(children) as string).trim()}; +} diff --git a/components/cli/src/ui/components/messages.tsx b/components/cli/src/ui/components/messages.tsx new file mode 100644 index 0000000..b100f28 --- /dev/null +++ b/components/cli/src/ui/components/messages.tsx @@ -0,0 +1,33 @@ +import { Box, Newline, Text } from 'ink'; +import React from 'react'; +import { colors } from '../../core/styles'; + +export const Messages: React.FC<{ title?: string; messages: string[] }> = ({ title = 'Note', messages }) => { + if (messages.length === 0) { + return null; + } + return ( + <> + + + {title} + {messages.length > 1 ? 's' : ''}: + + + + {messages.map((message, index) => { + return ( + + + {'> '} + + {message} + + + ); + })} + + + + ); +}; diff --git a/components/cli/src/ui/components/select.tsx b/components/cli/src/ui/components/select.tsx new file mode 100644 index 0000000..adab206 --- /dev/null +++ b/components/cli/src/ui/components/select.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Text, useInput, Box, Newline } from 'ink'; +import { colors } from '../../core/styles'; + +/** + * Due to terminals not always being that large, we need + * to cap the max amount of options to display to a low + * number. + */ +const maxOptionsToDisplay = 3; + +type Option = { + label: string; + value: T; + render?: () => JSX.Element; +}; + +type Styles = { + compact?: boolean; +}; + +export type Props = { + options: Option[]; + onSelect: (value: T) => void; + styles?: Styles; + defaultSelectedIndex?: number; +}; + +export function Select({ options, onSelect, styles, defaultSelectedIndex = 0 }: Props) { + const [selectedIndex, setSelectedIndex] = useState(defaultSelectedIndex); + + const optionsToDisplay = (() => { + if (selectedIndex === 0) { + return options.slice(selectedIndex, maxOptionsToDisplay); + } + + if (selectedIndex === options.length - 1) { + return options.slice(-maxOptionsToDisplay); + } + return options.slice(selectedIndex - 1, selectedIndex - 1 + maxOptionsToDisplay); + })(); + const lastDisplayedIndex = options.findIndex( + (option: Option) => option === optionsToDisplay[optionsToDisplay.length - 1], + ); + const overflowItem = lastDisplayedIndex < options.length - 1 ? options[lastDisplayedIndex + 1] : null; + + useInput((_, key) => { + if (key.return) { + onSelect(options[selectedIndex].value); + return; + } + + if (key.upArrow) { + setSelectedIndex(selectedIndex <= 0 ? options.length - 1 : selectedIndex - 1); + } + if (key.downArrow) { + setSelectedIndex(selectedIndex >= options.length - 1 ? 0 : selectedIndex + 1); + } + }); + + return ( + + {optionsToDisplay.map((option: Option, index: number) => { + return ( + + + + {options[selectedIndex].value === option.value ? '>' : ''} + + + + + {option.render ? option.render() : option.label} + + + + ); + })} + {overflowItem && ( + + + {overflowItem.label} + + ... + + + )} + + ); +} diff --git a/components/cli/src/ui/components/setup-credentials.tsx b/components/cli/src/ui/components/setup-credentials.tsx new file mode 100644 index 0000000..07e88f0 --- /dev/null +++ b/components/cli/src/ui/components/setup-credentials.tsx @@ -0,0 +1,260 @@ +import { useEffect, useState } from 'react'; +import { Text, Box, Newline } from 'ink'; +import { Select } from './select'; +import { UncontrolledTextInput } from 'ink-text-input'; +import React from 'react'; +import type { PimAuthenticatedUser } from '../../domain/contracts/models/authenticated-user'; +import type { PimCredentials } from '../../domain/contracts/models/credentials'; +import { colors } from '../../core/styles'; +import type { CredentialRetriever } from '../../domain/contracts/credential-retriever'; + +type YesOrNo = 'yes' | 'no'; +type UseExistingCredentials = YesOrNo | 'remove'; +type SaveCredentials = YesOrNo; + +type SetupCredentialsProps = { + dispatch: (authenticatedUser: PimAuthenticatedUser, credentials: PimCredentials) => void; + credentialsRetriever: CredentialRetriever; +}; +export const SetupCredentials = ({ dispatch, credentialsRetriever }: SetupCredentialsProps) => { + const [user, setUser] = useState(null); + const [credentials, setCredentials] = useState(); + const [askForInput, setAskForInput] = useState(false); + const [askForSaving, setAskForSaving] = useState(false); + + useEffect(() => { + (async () => { + try { + const credentials = await credentialsRetriever.getCredentials(); + setCredentials(credentials); + } catch { + setAskForInput(true); + return; + } + })(); + }, []); + + useEffect(() => { + if (!credentials || user) { + return; + } + (async () => { + const authicatedUser = await credentialsRetriever.checkCredentials(credentials); + if (!authicatedUser) { + setAskForInput(true); + return; + } + setUser(authicatedUser); + })(); + }, [credentials]); + + if (user && !askForInput && !askForSaving) { + return ( + { + switch (answer) { + case 'remove': + credentialsRetriever.removeCredentials(); + setAskForInput(true); + setUser(null); + break; + case 'no': + setAskForInput(true); + setUser(null); + break; + case 'yes': + dispatch(user, credentials!); + return; + } + }} + /> + ); + } + + if (askForInput) { + return ( + { + setCredentials(credentials); + setUser(user); + setAskForSaving(true); + setAskForInput(false); + }} + /> + ); + } + + if (askForSaving) { + return ( + { + dispatch(user!, credentials!); + }} + /> + ); + } + + return Verifying Crystallize Access Tokens...; +}; + +const ShouldWeUsedExistingTokenQuestion: React.FC<{ + user: PimAuthenticatedUser; + onAnswer: (answer: UseExistingCredentials) => void; +}> = ({ onAnswer, user }) => { + return ( + + + Hello{' '} + + {user.firstName} {user.lastName} + {' '} + + ({user.email}) + + + We found existing valid Crystallize Access Tokens. Want to use it? + + styles={{ compact: true }} + onSelect={onAnswer} + options={[ + { + value: 'yes', + label: 'Yes', + }, + { + value: 'no', + label: 'No', + }, + { + value: 'remove', + label: 'Wait, what? Remove the stored access tokens please', + }, + ]} + /> + + ); +}; + +const AskForCredentials: React.FC<{ + onValidCredentials: (credentials: PimCredentials, user: PimAuthenticatedUser) => void; + credentialsRetriever: CredentialRetriever; +}> = ({ onValidCredentials, credentialsRetriever }) => { + const [inputCredentials, setInputCredentials] = useState>(); + const [error, setError] = useState(null); + + useEffect(() => { + if (!inputCredentials?.ACCESS_TOKEN_SECRET) { + return; + } + (async () => { + const authenticatedUser = await credentialsRetriever.checkCredentials({ + ACCESS_TOKEN_ID: inputCredentials?.ACCESS_TOKEN_ID || '', + ACCESS_TOKEN_SECRET: inputCredentials?.ACCESS_TOKEN_SECRET || '', + }); + if (!authenticatedUser) { + setError('⚠️ Invalid tokens supplied. Please try again ⚠️'); + setInputCredentials({}); + return; + } + onValidCredentials(inputCredentials as PimCredentials, authenticatedUser); + })(); + }, [inputCredentials]); + + const isLoading = !!inputCredentials?.ACCESS_TOKEN_ID && !!inputCredentials?.ACCESS_TOKEN_SECRET; + + return ( + <> + + + Please provide Access Tokens to bootstrap the tenant + + + Learn about access tokens: https://crystallize.com/learn/developer-guides/access-tokens + + + + {error && ( + + {error} + + )} + + {!isLoading && !inputCredentials?.ACCESS_TOKEN_ID && ( + <> + Access Token ID: + + setInputCredentials({ + ACCESS_TOKEN_ID: value, + }) + } + /> + + )} + {!isLoading && inputCredentials?.ACCESS_TOKEN_ID && !inputCredentials?.ACCESS_TOKEN_SECRET && ( + <> + + Access Token ID: *** + + Access Token Secret: + + setInputCredentials({ + ...inputCredentials, + ACCESS_TOKEN_SECRET: value, + }) + } + /> + + )} + {isLoading && Verifying Crystallize Access Tokens...} + + + ); +}; + +const AskToSaveCredentials: React.FC<{ + credentials: PimCredentials; + user: PimAuthenticatedUser; + credentialsRetriever: CredentialRetriever; + onAnswered: () => void; +}> = ({ credentials, user, onAnswered, credentialsRetriever }) => { + return ( + + + Hello {user.firstName} {user.lastName} ({user.email}) + + Would you like to save the access tokens for future use? + + styles={{ compact: true }} + onSelect={(answer) => { + if (answer === 'yes') { + credentialsRetriever.saveCredentials(credentials); + } + onAnswered(); + }} + options={[ + { + value: 'yes', + label: 'Yes, please', + }, + { + value: 'no', + label: 'No, thanks', + }, + ]} + /> + + ); +}; diff --git a/components/cli/src/ui/components/spinner.tsx b/components/cli/src/ui/components/spinner.tsx new file mode 100644 index 0000000..1e69ea1 --- /dev/null +++ b/components/cli/src/ui/components/spinner.tsx @@ -0,0 +1,29 @@ +import cliSpinners from 'cli-spinners'; +import type { Spinner as TSpinner } from 'cli-spinners'; +import { Text } from 'ink'; +import { useEffect, useState } from 'react'; +import { colors } from '../../core/styles'; + +type SpinnerProps = { + name?: keyof typeof cliSpinners; +}; +export const Spinner = ({ name = 'dots' }: SpinnerProps) => { + const [frame, setFrame] = useState(0); + const spinner: TSpinner = cliSpinners[name] as TSpinner; + useEffect(() => { + const timer = setInterval(() => { + setFrame((previousFrame) => { + const isLastFrame = previousFrame === spinner.frames.length - 1; + return isLastFrame ? 0 : previousFrame + 1; + }); + }, spinner.interval); + return () => { + clearInterval(timer); + }; + }, [spinner]); + return ( + + {spinner.frames[frame]}{' '} + + ); +}; diff --git a/components/cli/src/ui/components/success.tsx b/components/cli/src/ui/components/success.tsx new file mode 100644 index 0000000..bd6eff0 --- /dev/null +++ b/components/cli/src/ui/components/success.tsx @@ -0,0 +1,46 @@ +import { Box, Newline, Text, useApp } from 'ink'; +import { useEffect, useState } from 'react'; +import { colors } from '../../core/styles'; +import { Spinner } from './spinner'; +import Markdown from './markdown'; + +type SuccessProps = { + children: string; +}; + +export const Success = ({ children }: SuccessProps) => { + const { exit } = useApp(); + const [showSpinnerWaiter, setShowSpinnerWaiter] = useState(true); + useEffect(() => { + const timeout = setTimeout(() => { + setShowSpinnerWaiter(false); + exit(); + }, 1500); + return () => { + clearTimeout(timeout); + }; + }, []); + return ( + <> + + + Go fast and prosper. + The milliseconds are with you! + {showSpinnerWaiter ? : ▰▰▰▰▰▰▰} + + {children} + + + {showSpinnerWaiter ? : 🤜✨🤛} + + + + ); +}; diff --git a/components/cli/src/ui/components/tips.tsx b/components/cli/src/ui/components/tips.tsx new file mode 100644 index 0000000..85dae66 --- /dev/null +++ b/components/cli/src/ui/components/tips.tsx @@ -0,0 +1,45 @@ +import { Box, Newline, Text } from 'ink'; +import Link from 'ink-link'; +import { useEffect, useState } from 'react'; +import type { Tip } from '../../domain/contracts/models/tip'; +import { staticTips } from '../../content/static-tips'; + +export type TipsProps = { + fetchTips: () => Promise; +}; +export const Tips = ({ fetchTips }: TipsProps) => { + const [tips, setTips] = useState(staticTips); + const [tipIndex, setTipIndex] = useState(0); + + useEffect(() => { + fetchTips().then((tipResults) => setTips(tipResults)); + }, []); + + useEffect(() => { + const interval = setInterval(() => { + setTipIndex(Math.floor(Math.random() * (tips.length - 1))); + }, 4000); + return () => clearInterval(interval); + }); + if (tips.length === 0) { + return null; + } + const tip = tips[tipIndex]; + return ( + <> + + -------------------------------------- + + + + {tip.type === 'blogPost' && `Catch up on our blog post: "${tip.title}"`} + {tip.type === 'comic' && `Like comics? Check this out: "${tip.title}"`} + {tip.type === '' && `${tip.title}`} + + {tip.url} + + + + + ); +}; diff --git a/components/cli/tsconfig.json b/components/cli/tsconfig.json new file mode 100644 index 0000000..0378033 --- /dev/null +++ b/components/cli/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/components/manifest.json b/components/manifest.json index b4dcb1d..d3d7cd3 100644 --- a/components/manifest.json +++ b/components/manifest.json @@ -10,8 +10,12 @@ "git": "git@github.com:CrystallizeAPI/crystallize-magento-import.git" }, "crystallize-cli": { + "name": "Crystallize CLI (legacy)", + "git": "git@github.com:CrystallizeAPI/crystallize-cli.git" + }, + "cli": { "name": "Crystallize CLI", - "git": "git@github.com:CrystallizeAPI/crystallize-cli-next.git" + "git": "git@github.com:CrystallizeAPI/cli.git" } } }