├── CHANGELOG ├── LICENSE ├── README ├── example ├── controls.js ├── deprecated.js └── taskless.js ├── index.js ├── lib ├── configurator.js ├── controller.js ├── index.js ├── log.js ├── task.js └── timestamp.js └── package.json /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.2.2 2 | - Improve logging to create new fs.WriteStream only on controller logPath change 3 | 4 | 0.2.1 5 | - Add remote process stdout/stderr custom listener support for ssh() and scp() 6 | - Add ability to write to remote process stdin 7 | - Revise documentation and make JSON notation standard config pattern 8 | 9 | 0.2.0 10 | - Modify hosts objects implementation to prototype-based implementation 11 | - Make semantic change from 'host' objects to 'controller' objects 12 | - Separate mass configuration from controller objects implementation 13 | - Tasks now get config object's id with id() as method instead of as property 14 | - Add controllers() configurator for array and JSON notation configuration 15 | - Deprecate hosts(), prints deprecation warning, will remove in future release 16 | - Change setting of log path from log to logPath on hosts (now controllers) 17 | - Extend configuration, including deprecated, examples 18 | 19 | 0.1.9 20 | - Init scpOptions as array if not configured so later logic can assume array 21 | - Document and add example of hosts() usage without tasks system 22 | 23 | 0.1.8 24 | - Add exit callbacks to host.ssh(), host.scp() for handling non-zero exit codes 25 | - Add scpOptions (like sshOptions) 26 | - Add config tasks command line arguments rewriting 27 | 28 | 0.1.7 29 | - Add host.logMask to allow masking things like passwords from command logging 30 | 31 | 0.1.6 32 | - Document sshOptions and reimplement to allow setting in config or host object 33 | - Remove undocumented host() method for setting up a single host from config 34 | - Add engines specification to package.json for node >=0.1.99 35 | 36 | 0.1.5 37 | - Remove errant console.log statement in ssh command 38 | - Log commands before launching subprocess instead of after 39 | 40 | 0.1.4 41 | - Add sshOptions to host config to allow passing options to ssh on ssh() 42 | - Add -r flag to scp() invocations to make useful for both files and directories 43 | - Tighten permissions on log file so readable only to local control user 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Thomas Smith (MIT License) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | DESCRIPTION 2 | 3 | Define tasks for system administration or code deployment, then execute them on 4 | one or many remote machines simultaneously. Strong logging creates a complete 5 | audit trail of commands executed on remote machines in logs easily analyzed by 6 | standard text manipulation tools. 7 | 8 | node-control depends only on OpenSSH and Node on the local control machine. 9 | Remote machines simply need a standard sshd daemon. 10 | 11 | 12 | 13 | QUICK EXAMPLE 14 | 15 | If you want to control remote machines from individual scripts without the 16 | tasks system, see QUICK EXAMPLE WITHOUT TASKS. Otherwise, to get the current 17 | date from the two machines listed in the 'mycluster' config with a single 18 | command: 19 | 20 | var control = require('control'), 21 | task = control.task; 22 | 23 | task('mycluster', 'Config for my cluster', function () { 24 | var config = { 25 | 'a.domain.com': { 26 | user: 'alogin' 27 | }, 28 | 'b.domain.com': { 29 | user: 'blogin', 30 | sshOptions: ['-p 44'] // sshd daemon on non-standard port 31 | } 32 | }; 33 | 34 | return control.controllers(config); 35 | }); 36 | 37 | 38 | task('date', 'Get date', function (controller) { 39 | controller.ssh('date'); 40 | }); 41 | 42 | control.begin(); 43 | 44 | 45 | If saved in a file named 'controls.js', run with: 46 | 47 | node controls.js mycluster date 48 | 49 | 50 | Each machine is contacted in parallel, date is executed, and the output from 51 | the remote machine is printed to the console. Example console output: 52 | 53 | Performing mycluster 54 | Performing date for a.domain.com 55 | a.domain.com:alogin:ssh: date 56 | Performing date for b.domain.com 57 | b.domain.com:blogin:ssh: date 58 | a.domain.com:stdout: Sun Jul 18 13:30:50 UTC 2010 59 | b.domain.com:stdout: Sun Jul 18 13:30:51 UTC 2010 60 | a.domain.com:exit: 0 61 | b.domain.com:exit: 0 62 | 63 | 64 | Each line of output is labeled with the address of the machine the command was 65 | executed on. The actual command sent and the user used to send it is 66 | displayed. stdout and stderr output of the remote process is identified 67 | as well as the final exit code of the local ssh command. Each command, stdout, 68 | stderr, and exit line also appears timestamped in a control.log file in the 69 | current working directory. 70 | 71 | See CODE DEPLOYMENT EXAMPLE for an example of deploying an application to 72 | remote servers. 73 | 74 | 75 | 76 | INSTALLATION 77 | 78 | If you use npm: 79 | 80 | npm install control 81 | 82 | If you do not use npm, clone this repository with git or download the latest 83 | version using the GitHub repository Downloads link. Then use as a standard Node 84 | module by requiring the node-control directory. 85 | 86 | 87 | 88 | EXAMPLE CONTROLS 89 | 90 | As you read this documentation, you may find it useful to refer to the 91 | example/controls.js file. Its work tasks cover a variety of advanced usage. The 92 | config tasks use your local machine as a mock remote machine or cluster, so if 93 | you run an sshd daemon locally, you can run the controls against your own 94 | machine to experiment. 95 | 96 | 97 | 98 | CONFIG TASKS 99 | 100 | When using tasks, you always identify two tasks on the command line for remote 101 | operations. The first task is the config task and the second task is the work 102 | task. Config tasks have a name, description, and function that will be called 103 | once: 104 | 105 | task('mycluster', 'Config for my cluster', function () { 106 | 107 | 108 | The config task function must return an array of controllers (objects that 109 | extend the control.controller prototype, described further in CONTROLLERS). 110 | Each controller in the array controls a single machine and optionally has its 111 | own properties. 112 | 113 | Config tasks enable definition of reusable work tasks independent of the 114 | machines they will control. For example, if you have a staging environment with 115 | different machines than your production environment, you can create two 116 | different config tasks, each returning controllers for machines in the 117 | respective environment, yet use the same deploy work task: 118 | 119 | node controls.js stage deploy ~/myapp/releases/myapp-1.0.tgz 120 | 121 | node controls.js production deploy ~/myapp/releases/myapp-1.0.tgz 122 | 123 | 124 | If all the machines in a cluster share common properties, you can extend the 125 | control.controller prototype and pass the new prototype into controllers() as 126 | the second argument. For example, if all the machines in your cluster run sshd 127 | on a non-standard port instead of just one as in QUICK EXAMPLE: 128 | 129 | task('mycluster', 'Config for my cluster', function () { 130 | var shared = Object.create(control.controller), 131 | config = { 132 | 'a.domain.com': { 133 | user: 'alogin' 134 | }, 135 | 'b.domain.com': { 136 | user: 'blogin' 137 | } 138 | }; 139 | 140 | shared.sshOptions = ['-p 44']; 141 | 142 | return control.controllers(config, shared); 143 | }); 144 | 145 | 146 | controllers() will return an array of controllers that prototypically inherit 147 | from the shared prototype instead of the base prototype, each having 148 | controller-specific properties as defined in the JSON notation. In this case, 149 | both controllers will effectively have sshOptions = ['-p 44'], but different 150 | user names. 151 | 152 | If all machines in your cluster have the same properties, can you pass an array 153 | of addresses as the first argument to controllers(). For example, if all the 154 | machines your cluster run sshd on a non-standard port and you use the same 155 | login on each: 156 | 157 | task('mycluster', 'Config for my cluster', function () { 158 | var shared = Object.create(control.controller), 159 | addresses = [ 'a.domain.com', 160 | 'b.domain.com', 161 | 'c.domain.com' ]; 162 | shared.user = 'mylogin'; 163 | shared.sshOptions = ['-p 44']; 164 | return control.controllers(addresses, shared); 165 | }); 166 | 167 | 168 | Alternatively, you can build up your list of controllers without the use of 169 | controllers(): 170 | 171 | task('mycluster', 'Config for my cluster', function () { 172 | var controllers = [], 173 | shared = Object.create(control.controller), // Extend prototype 174 | a, b; 175 | 176 | shared.sshOptions = ['p 44']; 177 | 178 | a = Object.create(shared); // Extend shared prototype 179 | a.address = 'a.domain.com'; 180 | a.user = 'alogin'; 181 | controllers.push(a); 182 | 183 | b = Object.create(shared); 184 | b.address = 'b.domain.com'; 185 | b.user = 'blogin'; 186 | controllers.push(b); 187 | 188 | return controllers; 189 | }); 190 | 191 | 192 | 193 | WORK TASKS 194 | 195 | Work tasks define logic to drive each controller returned by the config task. 196 | They have a name, description, and a callback that will execute independently 197 | and simultaneously for each controller: 198 | 199 | task('date', 'Get date', function (controller) { 200 | 201 | 202 | Arguments on the command line after the name of the work task become arguments 203 | to the work task's function. With this task: 204 | 205 | task('deploy', 'Deploy my app', function (controller, release) { 206 | 207 | 208 | This command: 209 | 210 | node controls.js stage deploy ~/myapp/releases/myapp-1.0.tgz 211 | 212 | 213 | Results in: 214 | 215 | release = '~/myapp/releases/myapp-1.0.tgz' 216 | 217 | 218 | More than one argument is possible: 219 | 220 | task('deploy', 'Deploy my app', function (controller, release, tag) { 221 | 222 | 223 | 224 | TASK EXECUTION 225 | 226 | To execute the tasks identified on the command line, use the begin() method 227 | after you have defined all your config and work tasks: 228 | 229 | var control = require('control'); 230 | ... // Define tasks 231 | control.begin(); 232 | 233 | 234 | begin() calls the first (config) task identified on the command line to get the 235 | array of controllers, then calls the second (work) task with each of the 236 | controllers. If you run a control script and nothing happens, check if the 237 | script calls begin(). 238 | 239 | 240 | 241 | CONTROLLERS 242 | 243 | node-control provides a base controller prototype as control.controller, which 244 | all controllers must extend. To create controllers, use the controllers() 245 | method described in CONFIG TASKS or extend the base controller prototype and 246 | assign the controller a DNS or IP address, user if not the same as the local 247 | user, and any other properties required by work tasks or further logic: 248 | 249 | var controller = Object.create(control.controller); 250 | controller.address = 'a.domain.com'; // Machine to control 251 | controller.user = 'mylogin'; // Username on remote machine if not same as local 252 | controller.ips = [ // Example of property used by work task or further logic 253 | '10.2.136.23', 254 | '10.2.136.24', 255 | '10.2.136.25', 256 | '10.2.136.26', 257 | '10.2.136.27' 258 | ]; 259 | 260 | 261 | The base controller prototype provides ssh() and scp() methods for 262 | communicating with a controller's assigned remote machine. 263 | 264 | The ssh() method takes one argument - the command to be executed on the 265 | remote machine. The scp method takes two arguments - the local file path and the 266 | remote file path. 267 | 268 | Both ssh() and scp() methods are asynchronous and can additionally take a 269 | callback function that is executed once the ssh or scp operation is complete. 270 | This guarantees that the first operation completes before the next one begins 271 | on that machine: 272 | 273 | controller.scp(release, remoteDir, function () { 274 | controller.ssh('tar xzvf ' + remotePath + ' -C ' + remoteDir, 275 | function () { 276 | 277 | 278 | You can chain callbacks as far as necessary. 279 | 280 | If a command returns a non-zero exit code, the scp() and ssh() methods will log 281 | the exit and exit code, but will not call the callback, ending any further 282 | operations on that machine. This avoids doing further harm where a callback may 283 | assume a successful execution of a previous command. However, you can specify 284 | an exit callback that will be called and receive the exit code if a non-zero 285 | exit occurs: 286 | 287 | function callback() { ... } 288 | function exitCallback(code) { ... } 289 | 290 | controller.ssh('date', callback, exitCallback); 291 | 292 | 293 | You can make both callbacks the same callback function if you want to check the 294 | exit code and handle both zero and non-zero exits within a single callback. 295 | 296 | 297 | 298 | CUSTOM STDOUT & STDERR LISTENERS 299 | 300 | When running a command with ssh() on a remote device, controller objects listen 301 | to the stdout and stderr of the process running on the remote device through 302 | the local ssh process, printing what is heard to console and log. You can 303 | attach your own listeners to these stdout and stderr streams to gather data to 304 | use in your callback function: 305 | 306 | task('ondate', 'Different logic paths based on date', function (controller) { 307 | var datestring = ''; 308 | 309 | controller.stdout.on('data', function (chunk) { 310 | datestring += chunk.toString(); 311 | }); 312 | 313 | controller.ssh('date', function () { 314 | console.log(' Date string is ' + datestring); 315 | // Further logic dependent on value of datestring 316 | }); 317 | }); 318 | 319 | 320 | Refer to Node's ReadableStream and EventEmitter documentation if the 321 | stdout.on() pattern looks unfamiliar. Controllers also provide a stderr.on() 322 | for attaching custom listeners to the stderr stream. 323 | 324 | You can respond to prompts and errors as they happen in the remote process 325 | through the remote process stdin stream, similar to expect. An example of 326 | responding to a prompt through the stdin of the remote process: 327 | 328 | task('stdin', 'Test controller stdin usage', function (controller) { 329 | var stdout; 330 | 331 | controller.stdout.on('data', function (chunk) { 332 | chunk = chunk.toString(); // Assumes chunks come in full lines 333 | if (chunk.match('^Enter data')) { // Assumes command uses this prompt 334 | controller.stdin.write('hello\n'); 335 | } 336 | }); 337 | 338 | controller.ssh('acommand'); 339 | }); 340 | 341 | 342 | The controller only uses custom listeners for the next ssh() or scp() call. 343 | Further ssh() or scp() calls will not attach the custom listener unless it is 344 | reattached via controller.stdout.on() or controller.stderr.on() before the next 345 | call. This avoids unanticipated usage of one-off listeners, such as filling the 346 | datestring variable in the first example with the output of every subsequent 347 | ssh() command executed by the controller. 348 | 349 | 350 | 351 | PERFORMING MULTIPLE TASKS 352 | 353 | A task can call other tasks using perform() and optionally pass arguments to 354 | them: 355 | 356 | var perform = require('control').perform; 357 | 358 | task('mytask', 'My task description', function (controller, argument) { 359 | perform('anothertask', controller, argument); 360 | 361 | 362 | perform() requires only the task name and the controller. Arguments are 363 | optional. If the other task supports it, optionally pass a callback function as 364 | one of the arguments: 365 | 366 | perform('anothertask', controller, function () { 367 | 368 | 369 | Tasks that support asynchronous performance should call the callback function 370 | when done doing their own work. For example: 371 | 372 | task('anothertask', 'My other task', function (controller, callback) { 373 | controller.ssh('date', function () { 374 | if (callback) { 375 | callback(); 376 | } 377 | }); 378 | }); 379 | 380 | 381 | The peform() call can occur anywhere in a task, not just at the beginning. 382 | 383 | 384 | 385 | LISTING TASKS 386 | 387 | To list all defined tasks with descriptions: 388 | 389 | node controls.js mycluster list 390 | 391 | 392 | 393 | NAMESPACES 394 | 395 | Use a colon, dash, or similar convention when naming if you want to group tasks 396 | by name. For example: 397 | 398 | task('bootstrap:tools', 'Bootstrap tools', function (controller) { 399 | ... 400 | task('bootstrap:compilers', 'Bootstrap compilers', function (controller) { 401 | 402 | 403 | 404 | SUDO 405 | 406 | To use sudo, include sudo as part of your command: 407 | 408 | controller.ssh('sudo date'); 409 | 410 | 411 | This requires that sudo be installed on the remote machine and have requisite 412 | permissions setup. 413 | 414 | 415 | 416 | ROLES 417 | 418 | Some other frameworks like Capistrano provide the notion of roles for different 419 | machines. node-control does not employ a separate roles construct. Since 420 | controllers can have any properties defined on them in a config task, a 421 | possible pattern for roles if needed: 422 | 423 | task('mycluster', 'Config for my cluster', function () { 424 | var dbs = Object.create(control.controller), 425 | apps = Object.create(control.controller); 426 | 427 | dbs = { 428 | user: 'dbuser', 429 | role: 'db' 430 | }; 431 | 432 | apps = { 433 | user: 'appuser', 434 | role: 'app' 435 | }; 436 | 437 | dbs = control.controllers(['db1.domain.com', 'db2.domain.com'], dbs); 438 | apps = control.controllers(['app1.domain.com', 'app2.domain.com'], apps); 439 | 440 | return dbs.concat(apps); 441 | }); 442 | 443 | task('deploy', 'Deploy my system', function (controller, release) { 444 | if (controller.role === 'db') { 445 | // Do db deploy work 446 | } 447 | 448 | if (controller.role === 'app') { 449 | // Do app deploy work 450 | } 451 | }); 452 | 453 | 454 | 455 | LOGS 456 | 457 | All commands sent and responses received are logged with timestamps (from the 458 | control machine's clock). By default, logging goes to a control.log file in the 459 | working directory of the node process. However, you can override this in your 460 | control script: 461 | 462 | task('mycluster', 'Config for my cluster', function () { 463 | var shared, addresses; 464 | shared = { 465 | user: 'mylogin', 466 | logPath: '~/mycluster-control.log' 467 | }; 468 | addresses = [ 'a.domain.com', 469 | 'b.domain.com', 470 | 'c.domain.com' ]; 471 | return control.controllers(addresses, shared); 472 | }); 473 | 474 | 475 | Since each controller gets its own log property, every controller could 476 | conceivably have its own log fie. However, every line in the log file has a 477 | prefix that includes the controller's address so, for example: 478 | 479 | grep a.domain.com control.log | less 480 | 481 | 482 | Would allow paging the log and seeing only lines pertaining to 483 | a.domain.com. 484 | 485 | 486 | If you send something you do not want to get logged (like a password) in a 487 | command, use the log mask: 488 | 489 | controller.logMask = secret; 490 | controller.ssh('echo ' + secret + ' > file.txt'); 491 | 492 | 493 | The console and command log file will show the masked text as asterisks instead 494 | of the actual text. 495 | 496 | 497 | 498 | SSH 499 | 500 | To avoid repeatedly entering passwords across possibly many machines, use 501 | standard ssh keypair authentication. 502 | 503 | Each controller.ssh() call requires a new connection to the remote machine. To 504 | configure ssh to reuse a single connection, place this: 505 | 506 | Host * 507 | ControlMaster auto 508 | ControlPath ~/.ssh/master-%r@%h:%p 509 | 510 | 511 | In your ssh config file (create if it does not exist): 512 | 513 | ~/.ssh/config 514 | 515 | 516 | To pass options to the ssh command when using ssh(), add the option or options 517 | as an array to the sshOptions property of the controller or controllers' 518 | prototype: 519 | 520 | controller.sshOptions = [ '-2', '-p 44' ]; 521 | 522 | 523 | Use scpOptions in the same manner for scp(). 524 | 525 | 526 | 527 | CONFIG TASK COMMAND LINE ARGUMENTS REWRITING 528 | 529 | Config tasks receive a reference to the array of remaining arguments on the 530 | command line after the config task name is removed. Therefore, config tasks 531 | can rewrite the command line arguments other than the config task name. Example: 532 | 533 | function configure(addresses) { 534 | var shared; 535 | shared = { 536 | user: 'mylogin' 537 | }; 538 | return control.controllers(addresses, shared); 539 | } 540 | 541 | task('mycluster', 'Config for my cluster', function () { 542 | var addresses = [ 'a.domain.com', 543 | 'b.domain.com', 544 | 'c.domain.com' ]; 545 | return configure(addresses); 546 | }); 547 | 548 | task('mymachine', 'Config for one machine from command line', function (args) { 549 | return configure([args.shift()]); // From command line arguments rewriting 550 | }); 551 | 552 | 553 | With this set of config tasks, if there is an ad hoc need to run certain tasks 554 | against a single machine in the cluster, but otherwise have identical 555 | configuration as when run as part of the cluster, the machine address can be 556 | specified on the command line: 557 | 558 | node controls.js mymachine b.domain.com mytask x 559 | 560 | 561 | In that case, the mymachine config task receives as args: 562 | 563 | ['b.domain.com', 'mytask', 'x'] 564 | 565 | 566 | This is generally not necessary since you can edit the config task in the 567 | control file at any time, but is available if config tasks need to have command 568 | line arguments or rewrite the work task name and its arguments on the fly. 569 | 570 | 571 | 572 | CODE DEPLOYMENT EXAMPLE 573 | 574 | A task that will upload a local compressed tar file containing a release of a 575 | node application to a remote machine, untar it, and start the node application. 576 | 577 | var path = require('path'); 578 | 579 | task('deploy', 'Deploy my app', function (controller, release) { 580 | var basename = path.basename(release), 581 | remoteDir = '/apps/', 582 | remotePath = path.join(remoteDir, basename), 583 | remoteAppDir = path.join(remoteDir, 'myapp'); 584 | controller.scp(release, remoteDir, function () { 585 | controller.ssh('tar xzvf ' + remotePath + ' -C ' + remoteDir, 586 | function () { 587 | controller.ssh("sh -c 'cd " + remoteAppDir + " && node myapp.js'"); 588 | }); 589 | }); 590 | }); 591 | 592 | Execute as follows, for example: 593 | 594 | node controls.js mycluster deploy ~/myapp/releases/myapp-1.0.tgz 595 | 596 | 597 | A full deployment solution would shut down the existing application and have 598 | different directory conventions. node-control does not assume a particular 599 | style or framework. It provides tools to build a custom deployment strategy for 600 | your application, system, or framework. 601 | 602 | 603 | 604 | QUICK EXAMPLE WITHOUT TASKS 605 | 606 | You can create scripts to run individually instead of through the tasks system 607 | by using controllers() to create an array of controllers and then using 608 | the controllers directly: 609 | 610 | var control = require('../'), 611 | shared = Object.create(control.controller), 612 | i, l, controller, controllers; 613 | 614 | shared.user = 'mylogin'; 615 | controllers = control.controllers(['a.domain.com', 'b.domain.com'], shared); 616 | 617 | for (i = 0, l = controllers.length; i < l; i += 1) { 618 | controller = controllers[i]; 619 | controller.ssh('date'); 620 | } 621 | 622 | 623 | If saved in a file named 'controls.js', run with: 624 | 625 | node controls.js 626 | 627 | 628 | See example/taskless.js for a working example you can run against your local 629 | machine if running a local sshd. 630 | 631 | 632 | 633 | CONTRIBUTORS 634 | 635 | * David Pratt (https://github.com/fairwinds) 636 | * Peter Lyons (https://github.com/focusaurus) 637 | 638 | 639 | 640 | FEEDBACK 641 | 642 | Welcome at node@thomassmith.com or the Node mailing list. 643 | -------------------------------------------------------------------------------- /example/controls.js: -------------------------------------------------------------------------------- 1 | /*global require, process, console */ 2 | 3 | // Example with some advanced usage: 4 | // advanced configuration 5 | // error callbacks 6 | // scpOptions 7 | // config task command line arguments rewriting 8 | // custom listeners 9 | // stdin writing 10 | // 11 | // Uses localhost as a 'remote' machine and this script recursively to simulate 12 | // exit code returns, stderr and stout output, and stdin reading on the 13 | // 'remote' machine. 14 | 15 | // Run like: 16 | // node controls.js myhost test 0 17 | // node controls.js myhost test 64 18 | // node controls.js mycluster test 0 19 | // node controls.js mycluster test 64 20 | // node controls.js mycluster scp 21 | // node controls.js mycluster clean 22 | // node controls.js myhost listeners 23 | // node controls.js myhost stdin 24 | // node controls.js myclusterarray test 0 25 | // node controls.js myclusterjson test 0 26 | // node controls.js mymachine 127.0.0.1 test 0 27 | 28 | var control = require('../'), 29 | task = control.task, 30 | script = process.argv[1], 31 | scpTest = 'controlScpTest'; 32 | 33 | task('mycluster', 'Prototype config for cluster of two', function () { 34 | var controllers = [], 35 | local, controller; 36 | 37 | local = Object.create(control.controller); 38 | 39 | // Tags to demonstrate chaining prototype usage 40 | local.user = process.env.USER; 41 | local.tags = ['local']; 42 | 43 | controller = Object.create(local); 44 | controller.address = 'localhost'; 45 | controller.scpOptions = ['-v']; 46 | controller.tags = controller.tags.concat(['dns']); 47 | controllers.push(controller); 48 | 49 | controller = Object.create(local); 50 | controller.address = '127.0.0.1'; 51 | controller.tags = controller.tags.concat(['ip']); 52 | controllers.push(controller); 53 | 54 | return controllers; 55 | }); 56 | 57 | task('myclusterarray', 'Array config for cluster of two', function () { 58 | var controller = Object.create(control.controller); 59 | controller.user = process.env.USER; 60 | controller.scpOptions = ['-v']; 61 | 62 | return control.controllers(['localhost', '127.0.0.1'], controller); 63 | }); 64 | 65 | task('myclusterjson', 'JSON Config for my cluster of two', function () { 66 | 67 | // Demonstrates JSON configuration usage 68 | var addresses = { 69 | 'localhost': { 70 | user: process.env.USER, 71 | scpOptions: ['-v'], 72 | tags: ['local', 'dns'] 73 | }, 74 | '127.0.0.1': { 75 | user: process.env.USER, 76 | tags: ['local', 'ip'] 77 | } 78 | }; 79 | return control.controllers(addresses); 80 | }); 81 | 82 | function configure(address) { 83 | var controller = Object.create(control.controller); 84 | 85 | controller.user = process.env.USER; 86 | controller.scpOptions = ['-v']; 87 | controller.address = address; 88 | 89 | return [controller]; 90 | } 91 | 92 | task('myhost', 'Config for cluster of one', function () { 93 | return configure('localhost'); 94 | }); 95 | 96 | task('mymachine', 'Config for single host from command line', function (args) { 97 | return configure([args.shift()]); // From command line arguments rewriting 98 | }); 99 | 100 | // note that many sshd configs default to a low number of allowed connections 101 | // run like: node controls.js many 5 test 102 | task('many', 'Config n controllers', function (args) { 103 | var controllers = [], shared, controller, i, l = args.shift(); 104 | 105 | shared = Object.create(control.controller); 106 | shared.user = process.env.USER; 107 | 108 | for (i = 0; i < l; i += 1) { 109 | controller = Object.create(shared); 110 | controller.address = 'localhost'; 111 | controllers.push(controller); 112 | } 113 | 114 | return controllers; 115 | }); 116 | 117 | function doTest(controller, code, callback, exitCallback) { 118 | code = code || 0; 119 | controller.ssh('node ' + script + ' myhost arbexit ' + code, 120 | callback, exitCallback); 121 | } 122 | 123 | // Task to perform 'remote' call requesting 'remote' to exit arbitrarily 124 | task('test', 'Test task', function (controller, code) { 125 | if (controller.tags) { 126 | console.log(' Tags for ' + controller.address + ' are: ' + 127 | controller.tags); 128 | } 129 | 130 | function callback() { 131 | console.log(' Regular callback activated for ' + controller.address); 132 | } 133 | 134 | function exitCallback(exit) { 135 | console.log(' Exit callback activated for ' + controller.address + 136 | ' with exit code ' + exit); 137 | } 138 | 139 | doTest(controller, code, callback, exitCallback); 140 | }); 141 | 142 | // Task that will run on 'remote' to exit with an arbitrary code 143 | task('arbexit', 'Arbitrary exit', function (controller, code) { 144 | code = code || 0; 145 | console.log(' (stdout) Exiting with code ' + code); 146 | console.error(' (stderr) Exiting with code ' + code); 147 | process.exit(code); 148 | }); 149 | 150 | task('scp', 'Test scp options', function (controller) { 151 | controller.scp(script, scpTest); 152 | }); 153 | 154 | task('clean', 'Remove file transferred in scp testing', function (controller) { 155 | controller.ssh('rm ' + scpTest); 156 | }); 157 | 158 | task('listeners', 'Custom listener example', function (controller) { 159 | var stdout, stderr; 160 | 161 | controller.stdout.on('data', function (data) { 162 | console.log(' Custom stdout listerner called for ' + 163 | controller.address); 164 | 165 | stdout = stdout || ''; 166 | stdout = stdout += data.toString(); 167 | }); 168 | 169 | controller.stderr.on('data', function (data) { 170 | console.log(' Custom stderr listerner called for ' + 171 | controller.address); 172 | stderr = stderr || ''; 173 | stderr = stderr += data.toString(); 174 | }); 175 | 176 | doTest(controller, 0, function () { 177 | console.log(' Response gathered by custom stdout listener for ' + 178 | controller.id() + ': \n' + stdout); 179 | console.log(' Response gatehered by custom stderr listener for ' + 180 | controller.id() + ': \n' + stderr); 181 | doTest(controller, 0); // Custom listeners are now cleared 182 | }); 183 | }); 184 | 185 | task('echo', 'Stdin to stdout echo until "end"', function (controller) { 186 | console.log('Enter data to echo ("end" to stop echoing): '); 187 | process.stdin.resume(); 188 | 189 | process.stdin.on('data', function (chunk) { 190 | process.stdout.write(chunk); 191 | chunk = chunk.toString(); 192 | if (chunk.match('end')) { 193 | process.stdin.pause(); 194 | } 195 | }); 196 | }); 197 | 198 | task('stdin', 'Test controller stdin usage', function (controller) { 199 | var stdout; 200 | 201 | controller.stdout.on('data', function (chunk) { 202 | chunk = chunk.toString(); 203 | if (chunk.match('^Enter data')) { 204 | controller.stdin.write('hello\n'); 205 | controller.stdin.write('end'); 206 | } 207 | }); 208 | 209 | controller.ssh('node ' + script + ' myhost echo'); 210 | }); 211 | 212 | task('ondate', 'Different logic paths based on date', function (controller) { 213 | var datestring = ''; 214 | 215 | controller.stdout.on('data', function (chunk) { 216 | datestring += chunk.toString(); 217 | }); 218 | 219 | controller.ssh('date', function () { 220 | console.log(' Date string is ' + datestring); 221 | // Further logic dependent on value of datestring 222 | }); 223 | }); 224 | 225 | task('logchange', 'Call date two times, changing log path before second call', 226 | function (controller) { 227 | controller.ssh('date', function () { 228 | controller.logPath = 'alt.log'; 229 | controller.ssh('date -r 1'); 230 | }); 231 | }); 232 | 233 | control.begin(); 234 | -------------------------------------------------------------------------------- /example/deprecated.js: -------------------------------------------------------------------------------- 1 | /*global require, process */ 2 | 3 | // Example of deprecated control.hosts() usage 4 | 5 | var control = require('../'), 6 | task = control.task; 7 | 8 | task('myclusterdep', 'Deprecated array config for cluster of two', 9 | function () { 10 | var config = { 11 | user: process.env.USER, 12 | scpOptions: ['-v'] 13 | }; 14 | 15 | return control.hosts(config, [ 'localhost', '127.0.0.1' ]); 16 | }); 17 | 18 | task('myclusterlogdep', 'Deprecated array config for cluster of two', 19 | function () { 20 | var config = { 21 | user: process.env.USER, 22 | scpOptions: ['-v'], 23 | log: 'deprecated.log' 24 | }; 25 | 26 | return control.hosts(config, [ 'localhost', '127.0.0.1' ]); 27 | }); 28 | 29 | require('./controls.js'); 30 | -------------------------------------------------------------------------------- /example/taskless.js: -------------------------------------------------------------------------------- 1 | /*global require, process, console */ 2 | 3 | var control = require('../'), 4 | shared = Object.create(control.controller), 5 | i, l, controller, controllers; 6 | 7 | shared.user = process.env.USER; 8 | controllers = control.controllers(['localhost', '127.0.0.1'], shared); 9 | 10 | for (i = 0, l = controllers.length; i < l; i += 1) { 11 | controller = controllers[i]; 12 | controller.ssh('date'); 13 | } 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*global module, require */ 2 | 3 | module.exports = require('./lib'); 4 | -------------------------------------------------------------------------------- /lib/configurator.js: -------------------------------------------------------------------------------- 1 | /*global require, exports */ 2 | 3 | var util = require('util'), 4 | controller = require('./controller'); 5 | 6 | // Return a copy of a with prototype of b 7 | function chain(a, b) { 8 | var prop, descriptor = {}; 9 | for (prop in a) { 10 | if (a.hasOwnProperty(prop)) { 11 | descriptor[prop] = Object.getOwnPropertyDescriptor(a, prop); 12 | } 13 | } 14 | return Object.create(b, descriptor); 15 | } 16 | 17 | function configure(prototype, address, options) { 18 | if (controller.prototype !== prototype && 19 | !controller.prototype.isPrototypeOf(prototype)) { 20 | throw new Error("Prototype is not a controller"); 21 | } 22 | 23 | if (!address) { 24 | throw new Error("No address"); 25 | } 26 | 27 | var configured; 28 | if (options) { 29 | configured = chain(options, prototype); 30 | } else { 31 | configured = Object.create(prototype); 32 | } 33 | configured.address = address; 34 | return configured; 35 | } 36 | 37 | function controllers(addresses, prototype) { 38 | if (!addresses) { 39 | throw new Error("No addresses"); 40 | } 41 | 42 | if (!prototype) { 43 | prototype = controller.prototype; 44 | } 45 | 46 | var list = [], 47 | i, length, configured; 48 | if (Array.prototype.isPrototypeOf(addresses)) { 49 | for (i = 0, length = addresses.length; i < length; i += 1) { 50 | configured = configure(prototype, addresses[i]); 51 | list.push(configured); 52 | } 53 | } else { 54 | for (i in addresses) { 55 | if (addresses.hasOwnProperty(i)) { 56 | configured = configure(prototype, i, addresses[i]); 57 | list.push(configured); 58 | } 59 | } 60 | } 61 | return list; 62 | } 63 | 64 | // deprecated 65 | function hosts(config, addresses) { 66 | console.log("!! hosts() is deprecated"); 67 | 68 | if (!config) { 69 | throw new Error("No config"); 70 | } 71 | 72 | if (!controller.prototype.isPrototypeOf(config)) { 73 | config = chain(config, controller.prototype); 74 | } 75 | return controllers(addresses, config); 76 | } 77 | 78 | exports.hosts = hosts; 79 | exports.controllers = controllers; 80 | -------------------------------------------------------------------------------- /lib/controller.js: -------------------------------------------------------------------------------- 1 | /*global require, exports, console, spawn: true */ 2 | 3 | var spawn = require('child_process').spawn, 4 | path = require('path'), 5 | Log = require('./log').Log, 6 | prototype = {}; 7 | 8 | // The id of a controller is its address (used by tasks system). 9 | function id() { 10 | return this.address; 11 | } 12 | prototype.id = id; 13 | 14 | // Initialize ssh and scp options to an array so config logic can assume an 15 | // array exists when adding or removing options. 16 | prototype.sshOptions = []; 17 | prototype.scpOptions = []; 18 | 19 | 20 | // Support logging 21 | function log(message, prefix) { 22 | if (!this.logger) { 23 | this.logger = new Log(this.address + ':', this.logPath, true); 24 | } 25 | 26 | this.logger.puts(message, prefix, this.logPath); 27 | } 28 | prototype.logPath = 'control.log'; // Default 29 | prototype.log = log; 30 | 31 | function logBuffer(prefix, buffer) { 32 | var message = buffer.toString(); 33 | this.log(message, prefix); 34 | } 35 | prototype.logBuffer = logBuffer; 36 | 37 | 38 | // Support custom listeners via controller.stdout.on(event, callback) pattern 39 | prototype.stdout = {}; 40 | prototype.stdout.listeners = 'stdoutListeners'; 41 | prototype.stdout.controller = prototype; 42 | 43 | prototype.stderr = {}; 44 | prototype.stderr.listeners = 'stderrListeners'; 45 | prototype.stderr.controller = prototype; 46 | 47 | function on(evt, callback) { 48 | var listeners = this.listeners, 49 | controller = this.controller; 50 | controller[listeners] = controller[listeners] || {}; 51 | controller[listeners][evt] = callback; 52 | } 53 | prototype.stdout.on = on; 54 | prototype.stderr.on = on; 55 | 56 | 57 | // Controller support for adding listeners to subprocess stream upon call 58 | function addListenersToStream(listeners, stream) { 59 | var evt, callback; 60 | if (listeners) { 61 | for (evt in listeners) { 62 | if (listeners.hasOwnProperty(evt)) { 63 | callback = listeners[evt]; 64 | stream.on(evt, callback); 65 | } 66 | } 67 | } 68 | } 69 | 70 | function addCustomListeners(child) { 71 | var stdoutListeners = this.stdoutListeners, 72 | stderrListeners = this.stderrListeners; 73 | 74 | // Clear custom listeners on each call 75 | this.stdoutListeners = {}; 76 | this.stderrListeners = {}; 77 | 78 | addListenersToStream(stdoutListeners, child.stdout); 79 | addListenersToStream(stderrListeners, child.stderr); 80 | } 81 | prototype.addCustomListeners = addCustomListeners; 82 | 83 | 84 | function listen(child, callback, exitCallback) { 85 | var codes = '', controller = this; 86 | 87 | this.stdin = child.stdin; 88 | 89 | this.addCustomListeners(child); 90 | 91 | child.stdout.addListener('data', function (data) { 92 | controller.logBuffer('stdout: ', data); 93 | }); 94 | 95 | child.stderr.addListener('data', function (data) { 96 | controller.logBuffer('stderr: ', data); 97 | }); 98 | 99 | child.addListener('exit', function (code) { 100 | controller.logBuffer('exit: ', code); 101 | if (code === 0) { 102 | if (callback) { 103 | callback(); 104 | } 105 | } else { 106 | if (exitCallback) { 107 | exitCallback(code); 108 | } 109 | } 110 | }); 111 | } 112 | prototype.listen = listen; 113 | 114 | function star(mask) { 115 | var stars = '', 116 | i, length; 117 | for (i = 0, length = mask.length; i < length; i += 1) { 118 | stars += '*'; 119 | } 120 | return stars; 121 | } 122 | 123 | function ssh(command, callback, exitCallback) { 124 | if (!command) { 125 | throw new Error(this.address + ': No command to run'); 126 | } 127 | 128 | var user = this.user, 129 | options = this.sshOptions, 130 | mask = this.logMask, stars, 131 | args = ['-l' + user, this.address, "''" + command + "''"], 132 | child; 133 | 134 | if (options) { 135 | args = options.concat(args); 136 | } 137 | 138 | if (mask) { 139 | stars = star(mask); 140 | while (command.indexOf(mask) !== -1) { 141 | command = command.replace(mask, stars); 142 | } 143 | } 144 | 145 | this.log(user + ':ssh: ' + command); 146 | child = spawn('ssh', args); 147 | this.listen(child, callback, exitCallback); 148 | } 149 | prototype.ssh = ssh; 150 | 151 | function scp(local, remote, callback, exitCallback) { 152 | if (!local) { 153 | throw new Error(this.address + ': No local file path'); 154 | } 155 | 156 | if (!remote) { 157 | throw new Error(this.address + ': No remote file path'); 158 | } 159 | 160 | var controller = this, 161 | user = this.user, 162 | options = this.scpOptions, 163 | address = this.address; 164 | path.exists(local, function (exists) { 165 | if (exists) { 166 | var reference = user + '@' + address + ':' + remote, 167 | args = ['-r', local, reference], 168 | child; 169 | 170 | if (options) { 171 | args = options.concat(args); 172 | } 173 | 174 | controller.log(user + ':scp: ' + local + ' ' + reference); 175 | child = spawn('scp', args); 176 | controller.listen(child, callback, exitCallback); 177 | } else { 178 | throw new Error('Local: ' + local + ' does not exist'); 179 | } 180 | }); 181 | } 182 | prototype.scp = scp; 183 | 184 | exports.prototype = prototype; 185 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /*global require, exports */ 2 | 3 | var task = require('./task'), 4 | controller = require('./controller'), 5 | configurator = require('./configurator'), 6 | util = require ('util'); 7 | 8 | function begin() { 9 | try { 10 | task.begin(); 11 | } catch (e) { 12 | if (e.name === 'TypeError' && e.message === 13 | "Property 'log' of object # is not a function") { 14 | console.log('!! Set logPath instead of log on controllers.'); 15 | } 16 | throw e; 17 | } 18 | } 19 | 20 | exports.task = task.task; 21 | exports.perform = task.perform; 22 | exports.controller = controller.prototype; 23 | exports.hosts = configurator.hosts; 24 | exports.controllers = configurator.controllers; 25 | exports.begin = begin; 26 | -------------------------------------------------------------------------------- /lib/log.js: -------------------------------------------------------------------------------- 1 | // Provides a grep-friendly log where every line is prefixed with 2 | // a prefix given during construction. Output goes to file and/or 3 | // console or nowhere. Each line of output to a file is timestamped. 4 | 5 | /*global require, exports */ 6 | 7 | var util = require('util'), 8 | fs = require('fs'); 9 | 10 | function createWriteStream(path) { 11 | return fs.createWriteStream(path, { flags: 'a', mode: '0600' }); 12 | } 13 | 14 | // prefix: added to log prefix to appear on each line if message has new lines 15 | function puts(message, prefix, path) { 16 | var filestream = this.filestream, 17 | logPrefix = this.prefix, 18 | echo = this.echo, 19 | timestamp = this.timestamp, 20 | lines, line, i, length; 21 | 22 | if (prefix) { 23 | logPrefix += prefix; 24 | } 25 | 26 | if (path && path !== this.path) { 27 | this.path = path; 28 | filestream = this.filestream = createWriteStream(path); 29 | } 30 | 31 | // Message may contain leading, interstitial, or trailing carriage 32 | // returns and new lines. Carriage returns, when output to the 33 | // console (terminal) will act as a literal carriage return, going back to 34 | // the beginning of the line and overwriting content laid down in a 35 | // previous line, which makes the console (terminal) log look corrupted. 36 | // The strategy here is to convert all carriage returns into new lines and 37 | // any group of new lines into one new line and then log each line with the 38 | // timestamp and prefix. If you decide that all this trimming and 39 | // indenting can be done more efficiently, please keep the carriage return 40 | // issue in mind. 41 | message = message.replace(/\r/g, "\n"); // Carriage return conversion 42 | message = message.replace(/^\n+|\n+$/g, ""); // Start and end clean up 43 | message = message.replace(/\n+/g, "\n"); // Group interstitial new lines 44 | 45 | lines = message.split("\n"); 46 | for (i = 0, length = lines.length; i < length; i += 1) { 47 | line = lines[i]; 48 | if (line.length > 0) { // Disregard empty lines 49 | 50 | if (filestream) { 51 | filestream.write(timestamp.now() + ':' + 52 | logPrefix + line + '\n'); 53 | } 54 | 55 | if (echo) { 56 | console.log(logPrefix + line); 57 | } 58 | } 59 | } 60 | } 61 | 62 | // prefix: prefix that will be prefixed to every line of output 63 | // path: (optional) file path of persisted log 64 | // echo: (optional) true to echo to console, false otherwise 65 | // timestamper: (optional) object that returns a timestamp from now() method 66 | function Log(prefix, path, echo, timestamp) { 67 | var filestream; 68 | 69 | if (path) { 70 | filestream = createWriteStream(path); 71 | } 72 | 73 | timestamp = timestamp || require('./timestamp'); 74 | 75 | return { 76 | prefix: prefix, 77 | path: path, 78 | filestream: filestream, 79 | echo: echo, 80 | timestamp: timestamp, 81 | puts: puts 82 | }; 83 | } 84 | 85 | exports.Log = Log; 86 | -------------------------------------------------------------------------------- /lib/task.js: -------------------------------------------------------------------------------- 1 | /*global require, exports, process */ 2 | 3 | var util = require('util'); 4 | 5 | var tasks = {}, 6 | descriptions = {}; 7 | 8 | // unshifted: arguments object from another function 9 | // arguments object does not implement Array methods so this function provides a 10 | // logical arguments.shift() 11 | function shift(unshifted) { 12 | var i, length, shifted = []; 13 | for (i = 1, length = unshifted.length; i < length; i += 1) { 14 | shifted[i - 1] = unshifted[i]; 15 | } 16 | return shifted; 17 | } 18 | 19 | function perform(name, config) { 20 | if (!name) { 21 | throw new Error('No task name'); 22 | } 23 | 24 | var task = tasks[name], 25 | log = " Performing " + name; 26 | 27 | if (config && config.id) { 28 | log += " for " + config.id(); 29 | } 30 | 31 | console.log(log); 32 | 33 | if (!task) { 34 | throw new Error('No task named: ' + name); 35 | } 36 | 37 | return task.apply(null, shift(arguments)); 38 | } 39 | 40 | function performAll(configs) { 41 | if (!configs || !(configs instanceof Array) || configs.length < 1) { 42 | throw new Error('No array of config objects'); 43 | } 44 | 45 | var i, length, config, 46 | args = shift(arguments), 47 | argsWithConfig; 48 | 49 | for (i = 0, length = configs.length; i < length; i += 1) { 50 | config = configs[i]; 51 | 52 | // Copy the arguments array for each config and insert the 53 | // config object as the first argument to the perform function 54 | // to use when calling the task. 55 | argsWithConfig = args.slice(0); 56 | argsWithConfig.splice(1, 0, config); 57 | 58 | perform.apply(null, argsWithConfig); 59 | } 60 | } 61 | 62 | function task(name, description, callback) { 63 | tasks[name] = callback; 64 | descriptions[name] = description; 65 | } 66 | 67 | function list() { 68 | for (var i in tasks) { 69 | if (tasks.hasOwnProperty(i)) { 70 | console.log(i + ': ' + descriptions[i]); 71 | } 72 | } 73 | } 74 | 75 | function begin() { 76 | var configTask = process.argv[2], 77 | taskWithArgs = process.argv.slice(3), 78 | configs = perform(configTask, taskWithArgs); 79 | if (taskWithArgs.length > 0) { 80 | taskWithArgs.unshift(configs); 81 | performAll.apply(null, taskWithArgs); 82 | } 83 | } 84 | 85 | task('list', 'List tasks', function () { 86 | list(); 87 | }); 88 | 89 | exports.task = task; 90 | exports.begin = begin; 91 | exports.perform = perform; 92 | -------------------------------------------------------------------------------- /lib/timestamp.js: -------------------------------------------------------------------------------- 1 | // Creates a YYYYMMDDhhmmss timestamp. Ideas on how to do this more efficiently 2 | // are welcome. 3 | 4 | /*global require, exports */ 5 | 6 | function padLeft(message, length) { 7 | var delta, i; 8 | message = message.toString(); 9 | delta = length - message.length; 10 | for (i = 0; i < delta; i += 1) { 11 | message = '0' + message; 12 | } 13 | return message; 14 | } 15 | 16 | function now() { 17 | var d = new Date(), 18 | year = d.getFullYear(), 19 | month = d.getMonth() + 1, 20 | day = d.getDate(), 21 | minute = d.getMinutes(), 22 | hour = d.getHours(), 23 | second = d.getSeconds(); 24 | 25 | year = padLeft(year, 4); 26 | month = padLeft(month, 2); 27 | day = padLeft(day, 2); 28 | hour = padLeft(hour, 2); 29 | minute = padLeft(minute, 2); 30 | second = padLeft(second, 2); 31 | return (year + month + day + hour + minute + second); 32 | } 33 | 34 | exports.now = now; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { "name" : "control", 2 | "description" : "Scripted asynchronous control of remote machines in parallel via ssh", 3 | "version" : "0.2.3", 4 | "author" : "Thomas Smith ", 5 | "repository" : { "type" : "git" , "url" : "git://github.com/tsmith/node-control.git" }, 6 | "main" : "./lib", 7 | "engines" : { "node" : ">=0.6.0" } 8 | } 9 | --------------------------------------------------------------------------------