diff --git a/.github/workflows/npm-package.yml b/.github/workflows/npm-package.yml
index ca5f711..3c17817 100644
--- a/.github/workflows/npm-package.yml
+++ b/.github/workflows/npm-package.yml
@@ -1,7 +1,9 @@
name: Publish Package to NPM Registry
+
on:
release:
types: [published]
+
jobs:
npm-package:
runs-on: ubuntu-latest-16-core
@@ -17,3 +19,18 @@ jobs:
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ notification:
+ runs-on: ubuntu-latest-16-core
+ needs: npm-package
+ steps:
+ - name: Send Slack notification
+ uses: slackapi/slack-github-action@v1.23.0
+ with:
+ payload: |
+ {
+ "repository": "${{ github.repository }}",
+ "version": "${{ github.ref }}"
+ }
+ env:
+ SLACK_WEBHOOK_URL: ${{ vars.RELEASE_PIPELINE_SLACK_WEBHOOK_URL }}
diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml
deleted file mode 100644
index 96957aa..0000000
--- a/.github/workflows/release-pipeline.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-name: Release Pipeline
-
-on:
- release:
- types: [created]
-
-jobs:
- notification:
- runs-on: ubuntu-latest-16-core
- steps:
- - name: Send Slack notification
- uses: slackapi/slack-github-action@v1.23.0
- with:
- payload: |
- {
- "repository": "${{ github.repository }}",
- "version": "${{ github.ref }}"
- }
- env:
- SLACK_WEBHOOK_URL: ${{ vars.RELEASE_PIPELINE_SLACK_WEBHOOK_URL }}
diff --git a/.github/workflows/test:smoke.yml b/.github/workflows/test:smoke.yml
new file mode 100644
index 0000000..fa3ac9b
--- /dev/null
+++ b/.github/workflows/test:smoke.yml
@@ -0,0 +1,40 @@
+name: Smoke test
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ smoke-test:
+ runs-on: ubuntu-latest-16-core
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Set up NodeJS
+ uses: actions/setup-node@v3
+ with:
+ node-version: '18'
+
+ - name: Install deps
+ run: npm ci
+
+ - name: Build
+ run: npm run build
+
+ - name: Smoke root command
+ run: node dist/main --help
+
+ - name: Smoke start command
+ run: (node dist/main start -v | tee >(grep -q "π₯ Everything is done! π₯" && pkill -P $$); [ $? -eq 143 ])
+
+ - name: Smoke clean command
+ run: node dist/main clean -v
+
+ - name: Upload logs
+ uses: actions/upload-artifact@v3
+ if: always()
+ with:
+ name: logs
+ path: /home/runner/.local/state/topos-playground/logs
diff --git a/README.md b/README.md
index d86089c..b8ea922 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
- Topos Playground is the CLI to run a local devnet to test the Topos ecosystem π
+ Topos Playground is a CLI make it simple to run a local Topos devnet π
@@ -26,10 +26,29 @@ $ npm install -g @topos-protocol/topos-playground
### Run the CLI
-If you installed the package manually, you can run it like so
+If you have installed the package manually, you can run it like so:
```
-$ topos-playground [start|clean]
+$ topos-playground --help
+```
+
+The playground respects XDG Base Directory Specifications, so by default, it will store
+data used while running in `$HOME/.local/share/topos-playground` and it will store logs
+in `$HOME/.state/topos-playground/logs`.
+
+To override these default locations, you can set your `HOME`, `XDG_DATA_HOME` and `XDG_STATE_HOME`
+environment variables, or specify them in a `.env` file.
+
+```
+$ HOME=/tmp topos-playground start
+```
+
+By default, topos-playground sends output to both your console and to a log file when it is running.
+To disable this, you can use the `--quiet` flag to prevent output from going to the console, or the
+`--no-log` flag to prevent output from going to the log file.
+
+```
+$ topos-playground start --quiet
```
Otherwise, you can use `npx` to abstract the installation
diff --git a/package-lock.json b/package-lock.json
index 9941d48..97c67ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,22 +1,25 @@
{
- "name": "topos-playground",
- "version": "0.0.1",
+ "name": "@topos-protocol/topos-playground",
+ "version": "0.0.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
- "name": "topos-playground",
- "version": "0.0.1",
- "license": "UNLICENSED",
+ "name": "@topos-protocol/topos-playground",
+ "version": "0.0.2",
+ "license": "MIT",
"dependencies": {
"@nestjs/common": "^9.0.0",
"@nestjs/core": "^9.0.0",
+ "chalk": "^4.1.2",
+ "dotenv": "^16.3.1",
"ethers": "^5.7.2",
"nest-commander": "^3.7.1",
+ "semver": "^7.5.4",
"winston": "^3.8.2"
},
"bin": {
- "topos-playground": "dist/main"
+ "topos-playground": "dist/main.js"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
@@ -4079,6 +4082,17 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dotenv": {
+ "version": "16.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
+ "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/motdotla/dotenv?sponsor=1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -7814,10 +7828,9 @@
"integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA=="
},
"node_modules/semver": {
- "version": "7.5.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
- "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
- "dev": true,
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+ "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": {
"lru-cache": "^6.0.0"
},
@@ -7832,7 +7845,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
@@ -7843,8 +7855,7 @@
"node_modules/semver/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/send": {
"version": "0.18.0",
@@ -12120,6 +12131,11 @@
"esutils": "^2.0.2"
}
},
+ "dotenv": {
+ "version": "16.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
+ "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ=="
+ },
"ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -14940,10 +14956,9 @@
"integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA=="
},
"semver": {
- "version": "7.5.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
- "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
- "dev": true,
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+ "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"requires": {
"lru-cache": "^6.0.0"
},
@@ -14952,7 +14967,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "dev": true,
"requires": {
"yallist": "^4.0.0"
}
@@ -14960,8 +14974,7 @@
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
},
diff --git a/package.json b/package.json
index a20f6e9..76cc5a3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@topos-protocol/topos-playground",
"version": "0.0.2",
- "description": "CLI to run local Topos devnets with subnets, a TCE network, and apps",
+ "description": "topos-playground is a CLI tool which handles all of the orchestration necessary to run local Topos devnets with subnets, a TCE network, and apps.",
"author": "SΓ©bastien Dan ",
"license": "MIT",
"bin": {
@@ -18,8 +18,11 @@
"dependencies": {
"@nestjs/common": "^9.0.0",
"@nestjs/core": "^9.0.0",
+ "chalk": "^4.1.2",
+ "dotenv": "^16.3.1",
"ethers": "^5.7.2",
"nest-commander": "^3.7.1",
+ "semver": "^7.5.4",
"winston": "^3.8.2"
},
"devDependencies": {
diff --git a/src/ReactiveSpawn.ts b/src/ReactiveSpawn.ts
index 1abe2a9..fb255c2 100644
--- a/src/ReactiveSpawn.ts
+++ b/src/ReactiveSpawn.ts
@@ -2,6 +2,8 @@ import { ChildProcess, spawn } from 'child_process'
import { userInfo } from 'os'
import { Observable } from 'rxjs'
+import { log } from './loggers'
+
export interface Next {
origin: 'stdout' | 'stderr'
output: string | ChildProcess
@@ -12,6 +14,9 @@ export class ReactiveSpawn {
reactify(command: string, options?: { runInBackground }) {
return new Observable((subscriber) => {
+ if (globalThis.verbose) {
+ log(`π Running command: ${command}`)
+ }
const childProcess = spawn(command, { ...options, shell: this._shell })
if (options && options.runInBackground) {
@@ -22,7 +27,7 @@ export class ReactiveSpawn {
const output = data.toString()
subscriber.next({ origin: 'stdout', output })
})
-
+
childProcess.stderr.on('data', (data: Buffer) => {
const output = data.toString()
subscriber.next({ origin: 'stderr', output })
diff --git a/src/app.module.ts b/src/app.module.ts
index b69c34e..11ba750 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'
import { CleanCommand } from './commands/clean.command'
import { StartCommand } from './commands/start.command'
+import { VersionCommand } from './commands/version.command'
+import { Root } from './commands/root.command'
+
import { ReactiveSpawn } from './ReactiveSpawn'
@Module({
- providers: [ReactiveSpawn, CleanCommand, StartCommand],
+ providers: [ReactiveSpawn, CleanCommand, StartCommand, VersionCommand, Root],
})
export class AppModule {}
diff --git a/src/commands/clean.command.ts b/src/commands/clean.command.ts
index 5563f7d..9682791 100644
--- a/src/commands/clean.command.ts
+++ b/src/commands/clean.command.ts
@@ -1,130 +1,164 @@
-import { stat } from 'fs'
+import { stat, readdir } from 'fs'
import { Command, CommandRunner } from 'nest-commander'
-import { concatAll } from 'rxjs/operators'
-import { defer, EMPTY, Observable, of } from 'rxjs'
+import { Observable, catchError, concat, concatMap, of, tap } from 'rxjs'
import { homedir } from 'os'
-import { Next, ReactiveSpawn } from '../ReactiveSpawn'
-import { workingDir } from 'src/constants'
-import { createLoggerFile, loggerConsole } from 'src/loggers'
-import { randomUUID } from 'crypto'
+import { ReactiveSpawn } from '../ReactiveSpawn'
+import { log, logError } from '../loggers'
@Command({
name: 'clean',
- description: 'Clean artifacts from a previous start',
+ description:
+ 'Shut down Playground docker containers, and clean up the working directory',
})
export class CleanCommand extends CommandRunner {
- private _workingDir = workingDir
- private _loggerConsole = loggerConsole
- private _logFilePath = `logs/log-${randomUUID()}.log`
- private _loggerFile = createLoggerFile(this._logFilePath)
-
constructor(private _spawn: ReactiveSpawn) {
super()
}
- async run(): Promise {
- this._log(`Welcome to Topos-Playground!`)
- this._log(``)
+ async run() {
+ log(`Cleaning up Topos-Playground...`)
+ log(``)
- of(
+ concat(
this._verifyWorkingDirectoryExistence(),
- this._shutdownFullMsgProtocolInfra(),
+ this._verifyExecutionPathExistence(),
+ this._shutdownERC20MessagingProtocolInfra(),
this._shutdownRedis(),
- this._removeWorkingDir()
- )
- .pipe(concatAll())
- .subscribe({
- complete: () => {
- this._log(`π§Ή Everything is clean! π§Ή`)
- this._log(`Logs were written to ${this._logFilePath}`)
- },
- error: () => {},
- next: (data: Next) => {
- if (data && data.hasOwnProperty('origin')) {
- if (data.origin === 'stderr') {
- this._loggerFile.error(data.output)
- } else if (data.origin === 'stdout') {
- this._loggerFile.info(data.output)
- }
- }
- },
- })
+ this._removeWorkingDirectory()
+ ).subscribe()
}
private _verifyWorkingDirectoryExistence() {
return new Observable((subscriber) => {
- stat(this._workingDir, (error) => {
+ stat(globalThis.workingDir, (error, stats) => {
if (error) {
- this._logError(
- `Working directory have not been found, nothing to clean!`
+ globalThis.workingDirExists = false
+ log(
+ `Working directory (${globalThis.workingDir}) is not found; nothing to clean.`
)
+ subscriber.next()
+ subscriber.complete()
+ } else if (!stats.isDirectory()) {
+ logError(
+ `The working directory (${globalThis.workingDir}) is not a directory; this is an error!`
+ )
+ globalThis.workingDirExists = false
subscriber.error()
} else {
- subscriber.complete()
+ readdir(globalThis.workingDir, (err, files) => {
+ if (err) {
+ globalThis.workingDirExists = false
+ logError(
+ `Error while trying to read the working directory (${globalThis.workingDir})`
+ )
+ subscriber.error()
+ }
+
+ if (files.length === 0) {
+ globalThis.workingDirExists = false
+ log(
+ `Working directory (${globalThis.workingDir}) is empty; nothing to clean.`
+ )
+ subscriber.next()
+ subscriber.complete()
+ } else {
+ globalThis.workingDirExists = true
+ log(`Found working directory (${globalThis.workingDir})`)
+ subscriber.next()
+ subscriber.complete()
+ }
+ })
}
})
})
}
- private _shutdownFullMsgProtocolInfra() {
- const executionPath = `${this._workingDir}/local-erc20-messaging-infra`
+ private _verifyExecutionPathExistence() {
+ return new Observable((subscriber) => {
+ stat(globalThis.executionPath, (error) => {
+ if (error) {
+ globalThis.executionPathExists = false
+ log(`Execution path (${globalThis.executionPath}) not found`)
+ subscriber.next()
+ subscriber.complete()
+ } else {
+ globalThis.executionPathExists = true
+ log(`Found execution path (${globalThis.executionPath})`)
+ subscriber.next()
+ subscriber.complete()
+ }
+ })
+ })
+ }
- return of(
- defer(() => of(this._log(`Shutting down the ERC20 messaging infra...`))),
- this._spawn.reactify(`cd ${executionPath} && docker compose down -v`),
- defer(() => of(this._log(`β
subnets & TCE are down`), this._log(``)))
- ).pipe(concatAll())
+ private _shutdownERC20MessagingProtocolInfra() {
+ return new Observable((subscriber) => {
+ log('')
+ if (globalThis.executionPathExists) {
+ log(`Shutting down the ERC20 messaging infra...`)
+ this._spawn
+ .reactify(`cd ${globalThis.executionPath} && docker compose down -v`)
+ .subscribe(subscriber)
+ log(`β
subnets & TCE are down`)
+ } else {
+ log(`β
ERC20 messaging infra is not running; subnets & TCE are down`)
+ subscriber.complete()
+ }
+ })
}
private _shutdownRedis() {
const containerName = 'redis-stack-server'
- return of(
- defer(() => of(this._log(`Shutting down the redis server...`))),
- this._spawn.reactify(`docker rm -f ${containerName}`),
- defer(() => of(this._log(`β
redis is down`), this._log(``)))
- ).pipe(concatAll())
- }
-
- private _removeWorkingDir() {
- const homeDir = homedir()
+ return this._spawn
+ .reactify(`docker ps --format '{{.Names}}' | grep ${containerName}`)
+ .pipe(
+ concatMap((data) => {
+ if (
+ data &&
+ data.output &&
+ `${data.output}`.indexOf(containerName) !== -1
+ ) {
+ log('')
+ log(`Shutting down the redis server...`)
- return of(
- defer(() => of(this._log(`Removing the working directory...`))),
- // Let's make sure we're not removing something we shouldn't
- this._workingDir.indexOf(homeDir) !== -1 && this._workingDir !== homeDir
- ? of(
- this._spawn.reactify(`rm -rf ${this._workingDir}`),
- defer(() =>
- of(
- this._log('β
Working directory has been removed'),
- this._log(``)
- )
+ return this._spawn.reactify(`docker rm -f ${containerName}`).pipe(
+ tap({
+ complete: () => {
+ log(`β
redis is down`)
+ },
+ })
)
- ).pipe(concatAll())
- : of(
- EMPTY,
- defer(() =>
- of(
- this._logError(
- `Working directory (${this._workingDir}) is not safe for removal!`
- ),
- this._log(``)
- )
- )
- ).pipe(concatAll())
- ).pipe(concatAll())
- }
-
- private _log(logMessage: string) {
- this._loggerConsole.info(logMessage)
- this._loggerFile.info(logMessage)
+ } else {
+ of(log(`β
redis is not running; nothing to shut down`))
+ }
+ }),
+ catchError((error) => of(error))
+ )
}
- private _logError(errorMessage: string) {
- this._loggerConsole.error(errorMessage)
- this._loggerFile.error(errorMessage)
- this._loggerConsole.error(`π Find more details in ${this._logFilePath}`)
+ private _removeWorkingDirectory() {
+ const homeDir = homedir()
+ return new Observable((subscriber) => {
+ if (
+ globalThis.workingDirExists &&
+ globalThis.workingDir.indexOf(homeDir) !== -1 &&
+ globalThis.workingDir !== homeDir
+ ) {
+ log('')
+ log(`Cleaning up the working directory (${globalThis.workingDir})`)
+ this._spawn.reactify(`rm -rf ${globalThis.workingDir}`).subscribe({
+ complete: () => {
+ log('β
Working directory has been removed')
+ subscriber.complete()
+ },
+ })
+ } else {
+ log('')
+ log(`β
Working direction does not exist; nothing to clean`)
+ subscriber.complete()
+ }
+ })
}
}
diff --git a/src/commands/root.command.ts b/src/commands/root.command.ts
new file mode 100644
index 0000000..11225de
--- /dev/null
+++ b/src/commands/root.command.ts
@@ -0,0 +1,94 @@
+import * as chalk from 'chalk'
+import { RootCommand, Option, CommandRunner } from 'nest-commander'
+
+const { description, version } = require('../../package.json')
+import { log } from '../loggers'
+import { breakText } from '../utility'
+
+const example = chalk.green
+
+const helptext = `
+Example Usage
+
+ $ ${example('topos-playground start')}
+ Start the Topos-Playground. This command will output the status of the playground creation to the terminal as it runs, and will log a more detailed status to a log file.
+
+ $ ${example('topos-playground start --verbose')}
+ This will also start the topos playground, but the terminal output as well as the log file output will contain more information. This is useful for debugging if there are errors starting the playground.
+
+ $ ${example('topos-playground start -q')}
+ This will start the topos playground quietly. Most output will be suppressed.
+
+ $ ${example('topos-playground clean')}
+ This will clean the topos playground. It will shut down all containers, and remove all filesystem artifacts except for log files.
+
+ $ ${example('topos-playground version')}
+ This will print the version of the topos playground.
+
+ $ ${example('topos-playground version -q')}')
+ This will print only the numbers of the topos-playground version, with no other output.
+
+Configuration
+
+ topos-playground follows the XDG Base Directory Specification, which means that data files for use during runs of the playground are stored in $XDG_DATA_HOME/topos-playground, which defaults to $HOME/.local/share/topos-playground and log files are stored in $XDG_STATE_HOME/topos-playground/logs, which defaults to $HOME/.local/state/topos-playground/logs.
+
+ These locations can be overridden by setting the environment variables HOME, XDG_DATA_HOME, and XDG_STATE_HOME.
+`
+const columns = process.stdout.columns || 80
+const overrideQuiet = true
+
+@RootCommand({
+ description: `${breakText(description, columns)}\n\n${breakText(
+ helptext,
+ columns
+ )}`,
+})
+export class Root extends CommandRunner {
+ constructor() {
+ super()
+ }
+
+ async run(): Promise {}
+
+ @Option({
+ flags: '--version',
+ description: `Show topos-playground version (v${version})`,
+ })
+ doVersion() {
+ log(
+ (globalThis.quiet ? '' : 'topos-playground version ') + version,
+ overrideQuiet
+ )
+ log(``)
+ }
+
+ @Option({
+ flags: '-v, --verbose',
+ description: breakText(
+ `Show more information about the execution of a command`,
+ columns - 18
+ ),
+ })
+ doVerbose() {
+ globalThis.verbose = true
+ }
+
+ @Option({
+ flags: '-q, --quiet',
+ description: breakText(
+ `Show minimal onscreen information about the execution of a command`,
+ columns - 18
+ ),
+ })
+ doQuiet() {
+ globalThis.quiet = true
+ }
+
+ @Option({
+ flags: '-n, --no-log',
+ description: `Do not write a log file`,
+ })
+ doNoLog() {
+ globalThis.noLog = true
+ }
+}
diff --git a/src/commands/start.command.ts b/src/commands/start.command.ts
index 7b2d90c..ad35d70 100644
--- a/src/commands/start.command.ts
+++ b/src/commands/start.command.ts
@@ -1,36 +1,33 @@
-import { randomUUID } from 'crypto'
import { readFile, stat } from 'fs'
-import { Command, CommandRunner, InquirerService } from 'nest-commander'
-import { concatAll, tap } from 'rxjs/operators'
-import { defer, Observable, of } from 'rxjs'
+import { Command, CommandRunner } from 'nest-commander'
+import { concat, defer, Observable, of, tap } from 'rxjs'
+import { satisfies } from 'semver'
+import { log, logError, logToFile } from '../loggers'
import { Next, ReactiveSpawn } from '../ReactiveSpawn'
-import { workingDir } from 'src/constants'
-import { createLoggerFile, loggerConsole } from 'src/loggers'
-const INFRA_REF = 'v0.1.5'
-const FRONTEND_REF = 'v0.1.0-alpha3'
const EXECUTOR_SERVICE_REF = 'v0.1.1'
-
-@Command({ name: 'start', description: 'Run everything' })
+const FRONTEND_REF = 'v0.1.0-alpha3'
+const INFRA_REF = 'v0.1.5'
+const MIN_VERSION_DOCKER = '17.6.0'
+const MIN_VERSION_GIT = '2.0.0'
+const MIN_VERSION_NODE = '16.0.0'
+
+@Command({
+ name: 'start',
+ description:
+ 'Verify that all dependencies are installed, clone any needed repositories, setup the environment, and start all of the docker containers for the Playground',
+})
export class StartCommand extends CommandRunner {
- private _workingDir = workingDir
- private _loggerConsole = loggerConsole
- private _logFilePath = `logs/log-${randomUUID()}.log`
- private _loggerFile = createLoggerFile(this._logFilePath)
-
- constructor(
- private _spawn: ReactiveSpawn,
- private readonly inquirer: InquirerService
- ) {
+ constructor(private _spawn: ReactiveSpawn) {
super()
}
- async run(): Promise {
- this._log(`Welcome to Topos-Playground!`)
- this._log(``)
+ async run() {
+ log(`Starting Topos-Playground...`)
+ log(``)
- of(
+ concat(
this._verifyDependencyInstallation(),
this._createWorkingDirectoryIfInexistant(),
this._cloneGitRepositories(),
@@ -40,100 +37,167 @@ export class StartCommand extends CommandRunner {
this._runRedis(),
this._runExecutorService(),
this._rundDappFrontendService()
- )
- .pipe(concatAll())
- .subscribe({
- complete: () => {
- this._log(`π₯ Everything is done! π₯`)
- this._log(``)
- this._log(
- `π Start sending ERC20 tokens across subnet by accessing the dApp Frontend at http://localhost:3001`
- )
- this._log(``)
- this._log(
- `βΉοΈ Ctrl/cmd-c will shut down the dApp Frontend and the Executor Service BUT will keep subnets and the TCE running (use the clean command to shut them down)`
- )
- this._log(`βΉοΈ Logs were written to ${this._logFilePath}`)
- },
- error: () => {
- this._logError(`β Error`)
- },
- next: (data: Next) => {
- if (data && data.hasOwnProperty('origin')) {
- if (data.origin === 'stderr') {
- this._loggerFile.error(data.output)
- } else if (data.origin === 'stdout') {
- this._loggerFile.info(data.output)
- }
- }
- },
- })
+ ).subscribe({
+ complete: () => {
+ log(`π₯ Everything is done! π₯`)
+ log(``)
+ log(
+ `π Start sending ERC20 tokens across subnet by accessing the dApp Frontend at http://localhost:3001`
+ )
+ log(``)
+ log(
+ `βΉοΈ Ctrl/cmd-c will shut down the dApp Frontend and the Executor Service BUT will keep subnets and the TCE running (use the clean command to shut them down)`
+ )
+ log(`βΉοΈ Logs were written to ${logFilePath}`)
+ },
+ error: () => {
+ logError('β Error')
+ process.exit(1)
+ },
+ next: (data: Next) => {
+ if (globalThis.verbose && data && data.hasOwnProperty('output')) {
+ logToFile(`${data.output}`)
+ }
+ },
+ })
}
private _verifyDependencyInstallation() {
- return of(
- defer(() => of(this._log('Verifying dependency installation...'))),
+ return concat(
+ of(log('Verifying dependency installation...')),
this._verifyDockerInstallation(),
this._verifyGitInstallation(),
- this._verifyNodeJSInstallation(),
- defer(() => of(this._log('')))
- ).pipe(concatAll())
+ this._verifyNodeJSInstallation()
+ ).pipe(
+ tap({
+ complete: () => {
+ log('β
Dependency checks completed!')
+ log('')
+ },
+ })
+ )
}
private _verifyDockerInstallation() {
- return of(
- this._spawn.reactify('docker --version'),
- defer(() => of(this._log('β
Docker')))
- ).pipe(concatAll())
+ return this._spawn.reactify('docker --version').pipe(
+ tap({
+ next: (data: Next) => {
+ if (data && data.hasOwnProperty('output')) {
+ let match = RegExp(/Docker version ([0-9]+\.[0-9]+\.[0-9]+)/).exec(
+ `${data.output}`
+ )
+
+ if (match && match.length > 1) {
+ if (satisfies(match[1], `>=${MIN_VERSION_DOCKER}`)) {
+ log(`β
Docker -- Version: ${match[1]}`)
+ } else {
+ log(`β Docker -- Version: ${match[1]}`)
+ throw new Error(
+ `Docker ${match[1]} is not supported\n` +
+ 'Please upgrade Docker to version 17.06.0 or higher.'
+ )
+ }
+ }
+ }
+ },
+ error: () => {
+ logError(`β Docker Not Installed!`)
+ },
+ })
+ )
}
private _verifyGitInstallation() {
- return of(
- this._spawn.reactify('git --version'),
- defer(() => of(this._log('β
Git')))
- ).pipe(concatAll())
+ return this._spawn.reactify('git --version').pipe(
+ tap({
+ next: (data: Next) => {
+ if (data && data.hasOwnProperty('output')) {
+ let match = RegExp(/git version ([0-9]+\.[0-9]+\.[0-9]+)/).exec(
+ `${data.output}`
+ )
+
+ if (match && match.length > 1) {
+ if (satisfies(match[1], `>=${MIN_VERSION_GIT}`)) {
+ log(`β
Git -- Version: ${match[1]}`)
+ } else {
+ logError(`β Git -- Version: ${match[1]}`)
+ throw new Error(
+ `Git ${match[1]} is not supported\n` +
+ 'Please upgrade Git to version 2.0.0 or higher.'
+ )
+ }
+ }
+ }
+ },
+ error: () => {
+ logError(`β Git Not Intalled!`)
+ },
+ })
+ )
}
private _verifyNodeJSInstallation() {
- return of(
- this._spawn.reactify('node --version'),
- defer(() => of(this._log('β
NodeJS')))
- ).pipe(concatAll())
+ return this._spawn.reactify('node --version').pipe(
+ tap({
+ next: (data: Next) => {
+ if (data && data.hasOwnProperty('output')) {
+ let match = RegExp(/v([0-9]+\.[0-9]+\.[0-9]+)/).exec(
+ `${data.output}`
+ )
+
+ if (match && match.length > 1) {
+ if (satisfies(match[1], `>=${MIN_VERSION_NODE}`)) {
+ log(`β
Node.js -- Version: ${match[1]}`)
+ } else {
+ log(`β Node.js -- Version: ${match[1]}`)
+ throw new Error(
+ `Node.js ${match[1]} is not supported\n` +
+ 'Please upgrade Node.js to version 16.0.0 or higher.'
+ )
+ }
+ }
+ }
+ },
+ error: () => {
+ logError(`β Node.js Not Installed!`)
+ },
+ })
+ )
}
private _createWorkingDirectoryIfInexistant() {
return new Observable((subscriber) => {
- this._log(`Verifying working directory: [${this._workingDir}]...`)
+ log(`Verifying working directory: [${globalThis.workingDir}]...`)
- stat(this._workingDir, (error) => {
+ stat(globalThis.workingDir, (error) => {
if (error) {
this._spawn
- .reactify(`mkdir -p ${this._workingDir}`)
+ .reactify(`mkdir -p ${globalThis.workingDir}`)
.pipe(
tap({
complete: () => {
- this._log(`β
Working directory was successfully created`)
+ log(`β
Working directory was successfully created`)
},
})
)
.subscribe(subscriber)
} else {
- this._log(`β
Working directory exists`)
+ log(`β
Working directory exists`)
subscriber.complete()
}
})
}).pipe(
tap({
complete: () => {
- this._log('')
+ log('')
},
})
)
}
private _cloneGitRepositories() {
- return of(
- defer(() => of(this._log('Cloning repositories...'))),
+ return concat(
+ defer(() => of(log('Cloning repositories...'))),
this._cloneGitRepository(
'topos-protocol',
'local-erc20-messaging-infra',
@@ -149,8 +213,8 @@ export class StartCommand extends CommandRunner {
'executor-service',
EXECUTOR_SERVICE_REF
),
- defer(() => of(this._log('')))
- ).pipe(concatAll())
+ defer(() => of(log('')))
+ )
}
private _cloneGitRepository(
@@ -159,32 +223,39 @@ export class StartCommand extends CommandRunner {
branch?: string
) {
return new Observable((subscriber) => {
- const path = `${this._workingDir}/${repositoryName}`
+ const path = `${globalThis.workingDir}/${repositoryName}`
stat(path, (error) => {
if (error) {
+ if (globalThis.verbose) {
+ log(`Cloning ${organizationName}/${repositoryName}...`)
+ }
+
this._spawn
.reactify(
`git clone --depth 1 ${
branch ? `--branch ${branch}` : ''
- } git@github.com:${organizationName}/${repositoryName}.git ${
- this._workingDir
+ } https://github.com/${organizationName}/${repositoryName}.git ${
+ globalThis.workingDir
}/${repositoryName}`
)
.pipe(
tap({
complete: () => {
- this._log(
+ log(
`β
${repositoryName}${
branch ? ` | ${branch}` : ''
} successfully cloned`
)
+ if (globalThis.verbose) {
+ log('')
+ }
},
})
)
.subscribe(subscriber)
} else {
- this._log(
+ log(
`β
${repositoryName}${branch ? ` | ${branch}` : ''} already cloned`
)
subscriber.complete()
@@ -194,23 +265,27 @@ export class StartCommand extends CommandRunner {
}
private _copyEnvFiles() {
- return of(
- defer(() => of(this._log('Copying .env files across repositories...'))),
+ return concat(
+ defer(() => of(log('Copying .env files across repositories...'))),
this._copyEnvFile(
'.env.dapp-frontend',
- `${this._workingDir}/dapp-frontend-erc20-messaging/packages/frontend`
+ `${globalThis.workingDir}/dapp-frontend-erc20-messaging/packages/frontend`
),
this._copyEnvFile(
'.env.dapp-backend',
- `${this._workingDir}/dapp-frontend-erc20-messaging/packages/backend`
+ `${globalThis.workingDir}/dapp-frontend-erc20-messaging/packages/backend`
),
this._copyEnvFile(
'.env.executor-service',
- `${this._workingDir}/executor-service`
+ `${globalThis.workingDir}/executor-service`
),
- this._copyEnvFile('.env.secrets', `${this._workingDir}`, `.env.secrets`),
- defer(() => of(this._log('')))
- ).pipe(concatAll())
+ this._copyEnvFile(
+ '.env.secrets',
+ `${globalThis.workingDir}`,
+ `.env.secrets`
+ ),
+ defer(() => of(log('')))
+ )
}
private _copyEnvFile(
@@ -233,7 +308,7 @@ export class StartCommand extends CommandRunner {
.pipe(
tap({
complete: () => {
- this._log(`β
${localEnvFileName} copied`)
+ log(`β
${localEnvFileName} copied`)
},
})
)
@@ -241,7 +316,7 @@ export class StartCommand extends CommandRunner {
}
)
} else {
- this._log(`β
${localEnvFileName} already existing`)
+ log(`β
${localEnvFileName} already exists`)
subscriber.complete()
}
})
@@ -249,29 +324,29 @@ export class StartCommand extends CommandRunner {
}
private _runLocalERC20MessagingInfra() {
- const secretsFilePath = `${this._workingDir}/.env.secrets`
- const executionPath = `${this._workingDir}/local-erc20-messaging-infra`
+ const secretsFilePath = `${globalThis.workingDir}/.env.secrets`
+ const executionPath = `${globalThis.workingDir}/local-erc20-messaging-infra`
- return of(
- defer(() => of(this._log(`Running the ERC20 messaging infra...`))),
+ return concat(
+ defer(() => of(log(`Running the ERC20 messaging infra...`))),
this._spawn.reactify(
`source ${secretsFilePath} && cd ${executionPath} && docker compose up -d`
),
- defer(() => of(this._log(`β
Subnets & TCE are running`), this._log(``)))
- ).pipe(concatAll())
+ defer(() => of(log(`β
Subnets & TCE are running`), log(``)))
+ )
}
private _retrieveAndWriteContractAddressesToEnv() {
- const frontendEnvFilePath = `${this._workingDir}/dapp-frontend-erc20-messaging/packages/frontend/.env`
- const executorServiceEnvFilePath = `${this._workingDir}/executor-service/.env`
+ const frontendEnvFilePath = `${globalThis.workingDir}/dapp-frontend-erc20-messaging/packages/frontend/.env`
+ const executorServiceEnvFilePath = `${globalThis.workingDir}/executor-service/.env`
- return of(
- defer(() => of(this._log(`Retrieving contract addresses...`))),
+ return concat(
+ defer(() => of(log(`Retrieving contract addresses...`))),
this._spawn.reactify(
- `docker cp contracts-topos:/contracts/.env ${this._workingDir}/.env.addresses`
+ `docker cp contracts-topos:/contracts/.env ${globalThis.workingDir}/.env.addresses`
),
this._spawn.reactify(
- `source ${this._workingDir}/.env.addresses \
+ `source ${globalThis.workingDir}/.env.addresses \
&& echo "VITE_SUBNET_REGISTRATOR_CONTRACT_ADDRESS=$SUBNET_REGISTRATOR_CONTRACT_ADDRESS" >> ${frontendEnvFilePath} \
&& echo "VITE_TOPOS_CORE_CONTRACT_ADDRESS=$TOPOS_CORE_PROXY_CONTRACT_ADDRESS" >> ${frontendEnvFilePath} \
&& echo "VITE_ERC20_MESSAGING_CONTRACT_ADDRESS=$ERC20_MESSAGING_CONTRACT_ADDRESS" >> ${frontendEnvFilePath} \
@@ -280,44 +355,42 @@ export class StartCommand extends CommandRunner {
),
defer(() =>
of(
- this._log(
- `β
Contract addresses were retrieved and written to env files`
- ),
- this._log(``)
+ log(`β
Contract addresses were retrieved and written to env files`),
+ log(``)
)
)
- ).pipe(concatAll())
+ )
}
private _runRedis() {
const containerName = 'redis-stack-server'
- return of(
- defer(() => of(this._log(`Running the redis server...`))),
+ return concat(
+ defer(() => of(log(`Running the redis server...`))),
this._spawn.reactify(
`docker start ${containerName} 2>/dev/null || docker run -d --name ${containerName} -p 6379:6379 redis/redis-stack-server:latest`
),
- defer(() => of(this._log(`β
redis is running`), this._log(``)))
- ).pipe(concatAll())
+ defer(() => of(log(`β
redis is running`), log(``)))
+ )
}
private _runExecutorService() {
- const secretsFilePath = `${this._workingDir}/.env.secrets`
- const executionPath = `${this._workingDir}/executor-service`
+ const secretsFilePath = `${globalThis.workingDir}/.env.secrets`
+ const executionPath = `${globalThis.workingDir}/executor-service`
- return of(
- defer(() => of(this._log(`Running the Executor Service...`))),
+ return concat(
+ defer(() => of(log(`Running the Executor Service...`))),
this._npmInstall(executionPath),
this._startExecutorService(secretsFilePath, executionPath),
- defer(() => of(this._log(``)))
- ).pipe(concatAll())
+ defer(() => of(log(``)))
+ )
}
private _npmInstall(executionPath: string) {
return this._spawn.reactify(`cd ${executionPath} && npm install`).pipe(
tap({
complete: () => {
- this._log(`β
Deps are installed`)
+ log(`β
Deps are installed`)
},
})
)
@@ -335,23 +408,23 @@ export class StartCommand extends CommandRunner {
.pipe(
tap({
complete: () => {
- this._log(`β
Web server is running`)
+ log(`β
Web server is running`)
},
})
)
}
private _rundDappFrontendService() {
- const secretsFilePath = `${this._workingDir}/.env.secrets`
- const executionPath = `${this._workingDir}/dapp-frontend-erc20-messaging`
+ const secretsFilePath = `${globalThis.workingDir}/.env.secrets`
+ const executionPath = `${globalThis.workingDir}/dapp-frontend-erc20-messaging`
- return of(
- defer(() => of(this._log(`Running the dApp Frontend...`))),
+ return concat(
+ defer(() => of(log(`Running the dApp Frontend...`))),
this._npmInstall(executionPath),
this._buildDappFrontend(secretsFilePath, executionPath),
this._startDappFrontend(secretsFilePath, executionPath),
- defer(() => of(this._log(``)))
- ).pipe(concatAll())
+ defer(() => of(log(``)))
+ )
}
private _buildDappFrontend(secretsFilePath: string, executionPath: string) {
@@ -362,7 +435,7 @@ export class StartCommand extends CommandRunner {
.pipe(
tap({
complete: () => {
- this._log(`β
Static files are built`)
+ log(`β
Static files are built`)
},
})
)
@@ -377,19 +450,9 @@ export class StartCommand extends CommandRunner {
.pipe(
tap({
complete: () => {
- this._log(`β
Web server is running`)
+ log(`β
Web server is running`)
},
})
)
}
-
- private _log(logMessage: string) {
- this._loggerConsole.info(logMessage)
- this._loggerFile.info(logMessage)
- }
-
- private _logError(errorMessage: string) {
- this._loggerConsole.error(errorMessage)
- this._loggerConsole.error(`Find more details in ${this._logFilePath}`)
- }
}
diff --git a/src/commands/version.command.ts b/src/commands/version.command.ts
new file mode 100644
index 0000000..b710636
--- /dev/null
+++ b/src/commands/version.command.ts
@@ -0,0 +1,24 @@
+import { Command, CommandRunner } from 'nest-commander'
+
+const { version } = require('../../package.json')
+import { log } from '../loggers'
+
+const overrideQuiet = true
+
+@Command({
+ name: 'version',
+ description: `Show topos-playground version (v${version})`,
+})
+export class VersionCommand extends CommandRunner {
+ constructor() {
+ super()
+ }
+
+ async run(): Promise {
+ log(
+ (globalThis.quiet ? '' : 'topos-playground version ') + version,
+ overrideQuiet
+ )
+ log(``)
+ }
+}
diff --git a/src/initializers.ts b/src/initializers.ts
new file mode 100644
index 0000000..6e52dcf
--- /dev/null
+++ b/src/initializers.ts
@@ -0,0 +1,57 @@
+import { randomUUID } from 'crypto'
+import { config } from 'dotenv'
+import { mkdir } from 'fs'
+import { join } from 'path'
+
+import { loggerConsole, logError } from './loggers'
+
+config()
+
+declare global {
+ var verbose: boolean
+ var quiet: boolean
+ var noLog: boolean
+ var workingDir: string
+ var workingDirExists: boolean
+ var logDir: string
+ var executionPath: string
+ var executionPathExists: boolean
+ var loggerConsoleVar: typeof loggerConsole
+ var logFilePath: string
+ var loggerFile
+}
+
+export function initializeGlobals() {
+ // Setup global configuration
+
+ let home = process.env.HOME || '.'
+ let dataHome = process.env.XDG_DATA_HOME || join(home, '.local', 'share')
+ let stateHome = process.env.XDG_STATE_HOME || join(home, '.local', 'state')
+
+ globalThis.workingDir = join(dataHome, 'topos-playground')
+ globalThis.logDir = join(stateHome, 'topos-playground/logs')
+ globalThis.loggerConsoleVar = loggerConsole
+ globalThis.logFilePath = join(logDir, `log-${randomUUID()}.log`)
+ globalThis.executionPath = join(
+ globalThis.workingDir,
+ 'local-erc20-messaging-infra'
+ )
+
+ globalThis.loggerFile = false
+}
+
+export function initializeDirectories() {
+ // Create the working directory if it does not exist
+ mkdir(globalThis.workingDir, { recursive: true }, (error) => {
+ if (error) {
+ logError(`Could not create working directory (${globalThis.workingDir})`)
+ }
+ })
+
+ // Create the log directory if it does not exist
+ mkdir(globalThis.logDir, { recursive: true }, (error) => {
+ if (error) {
+ logError(`Could not create log directory (${globalThis.logDir})`)
+ }
+ })
+}
diff --git a/src/loggers.ts b/src/loggers.ts
index 05c4dff..eea4e5f 100644
--- a/src/loggers.ts
+++ b/src/loggers.ts
@@ -1,4 +1,3 @@
-import { randomUUID } from 'crypto'
import * as winston from 'winston'
export const loggerConsole = winston.createLogger({
@@ -28,3 +27,44 @@ export function createLoggerFile(logFilePath: string) {
],
})
}
+
+function getLogConsole() {
+ return globalThis.loggerConsoleVar
+}
+
+function getLogFile() {
+ if (!globalThis.loggerFile)
+ globalThis.loggerFile = createLoggerFile(
+ globalThis.noLog ? '/dev/null' : globalThis.logFilePath
+ )
+
+ return globalThis.loggerFile
+}
+
+export function log(logMessage: string, overrideQuiet: boolean = false) {
+ for (let line of logMessage.split('\n')) {
+ if (overrideQuiet || !globalThis.quiet) {
+ getLogConsole().info(line)
+ }
+ getLogFile().info(line)
+ }
+}
+
+export function logToFile(logMessage: string) {
+ for (let line of logMessage.split('\n')) {
+ getLogFile().info(line)
+ }
+}
+
+export function logError(errorMessage: string) {
+ let lines = errorMessage.split('\n')
+
+ for (let line of lines) {
+ getLogConsole().error(line)
+ getLogFile().error(line)
+ }
+
+ const message = `Find the full log file in ${globalThis.logFilePath}`
+ getLogConsole().error(message)
+ getLogFile().error(message)
+}
diff --git a/src/main.ts b/src/main.ts
index eb46184..d44e544 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,10 +1,13 @@
#!/usr/bin/env node
import { CommandFactory } from 'nest-commander'
+import { initializeGlobals, initializeDirectories } from './initializers'
import { AppModule } from './app.module'
async function bootstrap() {
await CommandFactory.run(AppModule)
}
+initializeGlobals()
+initializeDirectories()
bootstrap()
diff --git a/src/utility.ts b/src/utility.ts
new file mode 100644
index 0000000..4b44b9d
--- /dev/null
+++ b/src/utility.ts
@@ -0,0 +1,101 @@
+/*
+ Breaks a string into lines of length n, respecting word boundaries.
+
+ This code will add line breaks to a long string, breaking it into a
+ multiline string. It breaks at spaces, unless doing so would result in a
+ line with an abnormally large gap at the end. In that case, it will
+ hypenate the last word in the line and continue on the next line.
+*/
+export function breakText(str: string, maxLineLength: number = 60): string {
+ if (maxLineLength <= 0) return str
+
+ // Split the string into words to calculate word length statistics
+ let words = str.split(' ')
+ let wordLengths = words.map((word) => word.length)
+
+ // Calculate average word length
+ let average =
+ wordLengths.reduce((sum, length) => sum + length, 0) / wordLengths.length
+
+ // Calculate standard deviation of word length
+ let sumOfSquaredDifferences = wordLengths.reduce(
+ (sum, length) => sum + Math.pow(length - average, 2),
+ 0
+ )
+ let standardDeviation = Math.sqrt(
+ sumOfSquaredDifferences / wordLengths.length
+ )
+
+ // Determine the maximum word length to allow before breaking
+ let maxWordLength = Math.min(maxLineLength, average + standardDeviation)
+
+ let lines: string[] = []
+ let line = ''
+ let word = ''
+ let indentation = ''
+ let hasDeterminedIndentation = false
+ const whitespace = /[\s\n]/
+
+ for (let i = 0; i < str.length; i++) {
+ let char = str[i]
+ word += char
+
+ if (whitespace.exec(char) || i === str.length - 1) {
+ if (!hasDeterminedIndentation) {
+ indentation += char
+ }
+ if (line.length + word.length < maxLineLength) {
+ line += word
+ word = ''
+ } else {
+ if (
+ line.length + word.length > maxLineLength &&
+ word.length > maxWordLength /* || i === str.length - 1 */
+ ) {
+ const firstCharacter = word[0]
+ const minsplit = Math.max(2, Math.floor(word.length * 0.3))
+ const maxsplit = Math.min(
+ word.length - 3,
+ Math.ceil(word.length * 0.7)
+ )
+ const middle = maxLineLength - line.length - 1
+ if (
+ firstCharacter.toLowerCase() != firstCharacter.toUpperCase() &&
+ word.length > maxWordLength &&
+ middle > minsplit &&
+ middle < maxsplit
+ ) {
+ let part = word.substring(0, middle)
+ let remaining = word.substring(middle)
+ lines.push(line + part + (remaining.length > 0 ? '-' : ''))
+ line = indentation + remaining
+ word = ''
+ } else {
+ lines.push(line.trimEnd())
+ line = indentation + word
+ word = ''
+ }
+ } else {
+ lines.push(line.trimEnd())
+ line = indentation + word
+ word = ''
+ }
+ }
+ if (char === '\n') {
+ lines.push(line.trimEnd())
+ line = ''
+ indentation = ''
+ hasDeterminedIndentation = false
+ } else {
+ if (line.length >= maxLineLength || i === str.length - 1) {
+ lines.push(line.trimEnd())
+ line = ''
+ }
+ }
+ } else {
+ hasDeterminedIndentation = true
+ }
+ }
+ if (line) lines.push(line)
+ return lines.join('\n')
+}