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 0000000..d83bd08 Binary files /dev/null and b/components/cli/bun.lockb differ 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" } } }