npm package: @pilaniaanand/ndeploy
Description: Zero-downtime SSH deployment tool for Laravel and Node.js projects — inspired by Deployer
Author: Anand Pilania
Latest version: 0.0.1

# ndeploy

> [!WARNING]
> **Work in Progress:** This project is currently in active development. Features and API are subject to change until the `v1.0.0` release.

> Zero-downtime SSH deployment for **Laravel** and **Node.js** — inspired by [Deployer](https://deployer.org), rebuilt for Node.

```
npx ndeploy deploy production
npx ndeploy rollback staging
npx ndeploy run artisan:migrate production
npx ndeploy releases production
```

---

## Features

| Feature              | Details                                                            |
| -------------------- | ------------------------------------------------------------------ |
| **Zero-downtime**    | Atomic `current →` symlink swap — no interrupted requests          |
| **Strategies**       | Built-in recipes for Laravel and Node.js                           |
| **Multi-host**       | Deploy to many servers in series or parallel                       |
| **Config isolation** | Separate `stages` blocks per environment — no shared mutable state |
| **AWS provider**     | Resolve EC2 hosts by tag, ASG, or instance ID at deploy time       |
| **Hooks**            | `before` / `after` hooks per task                                  |
| **Custom tasks**     | Register any `async (ctx) => {}` as a named task                   |
| **Rollback**         | Single command rolls back to the previous release                  |
| **`npx`-ready**      | No global install needed                                           |
| **SSH-native**       | Connects over standard SSH — no agents, no remote daemons          |
| **Dry-run**          | `--dry-run` simulates every step without touching the server       |

---

## Installation

```bash
# Use without installing (recommended for CI)
npx ndeploy deploy production

# Or install globally
npm install -g ndeploy

# Or as a dev dependency in your project
npm install --save-dev ndeploy
```

---

## Quick start

```bash
# 1. Generate a config in the current directory
npx ndeploy init

# 2. Deploy
npx ndeploy deploy production

# 3. Rollback if something is wrong
npx ndeploy rollback production
```

---

## CLI Reference

```
Usage: ndeploy <command> [stage] [options]

Commands:
  init                  Interactively create deploy.config.js
  deploy  [stage]       Deploy to a stage            (default: production)
  rollback [stage]      Roll back to previous release
  run <task> [stage]    Run a single named task remotely
  releases [stage]      List releases on remote server
  ssh [stage]           Open interactive SSH session

Options:
  -c, --config <path>   Path to config file       (default: deploy.config.js)
  -t, --task <name>     Run only this task (inside deploy)
  -H, --host <host>     Target a specific host from the stage
      --dry-run         Simulate without executing
  -v, --verbose         Show all remote command output
      --no-color        Disable color output
  -V, --version         Print version
  -h, --help            Print help
```

---

## Configuration

`ndeploy` looks for `deploy.config.js` (ESM default export) in the current directory.
Also supported: `deploy.config.cjs`, `deploy.config.json`, `deploy.config.yml`.

### Minimal config

```js
// deploy.config.js
export default {
  strategy:   'laravel',          // 'laravel' | 'nodejs'
  repository: 'git@github.com:your-org/app.git',

  stages: {
    production: {
      hosts: [{
        host:        '10.0.0.1',
        user:        'deploy',
        deploy_path: '/var/www/app',
      }],
    },
  },
};
```

### Full schema

```js
export default {
  // ── Required ──────────────────────────────────────────────────────────────
  strategy:   'laravel',   // or 'nodejs'
  repository: 'git@github.com:org/repo.git',

  // ── Source control ────────────────────────────────────────────────────────
  branch:     'main',      // default branch; can be overridden per stage
  git_depth:  1,           // shallow clone depth (0 = full)

  // ── Release management ────────────────────────────────────────────────────
  keep_releases: 5,        // how many old releases to keep

  // ── Shared state (persisted across releases) ─────────────────────────────
  shared_files: ['.env'],
  shared_dirs:  ['storage'],   // laravel default; ['uploads','logs'] for node

  // ── Writable by web server ────────────────────────────────────────────────
  writable_dirs: ['storage', 'bootstrap/cache'],
  writable_mode: 'chmod',      // 'chmod' | 'acl' | 'chown'
  http_user: 'www-data',       // used when writable_mode = 'acl' | 'chown'

  // ── Custom pipeline (optional — strategy provides a sensible default) ─────
  pipeline: [
    'deploy:lock',
    'setup:dirs',
    'git:clone',
    'deploy:shared',
    'deploy:writable',
    'composer:install',   // laravel
    'artisan:migrate',    // laravel
    'artisan:optimize',   // laravel
    'deploy:symlink',
    'deploy:cleanup',
    'deploy:unlock',
  ],

  // ── Deploy all hosts in parallel (default: false = serial) ───────────────
  parallel_deployment: false,

  // ── Custom tasks ──────────────────────────────────────────────────────────
  tasks: {
    'slack:notify': async (ctx) => {
      await ctx.run(`curl -s -X POST "$SLACK_URL" -d '{"text":"deployed"}' || true`,
        { ignoreError: true });
    },
  },

  // ── Hooks: inject tasks before / after any built-in task ─────────────────
  hooks: {
    before: { 'deploy:symlink': ['slack:notify'] },
    after:  { 'deploy:cleanup': ['slack:notify'] },
  },

  // ── Laravel-specific ──────────────────────────────────────────────────────
  php_bin:         'php8.2',
  composer_bin:    'composer',
  composer_flags:  '--no-dev --no-interaction --optimize-autoloader',
  build_assets:    false,      // set true to add npm:build to pipeline
  php_fpm_service: 'php8.2-fpm',

  // ── Node.js-specific ──────────────────────────────────────────────────────
  package_manager:       'npm',          // 'npm' | 'yarn' | 'pnpm'
  npm_flags:             'ci --omit=dev',
  build_command:         'run build',    // runs: npm run build
  process_manager:       'pm2',          // 'pm2' | 'systemd'
  pm2_app_name:          'app',
  pm2_config:            'ecosystem.config.cjs',   // optional
  systemd_unit:          'myapp',
  health_check_url:      'http://localhost:3000/health',
  health_check_retries:  5,
  health_check_interval: 3,             // seconds

  // ── Stages (project isolation) ────────────────────────────────────────────
  stages: {
    production: {
      branch: 'main',
      hosts: [
        {
          label:       'web-1',          // friendly name shown in output
          host:        '10.0.0.1',       // IP or hostname
          user:        'deploy',
          port:        22,
          deploy_path: '/var/www/app',
          privateKey:  '~/.ssh/id_ed25519',
          // identityFile, password, passphrase, jumpHost also supported
        },
      ],
    },
    staging: {
      branch: 'develop',
      hosts: [{ host: '10.0.1.1', user: 'deploy', deploy_path: '/var/www/app-staging' }],
    },
  },
};
```

### Per-stage & per-host overrides

Every key in the top-level config can be overridden at the stage or host level.
Resolution order (lowest → highest precedence):

```
global config  →  stage config  →  host config  →  CLI flags
```

---

## Deployment strategies

### Laravel

```js
strategy: 'laravel'
```

Default pipeline:

```
deploy:lock          Prevent concurrent deployments
setup:dirs           mkdir releases/ shared/
git:clone            Clone repository into releases/<timestamp>
deploy:shared        Symlink shared/ dirs & files (.env, storage/)
deploy:writable      chmod/acl storage/ bootstrap/cache/
composer:install     composer install --no-dev --optimize-autoloader
artisan:storage:link php artisan storage:link
artisan:migrate      php artisan migrate --force
artisan:optimize     php artisan optimize (config+route+view cache)
deploy:symlink       Atomic: mv current → new release
artisan:queue:restart php artisan queue:restart
deploy:cleanup       Remove releases beyond keep_releases
deploy:unlock        Release lock
```

Available Laravel tasks:

| Task                     | Description                   |
| ------------------------ | ----------------------------- |
| `composer:install`       | Install PHP deps              |
| `composer:dump-autoload` | Regenerate autoloader         |
| `artisan:migrate`        | Run migrations `--force`      |
| `artisan:seed`           | Run seeders `--force`         |
| `artisan:optimize`       | Cache config + routes + views |
| `artisan:config:cache`   | Config cache only             |
| `artisan:route:cache`    | Route cache only              |
| `artisan:view:cache`     | Blade view cache              |
| `artisan:cache:clear`    | Clear application cache       |
| `artisan:queue:restart`  | Graceful queue restart        |
| `artisan:storage:link`   | Public storage symlink        |
| `artisan:down`           | Maintenance mode on           |
| `artisan:up`             | Maintenance mode off          |
| `npm:build`              | `npm ci && npm run build`     |
| `php-fpm:reload`         | `service php-fpm reload`      |

---

### Node.js

```js
strategy: 'nodejs'
```

Default pipeline (PM2):

```
deploy:lock
setup:dirs
git:clone
deploy:shared        Symlink .env, uploads/, logs/
npm:install          npm ci --omit=dev  (or yarn/pnpm equivalent)
node:build           npm run build  (only if build_command is set)
deploy:writable
deploy:symlink
pm2:reload           Zero-downtime reload via PM2
health:check         HTTP check (only if health_check_url is set)
deploy:cleanup
deploy:unlock
```

Available Node.js tasks:

| Task              | Description                                   |
| ----------------- | --------------------------------------------- |
| `npm:install`     | `npm ci --omit=dev`                           |
| `yarn:install`    | `yarn install --frozen-lockfile`              |
| `pnpm:install`    | `pnpm install --frozen-lockfile`              |
| `node:build`      | Run configured build command                  |
| `pm2:start`       | Start or reload app via PM2                   |
| `pm2:reload`      | Zero-downtime reload                          |
| `pm2:stop`        | Stop PM2 process                              |
| `pm2:delete`      | Delete PM2 process                            |
| `systemd:reload`  | `systemctl reload` (with fallback to restart) |
| `systemd:restart` | `systemctl restart`                           |
| `health:check`    | HTTP check with retries                       |
| `nginx:reload`    | `nginx -t && systemctl reload nginx`          |

---

## AWS Provider

Dynamically resolve EC2 hosts at deploy time instead of hard-coding IPs.

```bash
npm install @aws-sdk/client-ec2 @aws-sdk/client-ssm
```

```js
// deploy.config.js
import { AwsProvider } from 'ndeploy';

const aws = new AwsProvider({
  region:          'us-east-1',
  // profile:      'myprofile',          // AWS CLI profile
  // accessKeyId + secretAccessKey work too; defaults to env / instance role
});

// Resolve by Name tag
const webHosts = await aws.byTag('web-production', {
  user:        'ec2-user',
  deploy_path: '/srv/app',
  privateKey:  '~/.ssh/ec2.pem',
});

// Resolve by multiple tags
const apiHosts = await aws.byTags(
  { Environment: 'production', Role: 'api' },
  { user: 'ubuntu', deploy_path: '/srv/api' }
);

// Resolve all instances in an Auto Scaling Group
const asgHosts = await aws.byAutoScalingGroup('my-asg-name', {
  user: 'ec2-user', deploy_path: '/srv/app',
});

export default {
  strategy:   'nodejs',
  repository: 'git@github.com:org/api.git',

  stages: {
    production: {
      hosts: webHosts,   // ← EC2 instances resolved above
    },
  },
};
```

---

## Custom tasks & hooks

```js
export default {
  strategy: 'nodejs',
  repository: '…',

  tasks: {
    // Any async function receiving a Context object
    'db:migrate': async (ctx) => {
      ctx.set('remote_cwd', ctx.paths.release);
      await ctx.run('node scripts/migrate.js');
    },

    'notify:slack': async (ctx) => {
      const msg = `✅ Deployed ${ctx.releaseName} to ${ctx.stage}`;
      await ctx.run(
        `curl -s -X POST "$SLACK_WEBHOOK" -d '{"text":"${msg}"}' || true`,
        { ignoreError: true }
      );
    },
  },

  hooks: {
    before: {
      'deploy:symlink': ['db:migrate'],      // run before the atomic swap
    },
    after: {
      'deploy:cleanup': ['notify:slack'],    // run after cleanup
    },
  },

  stages: { production: { hosts: [{ … }] } },
};
```

---

## The `ctx` Context API

Every task receives a `Context` instance:

```js
// ── Variable store ────────────────────────────────────────────────────────
ctx.get('key')           // reads var → host config → global config
ctx.set('key', value)    // sets a runtime variable
ctx.add('key', ...vals)  // appends to an array variable

// ── Remote execution ──────────────────────────────────────────────────────
await ctx.run('command')                    // run; throw on non-zero exit
await ctx.run('command', { ignoreError: true })   // run; ignore failure
await ctx.capture('command')               // run; return trimmed stdout

// ── File transfer ─────────────────────────────────────────────────────────
await ctx.upload('/local/path', '/remote/path')
await ctx.download('/remote/path', '/local/path')

// ── Well-known paths ──────────────────────────────────────────────────────
ctx.paths.deploy     // /var/www/app
ctx.paths.releases   // /var/www/app/releases
ctx.paths.current    // /var/www/app/current   ← the live symlink
ctx.paths.shared     // /var/www/app/shared
ctx.paths.release    // /var/www/app/releases/20240315120000  ← this deploy

// ── Metadata ──────────────────────────────────────────────────────────────
ctx.releaseName      // '20240315120000'
ctx.stage            // 'production'
ctx.host             // host config object
ctx.log              // Logger instance
```

---

## Remote directory layout

```
/var/www/app/
├── current -> releases/20240315120000   ← always points to live release
├── releases/
│   ├── 20240315120000/   ← newest (just deployed)
│   ├── 20240314093000/   ← previous
│   └── 20240313150000/   ← older (removed by cleanup)
└── shared/
    ├── .env              ← persisted across releases
    ├── storage/          ← laravel: symlinked into each release
    └── uploads/          ← nodejs: symlinked into each release
```

---

## Programmatic API

```js
import { Deployer, ConfigLoader, AwsProvider } from 'ndeploy';

const config   = await new ConfigLoader().load('./deploy.config.js');
const deployer = new Deployer(config, { verbose: true });

// Deploy
await deployer.deploy('production');

// Rollback
await deployer.rollback('production');

// Run a single task
await deployer.runTask('artisan:migrate', 'production');

// List releases
await deployer.listReleases('production');
```

---

## Tips

### Zero-downtime with Nginx

Point your Nginx `root` at `current/public` (Laravel) or proxy to the PM2 process. The atomic symlink swap means Nginx keeps serving the old release while the new one is prepared; the cutover happens in microseconds.

```nginx
# Laravel
root /var/www/app/current/public;

# Node.js (PM2 handles the reload; nginx just proxies)
location / { proxy_pass http://localhost:3000; }
```

### SSH key setup

```js
hosts: [{
  host:        '10.0.0.1',
  user:        'deploy',
  deploy_path: '/var/www/app',

  // Option 1: explicit key file
  privateKey: '~/.ssh/id_ed25519',

  // Option 2: key content from env var (great for CI)
  privateKey: process.env.SSH_PRIVATE_KEY,

  // Option 3: password (not recommended)
  password: process.env.SSH_PASSWORD,
}]
```

### CI/CD (GitHub Actions)

```yaml
- name: Deploy
  run: npx ndeploy deploy production
  env:
    SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
    SLACK_WEBHOOK:   ${{ secrets.SLACK_WEBHOOK }}
```

In your config:

```js
hosts: [{
  host:       '10.0.0.1',
  user:       'deploy',
  deploy_path: '/var/www/app',
  privateKey: process.env.SSH_PRIVATE_KEY,
}]
```

---

## License

MIT