├── .gitignore ├── .npmignore ├── .nycrc ├── LICENSE ├── README.md ├── bin └── haiku ├── package.json ├── src ├── haiku-cli.ts ├── index.ts └── nib │ ├── index.ts │ └── nib.ts ├── test ├── haiku-cli.test.ts ├── index.test.ts ├── nib.test.ts └── upgrade-player.test.ts ├── tsconfig.all.json ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.log 4 | lib/ 5 | .nyc_output/ 6 | coverage/ 7 | checkstyle-result.xml 8 | test-result.tap 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | src/ 3 | tsconfig.test.json 4 | *.js.map 5 | 6 | node_modules/ 7 | .DS_Store 8 | *.log 9 | .vscode 10 | tmp 11 | ~test 12 | coverage/ 13 | .nyc_output/ 14 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extension": [ 3 | ".ts" 4 | ], 5 | "include": [ 6 | "src/**/*.ts" 7 | ], 8 | "reporter": [ 9 | "istanbul-reporter-cobertura-haiku", 10 | "html" 11 | ], 12 | "all": true 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Haiku Systems 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @haiku/cli 2 | 3 | > The Haiku CLI 4 | 5 | ## Installation 6 | 7 | Via yarn: 8 | 9 | ``` 10 | $ yarn global add @haiku/cli 11 | ``` 12 | 13 | Via npm: 14 | 15 | ``` 16 | $ npm install -g @haiku/cli 17 | ``` 18 | 19 | ## Usage 20 | 21 | For a list of available commands, enter: 22 | 23 | ``` 24 | $ haiku --help 25 | ``` 26 | 27 | ## License 28 | 29 | MIT. See LICENSE. 30 | 31 | ## Copyright 32 | 33 | Copyright (c) 2017 Haiku Systems 34 | -------------------------------------------------------------------------------- /bin/haiku: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var cli = require('../lib/haiku-cli').cli; 4 | cli.run() 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@haiku/cli", 3 | "version": "5.1.1", 4 | "description": "Haiku CLI", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "lint": "tslint -p tsconfig.all.json -t stylish", 8 | "lint-report": "yarn lint -t checkstyle -o checkstyle-result.xml", 9 | "fix": "yarn lint --fix", 10 | "ts": "cross-env NODE_ENV=test ts-node -r tsconfig-paths/register -P tsconfig.all.json", 11 | "test": "yarn ts ./node_modules/.bin/tape 'test/**/*.test.ts' | tap-spec", 12 | "test-report": "nyc yarn ts ./node_modules/.bin/tape 'test/**/*.test.ts' > test-result.tap", 13 | "compile": "tsc", 14 | "develop": "tsc --watch" 15 | }, 16 | "bin": { 17 | "haiku": "./bin/haiku" 18 | }, 19 | "authors": [ 20 | "Zack Brown ", 21 | "Matthew Trost " 22 | ], 23 | "files": [ 24 | "bin/**/*", 25 | "lib/**/*", 26 | "src/**/*" 27 | ], 28 | "license": "MIT", 29 | "dependencies": { 30 | "@haiku/sdk-client": "5.1.1", 31 | "@haiku/sdk-inkstone": "5.1.1", 32 | "async": "^2.5.0", 33 | "chalk": "^2.4.2", 34 | "cli-color": "^1.2.0", 35 | "dedent": "^0.7.0", 36 | "fs-extra": "^4.0.2", 37 | "hasbin": "^1.2.3", 38 | "inquirer": "^3.0.6", 39 | "lodash": "^4.17.4", 40 | "mkdirp": "^0.5.1", 41 | "prepend-file": "^1.3.1", 42 | "request": "^2.83.0", 43 | "semver": "^5.4.1", 44 | "tail": "^1.2.1", 45 | "yargs": "^6.6.0" 46 | }, 47 | "devDependencies": { 48 | "@types/cli-color": "^0.3.29", 49 | "@types/inquirer": "0.0.32", 50 | "@types/mkdirp": "^0.3.29", 51 | "@types/request": "^2.0.0", 52 | "@types/yargs": "^6.5.0", 53 | "cross-env": "^5.1.6", 54 | "istanbul-reporter-cobertura-haiku": "^1.0.2", 55 | "nyc": "^13.0.1", 56 | "tap-spec": "^4.1.2", 57 | "tape": "^4.9.0", 58 | "ts-node": "^6.1.0", 59 | "tsconfig-paths": "^3.3.2", 60 | "tslint": "^5.11.0", 61 | "tslint-config-haiku": "^1.0.16", 62 | "typescript": "^3.0.3", 63 | "leaked-handles": "^5.2.0", 64 | "haiku-testing": "5.1.1" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/haiku-cli.ts: -------------------------------------------------------------------------------- 1 | import {client} from '@haiku/sdk-client'; 2 | import {inkstone} from '@haiku/sdk-inkstone'; 3 | import {ErrorCode} from '@haiku/sdk-inkstone/lib/errors'; 4 | 5 | import { 6 | DEFAULT_BRANCH_NAME, 7 | fetchProjectConfigInfo, 8 | getHaikuComponentInitialVersion, 9 | storeConfigValues, 10 | } from '@haiku/sdk-client/lib/ProjectDefinitions'; 11 | 12 | import {bootstrapSceneFilesSync} from '@haiku/sdk-client/lib/bootstrapSceneFilesSync'; 13 | import {createProjectFiles} from '@haiku/sdk-client/lib/createProjectFiles'; 14 | 15 | import chalk from 'chalk'; 16 | import {execSync} from 'child_process'; 17 | import * as dedent from 'dedent'; 18 | import * as fs from 'fs'; 19 | // @ts-ignore 20 | import * as hasbin from 'hasbin'; 21 | import * as inquirer from 'inquirer'; 22 | import * as _ from 'lodash'; 23 | import * as path from 'path'; 24 | // @ts-ignore 25 | import * as prependFile from 'prepend-file'; 26 | 27 | import {IContext, Nib} from './nib'; 28 | 29 | // tslint:disable-next-line:no-var-requires 30 | const pkg = require('./../package.json'); 31 | 32 | const cli = new Nib({ 33 | name: 'haiku', 34 | version: pkg.version, 35 | description: 'The Haiku CLI — developer utilities for automating Haiku actions and performing local and' + 36 | ' server-enabled actions without requiring the desktop app.', 37 | preAction (context: IContext) { 38 | client.config.getenv(); 39 | }, 40 | commands: [ 41 | { 42 | name: 'list', 43 | action: doList, 44 | flags: [ 45 | { 46 | name: 'organizations', 47 | defaultValue: undefined, 48 | description: 'include to list organizations your account is a member of instead of projects', 49 | }, 50 | ], 51 | description: 'Lists your team projects', 52 | }, 53 | { 54 | name: 'change-password', 55 | action: doChangePassword, 56 | description: 'Changes your Haiku account password (interactive)', 57 | }, 58 | { 59 | name: 'clone', 60 | action: doClone, 61 | description: 'Clone a Haiku project to your filesystem, passing through to git clone', 62 | args: [ 63 | { 64 | name: 'project-name', 65 | required: true, 66 | usage: 'Clone a Haiku project to your filesystem, passing through to git clone', 67 | }, 68 | { 69 | name: 'destination', 70 | required: false, 71 | usage: 'Optional: location on the file system where the project should be cloned', 72 | }, 73 | ], 74 | }, 75 | { 76 | name: 'delete', 77 | action: doDelete, 78 | description: 'Deletes a Haiku project for your entire team. Cannot be undone.', 79 | args: [ 80 | { 81 | name: 'project-name', 82 | required: false, 83 | usage: 'Specifies the name of the project to delete (case-sensitive.) If this isn\'t provided, the action' + 84 | ' will be interactive.', 85 | }, 86 | ], 87 | }, 88 | { 89 | name: 'init', 90 | action: doInit, 91 | description: 'Inits a project for installing @haiku modules. ' + 92 | 'Will write or append to a .npmrc in this directory.', 93 | }, 94 | { 95 | name: 'install', 96 | action: doInstall, 97 | description: 'Install a Haiku project as an npm module, requires a package.json', 98 | args: [ 99 | { 100 | name: 'project-name', 101 | required: true, 102 | usage: 'Specifies the name of the project to install as a dependency. Case-sensitive.', 103 | }, 104 | ], 105 | }, 106 | { 107 | name: 'login', 108 | action: doLogin, 109 | description: 'Logs into Haiku services. (interactive)', 110 | }, 111 | { 112 | name: 'logout', 113 | action: doLogout, 114 | description: 'Logs out of Haiku services.', 115 | }, 116 | { 117 | name: 'update', 118 | aliases: ['upgrade'], 119 | args: [ 120 | { 121 | name: 'project-name', 122 | required: false, 123 | usage: 'Specifies the name of the project to update as a dependency. Case-sensitive. If not provided,' + 124 | ' will update all detected Haiku projects.', 125 | }, 126 | { 127 | name: 'version', 128 | required: false, 129 | usage: 'Specifies the version to update specified dependency to. If not provided, will update to the' + 130 | ' latest available version.', 131 | }, 132 | ], 133 | action: doUpdate, 134 | description: 'Updates dependencies', 135 | }, 136 | { 137 | name: 'generate', 138 | aliases: ['g'], 139 | args: [ 140 | { 141 | name: 'component-name', 142 | required: true, 143 | usage: 'Specifies the name of new component to be generated. Case-sensitive and must be unique.', 144 | }, 145 | ], 146 | action: generateComponent, 147 | description: 'Generate new component', 148 | }, 149 | ], 150 | }); 151 | 152 | export {cli}; 153 | 154 | function ensureAuth (context: IContext, cb: (authToken: string) => void) { 155 | const authToken: string = client.config.getAuthToken(); 156 | if (authToken) { 157 | inkstone.setConfig({authToken}); 158 | cb(authToken); 159 | return; 160 | } 161 | 162 | context.writeLine('You must be authenticated to do that.'); 163 | doLogin(context, () => { 164 | const newToken: string = client.config.getAuthToken(); 165 | if (newToken) { 166 | inkstone.setConfig({authToken: newToken}); 167 | cb(newToken); 168 | return; 169 | } 170 | 171 | context.writeLine('Hm, that didn\'t work. Let\'s try again.'); 172 | ensureAuth(context, cb); 173 | }); 174 | } 175 | 176 | function doChangePassword (context: IContext) { 177 | ensureAuth(context, (token) => { 178 | inquirer.prompt([ 179 | { 180 | type: 'password', 181 | name: 'OldPassword', 182 | message: 'Old Password:', 183 | }, 184 | { 185 | type: 'password', 186 | name: 'NewPassword', 187 | message: 'New Password:', 188 | }, 189 | { 190 | type: 'password', 191 | name: 'NewPassword2', 192 | message: 'New Password (confirm):', 193 | }, 194 | ]).then((answers: inquirer.Answers) => { 195 | if (answers.NewPassword !== answers.NewPassword2) { 196 | context.writeLine(chalk.red('New passwords do not match.')); 197 | process.exit(1); 198 | } 199 | 200 | const params: inkstone.user.ChangePasswordParams = { 201 | OldPassword: answers.OldPassword, 202 | NewPassword: answers.NewPassword, 203 | }; 204 | 205 | inkstone.user.changePassword(token, params, (err, responseBody, response) => { 206 | if (err) { 207 | context.writeLine(chalk.bold('Unable to change password: ') + err); 208 | process.exit(1); 209 | } else { 210 | context.writeLine(chalk.green('Password updated.')); 211 | } 212 | }); 213 | }); 214 | }); 215 | } 216 | 217 | function doClone (context: IContext) { 218 | const projectName = context.args['project-name']; 219 | let destination = context.args.destination || projectName; 220 | if (destination.charAt(destination.length - 1) !== '/') { 221 | destination += '/'; 222 | } 223 | 224 | ensureAuth(context, (token) => { 225 | context.writeLine('Cloning project...'); 226 | inkstone.project.get({Name: projectName}, (getByNameErr, projectAndCredentials) => { 227 | if (getByNameErr) { 228 | switch (getByNameErr.message) { 229 | case ErrorCode.ErrorCodeProjectNotFound: 230 | context.writeLine(chalk.bold(`Project ${projectName} not found.`)); 231 | break; 232 | case ErrorCode.ErrorCodeProjectNameRequired: 233 | context.writeLine(chalk.bold('Project name is required.')); 234 | break; 235 | } 236 | process.exit(1); 237 | } 238 | 239 | client.git.cloneRepo(projectAndCredentials.RepositoryUrl, destination, (cloneErr) => { 240 | if (cloneErr) { 241 | context.writeLine(chalk.red('Error cloning project. Use the --verbose flag for more information.')); 242 | process.exit(1); 243 | } else { 244 | context.writeLine(`Project ${chalk.bold(projectName)} cloned to ${chalk.bold(destination)}`); 245 | process.exit(0); 246 | } 247 | }); 248 | }); 249 | }); 250 | } 251 | 252 | function doDelete (context: IContext) { 253 | ensureAuth(context, (token: string) => { 254 | context.writeLine(chalk.bold('Please note that deleting this project will delete it for your entire team.')); 255 | context.writeLine(chalk.red('Deleting a project cannot be undone!')); 256 | 257 | const actuallyDelete = (finalProjectName: string) => { 258 | inkstone.project.deleteByName({Name: finalProjectName}, (err) => { 259 | if (err) { 260 | context.writeLine(chalk.red('Error deleting project. Does this project exist?')); 261 | process.exit(1); 262 | } else { 263 | context.writeLine(chalk.green('Project deleted!')); 264 | process.exit(0); 265 | } 266 | }); 267 | }; 268 | 269 | let projectName = context.args['project-name']; 270 | 271 | if (projectName) { 272 | actuallyDelete(projectName); 273 | } else { 274 | inquirer 275 | .prompt([ 276 | { 277 | type: 'input', 278 | name: 'name', 279 | message: 'Project Name:', 280 | }, 281 | ]) 282 | .then((answers: inquirer.Answers) => { 283 | projectName = answers.name; 284 | context.writeLine('Deleting project...'); 285 | actuallyDelete(projectName); 286 | }); 287 | } 288 | }); 289 | } 290 | 291 | function doInit (context: IContext) { 292 | // Set up @haiku scope for this project if it doesn't exist 293 | let npmrc = ''; 294 | try { 295 | npmrc = fs.readFileSync('.npmrc').toString(); 296 | } catch (exception) { 297 | if (exception.code === 'ENOENT') { 298 | // file not found, this is fine 299 | } else { 300 | // different error, should throw 301 | throw (exception); 302 | } 303 | } 304 | if (npmrc.indexOf('@haiku') === -1) { 305 | prependFile.sync('.npmrc', dedent` 306 | //reservoir.haiku.ai:8910/:_authToken= 307 | @haiku:registry=https://reservoir.haiku.ai:8910/\n 308 | `); 309 | } 310 | } 311 | 312 | function doInstall (context: IContext) { 313 | const projectName = context.args['project-name']; 314 | ensureAuth(context, () => { 315 | // ensure that npm is installed 316 | hasbin('npm', (result: boolean) => { 317 | if (result) { 318 | // ensure that there's a package.json in this directory 319 | if (fs.existsSync(process.cwd() + '/package.json')) { 320 | context.writeLine('Installing ' + projectName + '...'); 321 | 322 | const packageJson = client.npm.readPackageJson(); 323 | 324 | if (!packageJson.dependencies) { 325 | packageJson.dependencies = {}; 326 | } 327 | 328 | // construct project string: @haiku/org-project#latest 329 | let projectString = '@haiku/'; 330 | inkstone.organization.list((listErr, orgs) => { 331 | if (listErr) { 332 | context.writeLine( 333 | chalk.red('There was an error retrieving your account information.') + 334 | ' Please ensure that you have internet access.' + 335 | ' If this problem persists, please contact support@haiku.ai and tell us that you don\'t have an' + 336 | ' organization associated with your account.', 337 | ); 338 | process.exit(1); 339 | } 340 | 341 | // TODO: for multi-org support, get the org name more intelligently than this 342 | projectString += orgs[0].Name.toLowerCase() + '-'; 343 | 344 | inkstone.project.get({Name: projectName}, (getByNameErr, projectAndCredentials) => { 345 | if (getByNameErr) { 346 | context.writeLine( 347 | chalk.red('That project wasn\'t found.') + 348 | ' Note that project names are CaseSensitive. ' + 349 | 'Please ensure that you have the correct project name, that you\'re logged into the correct' + 350 | ' account, and that you have internet access.', 351 | ); 352 | process.exit(1); 353 | } 354 | 355 | projectString += projectAndCredentials.Name.toLowerCase(); 356 | 357 | // now projectString should be @haiku/org-project 358 | packageJson.dependencies[projectString] = 'latest'; 359 | 360 | // Set up @haiku scope for this project if it doesn't exist 361 | doInit(context); 362 | 363 | client.npm.writePackageJson(packageJson); 364 | try { 365 | execSync('npm install'); 366 | } catch (e) { 367 | context.writeLine(`${chalk.red('npm install failed.')} Your Haiku packages have been injected` + 368 | ' into package.json, but npm install failed. Please try again.'); 369 | process.exit(1); 370 | } 371 | 372 | context.writeLine(chalk.green('Haiku project installed successfully.')); 373 | process.exit(0); 374 | }); 375 | 376 | }); 377 | 378 | } else { 379 | context.writeLine(chalk.red('haiku install can only be used at the root of a project with a package.json.')); 380 | context.writeLine('You can use ' + chalk.bold('haiku clone ProjectName [/Optional/Destination]') + 381 | ' to clone the project\'s git repo directly.'); 382 | process.exit(1); 383 | } 384 | } else { 385 | context.writeLine(chalk.red('npm was not found on this machine. ') + 386 | ' We recommend installing it with nvm: https://github.com/creationix/nvm'); 387 | process.exit(1); 388 | } 389 | }); 390 | 391 | }); 392 | } 393 | 394 | function doList (context: IContext) { 395 | 396 | ensureAuth(context, () => { 397 | if (context.flags.organizations) { 398 | inkstone.organization.list((err, organizations, resp) => { 399 | if (organizations === undefined || organizations.length === 0) { 400 | context.writeLine('You are not a member of any organizations.'); 401 | } else { 402 | context.writeLine(chalk.cyan('Your Organizations:')); 403 | _.forEach(organizations, (org) => { 404 | context.writeLine(' ' + org.Name); 405 | }); 406 | } 407 | process.exit(0); 408 | }); 409 | } else { 410 | inkstone.project.list((err, projects) => { 411 | if (!projects || projects.length === 0) { 412 | context.writeLine('No existing projects. Use ' + chalk.bold('haiku generate') + ' to make a new one!'); 413 | process.exit(0); 414 | } else { 415 | context.writeLine(chalk.cyan('Your team\'s Haiku projects:')); 416 | context.writeLine('(To work with one, call ' + chalk.bold('haiku clone project_name') + ' or ' + 417 | chalk.bold('haiku install project_name')); 418 | _.forEach(projects, (project) => { 419 | context.writeLine(' ' + project.Name); 420 | }); 421 | process.exit(0); 422 | } 423 | }); 424 | } 425 | }); 426 | } 427 | 428 | function doLogin (context: IContext, cb?: () => void) { 429 | context.writeLine('Enter your Haiku credentials.'); 430 | let username = ''; 431 | let password = ''; 432 | 433 | inquirer.prompt([ 434 | { 435 | type: 'input', 436 | name: 'username', 437 | message: 'Email:', 438 | }, 439 | { 440 | type: 'password', 441 | name: 'password', 442 | message: 'Password:', 443 | }, 444 | ]).then((answers: inquirer.Answers) => { 445 | username = answers.username; 446 | password = answers.password; 447 | 448 | inkstone.user.authenticate(username, password, (err, authResponse, httpResponse) => { 449 | if (err !== undefined) { 450 | if (httpResponse && httpResponse.statusCode === 403) { 451 | context.writeLine(chalk.bold.yellow('You must verify your email address before logging in.')); 452 | } else { 453 | context.writeLine(chalk.bold.red('Username or password incorrect.')); 454 | } 455 | if (context.flags.verbose) { 456 | context.writeLine(err.toString()); 457 | } 458 | } else { 459 | client.config.setAuthToken(authResponse.Token); 460 | context.writeLine(chalk.bold.green(`Welcome ${username}!`)); 461 | } 462 | if (cb) { 463 | cb(); 464 | } else { 465 | process.exit(0); 466 | } 467 | }); 468 | }); 469 | } 470 | 471 | function doLogout () { 472 | // TODO: expire auth token on inkstone? 473 | client.config.setAuthToken(''); 474 | process.exit(0); 475 | } 476 | 477 | // TODO: update only @haiku packages, instead of all updatable packages in package.json 478 | function doUpdate (context: IContext) { 479 | hasbin('npm', (result: boolean) => { 480 | if (result) { 481 | try { 482 | context.writeLine('Updating packages...'); 483 | execSync('npm update'); 484 | context.writeLine(chalk.green('Haiku packages updated successfully.')); 485 | process.exit(0); 486 | } catch (e) { 487 | context.writeLine(chalk.red('npm update failed.') + 488 | ' This may be a configuration issue with npm. Try running npm install and then running haiku update again.'); 489 | process.exit(1); 490 | } 491 | } else { 492 | context.writeLine(chalk.red('npm was not found on this machine. ') + 493 | ' We recommend installing it with nvm: https://github.com/creationix/nvm'); 494 | process.exit(1); 495 | } 496 | }); 497 | } 498 | 499 | function generateComponent (context: IContext) { 500 | const componentName = context.args['component-name']; 501 | 502 | context.writeLine('Creating component...'); 503 | 504 | const projectPath = path.join(process.cwd(), componentName); 505 | const projectName = componentName; 506 | 507 | const authorName: string = null; 508 | const organizationName: string = null; 509 | 510 | storeConfigValues(projectPath, { 511 | username: authorName, 512 | branch: DEFAULT_BRANCH_NAME, 513 | version: getHaikuComponentInitialVersion(), 514 | organization: organizationName, 515 | project: projectName, 516 | }); 517 | 518 | const projectOptions = { 519 | organizationName, 520 | projectName, 521 | projectPath, 522 | authorName, 523 | skipContentCreation: false, 524 | }; 525 | 526 | createProjectFiles(projectOptions, () => { 527 | context.writeLine('Created initial project files'); 528 | fetchProjectConfigInfo(projectPath, (err: Error|null, userconfig: any) => { 529 | if (err) { 530 | throw err; 531 | } 532 | bootstrapSceneFilesSync(projectPath, 'main', userconfig); 533 | context.writeLine('Created main component'); 534 | }); 535 | }); 536 | 537 | context.writeLine('Project created'); 538 | process.exit(0); 539 | 540 | } 541 | 542 | // see ./unimplemented.txt for incomplete player upgrade logic 543 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {cli} from './haiku-cli'; 2 | cli.run(); 3 | -------------------------------------------------------------------------------- /src/nib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './nib'; 2 | -------------------------------------------------------------------------------- /src/nib/nib.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import {argv} from 'yargs'; 3 | 4 | export type NibAction = (context: IContext) => void; 5 | 6 | export interface Command { 7 | name: string; 8 | description?: string; 9 | aliases?: string[]; 10 | usage?: string; 11 | action?: NibAction; 12 | subcommands?: Command[]; 13 | args?: ArgumentDefinition[]; 14 | flags?: FlagDefinition[]; 15 | } 16 | 17 | export interface FlagDefinition { 18 | name: string; 19 | defaultValue?: string; 20 | description: string; 21 | } 22 | 23 | export interface ArgumentDefinition { 24 | name: string; 25 | required: boolean; 26 | usage: string; 27 | } 28 | 29 | export interface NibOptions { 30 | commands: Command[]; 31 | name: string; 32 | version: string; 33 | description?: string; 34 | preAction?: NibAction; 35 | } 36 | 37 | /** 38 | * Simple declarative CLI parser and microruntime. 39 | */ 40 | export class Nib { 41 | 42 | private options: NibOptions; 43 | private rootContext: IContext; 44 | 45 | constructor (options: NibOptions) { 46 | const args = argv._; 47 | const flags = _.clone(argv); 48 | delete flags._; 49 | delete flags.$0; 50 | this.rootContext = new Context( 51 | args, 52 | flags, 53 | console, 54 | ); 55 | this.options = options; 56 | // user needs to call .run() 57 | } 58 | 59 | /** 60 | * Kicks off CLI process. Separated from constructor for ease of testing. 61 | * @param mockContext optional override IContext, useful for testing 62 | */ 63 | run (mockContext?: IContext) { 64 | if (mockContext) { 65 | this.rootContext = mockContext; 66 | } 67 | this.interpretContext(this.rootContext); 68 | } 69 | 70 | private usage (head: string[], command: Command | Command[], context: IContext) { 71 | const topLevel = head.length === 0; 72 | const vals: { 73 | name?: string, 74 | usage?: string, 75 | version?: string, 76 | commands?: string, 77 | options?: string, 78 | } = { 79 | version: this.options.version, 80 | }; 81 | 82 | // Different values for 'top-level' help vs. nested help. 83 | if (topLevel) { 84 | vals.name = this.options.name + ((this.options.description && ' - ' + this.options.description) || ''); 85 | vals.usage = this.options.name + ' [global options] command [command options] [arguments...]'; 86 | vals.commands = ''; 87 | (command as Command[]).forEach((cmd) => { 88 | vals.commands += ` ${cmd.name + 89 | (cmd.aliases && cmd.aliases.length ? ',' + cmd.aliases.join(', ') : '')} ${cmd.description || ''}\n`; 90 | }); 91 | } else { 92 | const castCmd = command as Command; 93 | const desc = castCmd.description; 94 | vals.name = castCmd.name + ((desc && ' - ' + desc) || ''); 95 | const requiredArgs = _.filter( 96 | castCmd.args, 97 | (arg) => arg.required, 98 | ); 99 | const nonRequiredArgs = _.filter( 100 | castCmd.args, 101 | (arg) => !arg.required, 102 | ); 103 | vals.usage = castCmd.usage || (() => { 104 | let ret = this.options.name + ' ' + head.join(' '); 105 | _.forEach( 106 | requiredArgs, 107 | (arg) => { 108 | ret += ' <' + arg.name + '>'; 109 | }, 110 | ); 111 | if (nonRequiredArgs && nonRequiredArgs.length) { 112 | ret += ' ['; 113 | _.forEach( 114 | nonRequiredArgs, 115 | (arg) => { 116 | ret += ' <' + arg.name + '>'; 117 | }, 118 | ); 119 | ret += ' ]'; 120 | } 121 | if (castCmd.flags && castCmd.flags.length) { 122 | ret += ' [ '; 123 | _.forEach( 124 | castCmd.flags, 125 | (flag) => { 126 | ret += '--' + flag.name + ' '; 127 | }, 128 | ); 129 | ret += ']'; 130 | } 131 | return ret; 132 | })(); 133 | vals.commands = ''; 134 | (castCmd.subcommands || []).forEach((cmd) => { 135 | vals.commands += 136 | ` ${cmd.name + (cmd.aliases && cmd.aliases.length ? ',' + cmd.aliases.join(', ') : '')}${cmd.description && 137 | ' - ' + cmd.description}\n`; 138 | }); 139 | vals.options = ''; 140 | (castCmd.flags || []).forEach((flag) => { 141 | vals.options += 142 | ` --${flag.name + (flag.defaultValue ? '[=' + flag.defaultValue + '],' : '')} ${flag.description}\n`; 143 | }); 144 | } 145 | 146 | // TODO: Smarter printing/formatting, perhaps with a proper template or some printf bizness. 147 | context.writeLine(` 148 | NAME: 149 | ${vals.name || ''} 150 | 151 | USAGE: 152 | ${vals.usage || ''} 153 | 154 | VERSION: 155 | ${vals.version || ''}${vals.commands && '\n\nCOMMANDS:'} 156 | ${vals.commands || ''}${vals.options && '\nOPTIONS:' || ''} 157 | ${vals.options || ''}`); 158 | 159 | } 160 | 161 | /** 162 | * Runs the CLI process by executing the provided context 163 | * @param context IContext specifying flags, args, etc. 164 | */ 165 | private interpretContext (context: IContext) { 166 | const head = []; 167 | const arg = context.argList.shift(); 168 | if (arg) { 169 | head.push(arg); 170 | } 171 | 172 | if (this.options.preAction) { 173 | this.options.preAction(context); 174 | } 175 | 176 | const commands = this.options.commands; 177 | const matchedCommand = _.find( 178 | commands, 179 | (cmd: Command) => { 180 | return cmd.name === arg; 181 | }, 182 | ); 183 | if (matchedCommand) { 184 | this.evaluateCommand( 185 | matchedCommand, 186 | context, 187 | head, 188 | ); 189 | } else { 190 | if (context.flags.help !== undefined) { 191 | this.usage( 192 | head, 193 | commands, 194 | context, 195 | ); 196 | context.exit(0); 197 | } else { 198 | this.usage( 199 | [], 200 | commands, 201 | context, 202 | ); 203 | context.exit(1); 204 | } 205 | } 206 | } 207 | 208 | /** 209 | * Recursive function for determining whether a command 210 | * should be executed or traversed 211 | * @param command 212 | * @param context 213 | * @param head 214 | */ 215 | private evaluateCommand (command: Command, context: IContext, head: string[]) { 216 | let evaluatingSubcommand = false; 217 | const args = context.argList; 218 | if (command.subcommands && args.length) { 219 | const arg = args[0]; 220 | const matchedCommand = _.find( 221 | command.subcommands, 222 | (cmd: Command) => { 223 | return cmd.name === arg || cmd.aliases.indexOf(arg) > -1; 224 | }, 225 | ); 226 | if (matchedCommand) { 227 | evaluatingSubcommand = true; 228 | head.push(context.argList.shift()); 229 | this.evaluateCommand( 230 | matchedCommand, 231 | context, 232 | head, 233 | ); 234 | } 235 | } 236 | if (!evaluatingSubcommand) { 237 | const requiredArgs = _.filter( 238 | command.args, 239 | (arg) => { 240 | return arg.required; 241 | }, 242 | ); 243 | if (requiredArgs.length > args.length) { 244 | // TODO: check `required`. 245 | context.writeLine('Too few arguments.'); 246 | this.usage( 247 | head, 248 | command, 249 | context, 250 | ); 251 | context.exit(1); 252 | } else if (command.args) { 253 | _.forEach( 254 | command.args, 255 | (arg: ArgumentDefinition, i) => { 256 | context.args[arg.name] = args[i]; 257 | }, 258 | ); 259 | } 260 | 261 | _.forEach( 262 | command.flags, 263 | (flagDef: FlagDefinition) => { 264 | if (!context.flags[flagDef.name]) { 265 | context.flags[flagDef.name] = flagDef.defaultValue; 266 | } 267 | }, 268 | ); 269 | 270 | if (context.flags.help !== undefined) { 271 | this.usage( 272 | head, 273 | command, 274 | context, 275 | ); 276 | context.exit(0); 277 | } else { 278 | command.action(context); 279 | } 280 | } 281 | } 282 | } 283 | 284 | export interface IContext { 285 | argList: string[]; 286 | args: {[key: string]: string}; 287 | flags: {[key: string]: string}; 288 | exit: (code: number) => void; 289 | writeLine: (string: string) => void; 290 | } 291 | 292 | /** 293 | * Data structure for the CLI command data and metadata. This default instance of IContext 294 | * is used to run Nib — for testing, a custom IContext can be created for mock data and behavior 295 | */ 296 | export class Context implements IContext { 297 | args = {}; 298 | 299 | constructor ( 300 | readonly argList: string[], 301 | readonly flags: {[key: string]: string}, 302 | readonly logger?: {log: (...args: any[]) => void}, 303 | readonly mockMode = false, 304 | ) {} 305 | 306 | exit (code: number) { 307 | if (!this.mockMode) { 308 | process.exit(code); 309 | } 310 | } 311 | 312 | writeLine (string: string) { 313 | this.logger.log(string); 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /test/haiku-cli.test.ts: -------------------------------------------------------------------------------- 1 | import * as tape from 'tape'; 2 | 3 | tape('haiku-cli:list', (t) => { 4 | // Stub in 4d48b78. 5 | t.plan(1); 6 | t.ok(true, 'UNIMPLEMENTED'); 7 | }); 8 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as tape from 'tape'; 2 | 3 | tape('index', (t) => { 4 | t.plan(1); 5 | t.ok(true); 6 | }); 7 | -------------------------------------------------------------------------------- /test/nib.test.ts: -------------------------------------------------------------------------------- 1 | import * as tape from 'tape'; 2 | 3 | import {Context, Nib} from '@cli/nib'; 4 | 5 | function runBasicCommand (mockContext) { 6 | const nib = new Nib({ 7 | name: 'basic', 8 | version: '0.0.0', 9 | commands: [ 10 | { 11 | name: 'thing1', 12 | action: (context) => { 13 | context.writeLine('success'); 14 | }, 15 | usage: 'basic thing1 [arg1] [arg2]', 16 | }, 17 | ], 18 | }); 19 | nib.run(mockContext); 20 | } 21 | 22 | tape( 23 | 'nib:basic-command', 24 | (t) => { 25 | t.plan(1); 26 | 27 | let results = ''; 28 | 29 | const mockContext = new Context( 30 | ['thing1'], 31 | {}, 32 | { 33 | log: (output) => { 34 | console.log(output); 35 | results = output; 36 | }, 37 | }, 38 | true, 39 | ); 40 | 41 | runBasicCommand(mockContext); 42 | 43 | t.equal( 44 | results, 45 | 'success', 46 | 'basic commands should execute', 47 | ); 48 | }, 49 | ); 50 | 51 | tape( 52 | 'nib:basic-usage-banner', 53 | (t) => { 54 | t.plan(2); 55 | 56 | let results = ''; 57 | 58 | const mockContext = new Context( 59 | [], 60 | {help: ''}, 61 | { 62 | log: (output) => { 63 | console.log(output); 64 | results = output; 65 | }, 66 | }, 67 | true, 68 | ); 69 | 70 | runBasicCommand(mockContext); 71 | 72 | t.ok(results.indexOf('[global options]') !== -1, 'top level usage banner should run'); 73 | t.ok(results.indexOf('0.0.0') !== -1, 'top level usage banner should include version'); 74 | }, 75 | ); 76 | 77 | function runNestedCommand (mockContext) { 78 | const nib = new Nib({ 79 | name: 'tree', 80 | version: '0.0.0', 81 | commands: [ 82 | { 83 | name: 'nest', 84 | subcommands: [ 85 | { 86 | name: 'egg', 87 | action: (context) => { 88 | context.writeLine('nest egg success'); 89 | }, 90 | usage: 'tree nest egg [arg1] [arg1]', 91 | flags: [ 92 | { 93 | name: 'twiggy', 94 | defaultValue: 'true', 95 | description: 'set desired twigginess', 96 | }, 97 | { 98 | name: 'branchy', 99 | description: 'set desired branchiness', 100 | }, 101 | ], 102 | }, 103 | ], 104 | usage: 'tree nest [arg1]', 105 | }, 106 | ], 107 | }); 108 | nib.run(mockContext); 109 | } 110 | 111 | tape( 112 | 'nib:nested-command', 113 | (t) => { 114 | t.plan(1); 115 | 116 | let results = ''; 117 | const mockContext = new Context( 118 | ['nest', 'egg'], 119 | {}, 120 | { 121 | log: (output) => { 122 | console.log(output); 123 | results = output; 124 | }, 125 | }, 126 | true, 127 | ); 128 | 129 | runNestedCommand(mockContext); 130 | t.equal( 131 | results, 132 | 'nest egg success', 133 | 'nested commands should run', 134 | ); 135 | }, 136 | ); 137 | 138 | tape( 139 | 'nib:nested-usage-banner', 140 | (t) => { 141 | t.plan(1); 142 | 143 | let results = ''; 144 | const mockContext = new Context( 145 | ['nest', 'egg'], 146 | {help: ''}, 147 | { 148 | log: (output) => { 149 | console.log(output); 150 | results = output; 151 | }, 152 | }, 153 | true, 154 | ); 155 | 156 | runNestedCommand(mockContext); 157 | t.ok(results.indexOf('tree nest egg [arg1] [arg1]') !== -1, 'nested commands should show usage banner'); 158 | }, 159 | ); 160 | 161 | function runFlagCommand (mockContext) { 162 | const nib = new Nib({ 163 | name: 'flag', 164 | version: '0.0.0', 165 | commands: [ 166 | { 167 | name: 'check-default', 168 | action: (context) => { 169 | context.writeLine(context.flags.a); 170 | }, 171 | flags: [ 172 | { 173 | name: 'a', 174 | defaultValue: 'hello', 175 | description: 'defaults to hello', 176 | }, 177 | ], 178 | usage: 'flag check-default', 179 | }, 180 | { 181 | name: 'check-non-default', 182 | action: (context) => { 183 | context.writeLine(context.flags.b); 184 | }, 185 | flags: [ 186 | { 187 | name: 'b', 188 | description: 'no default value', 189 | }, 190 | ], 191 | usage: 'flag check-non-default', 192 | }, 193 | ], 194 | }); 195 | nib.run(mockContext); 196 | } 197 | 198 | tape( 199 | 'nib:flag-defaults', 200 | (t) => { 201 | t.plan(4); 202 | 203 | let results = ''; 204 | let mockContext = new Context( 205 | ['check-default'], 206 | {}, 207 | { 208 | log: (output) => { 209 | console.log(output); 210 | results = output; 211 | }, 212 | }, 213 | true, 214 | ); 215 | 216 | runFlagCommand(mockContext); 217 | t.ok(results === 'hello', 'default flags should have values'); 218 | 219 | mockContext = new Context( 220 | ['check-default'], 221 | {a: 'world'}, 222 | { 223 | log: (output) => { 224 | console.log(output); 225 | results = output; 226 | }, 227 | }, 228 | true, 229 | ); 230 | 231 | runFlagCommand(mockContext); 232 | t.ok(results === 'world', 'default flags should be overridable'); 233 | 234 | mockContext = new Context( 235 | ['check-non-default'], 236 | {}, 237 | { 238 | log: (output) => { 239 | console.log(output); 240 | results = output; 241 | }, 242 | }, 243 | true, 244 | ); 245 | 246 | runFlagCommand(mockContext); 247 | t.ok(results === undefined, 'non default flags should be blank when unset'); 248 | 249 | mockContext = new Context( 250 | ['check-non-default'], 251 | {b: 'world'}, 252 | { 253 | log: (output) => { 254 | console.log(output); 255 | results = output; 256 | }, 257 | }, 258 | true, 259 | ); 260 | 261 | runFlagCommand(mockContext); 262 | t.ok(results === 'world', 'non default flags should be settable'); 263 | }, 264 | ); 265 | -------------------------------------------------------------------------------- /test/upgrade-player.test.ts: -------------------------------------------------------------------------------- 1 | // Stub in 4d48b78. 2 | -------------------------------------------------------------------------------- /tsconfig.all.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "noImplicitAny": false, 6 | "baseUrl": ".", 7 | "paths": { 8 | "@cli/*": ["src/*"] 9 | } 10 | }, 11 | "include": [ 12 | "src/**/*.ts", 13 | "test/**/*.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "declaration": true, 6 | "declarationDir": "lib", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "noImplicitAny": true, 10 | "outDir": "lib", 11 | "preserveConstEnums": true, 12 | "rootDir": "src", 13 | "sourceMap": true, 14 | "suppressImplicitAnyIndexErrors": true, 15 | "target": "ESNext", 16 | "types": [ 17 | "node" 18 | ], 19 | "typeRoots": [ 20 | "node_modules/@types/" 21 | ] 22 | }, 23 | "include": [ 24 | "src/**/*.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-haiku" 4 | ] 5 | } 6 | --------------------------------------------------------------------------------