d3ployer 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +26 -0
- package/dist/configLoader.d.ts +3 -0
- package/dist/configLoader.js +33 -0
- package/dist/connection.d.ts +3 -0
- package/dist/connection.js +30 -0
- package/dist/def.d.ts +48 -0
- package/dist/def.js +1 -0
- package/dist/defaultTasks.d.ts +2 -0
- package/dist/defaultTasks.js +92 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/runner.d.ts +5 -0
- package/dist/runner.js +168 -0
- package/dist/utils/Exception.d.ts +10 -0
- package/dist/utils/Exception.js +33 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 100k
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# d3ployer
|
|
2
|
+
|
|
3
|
+
A TypeScript-based SSH deployment CLI tool. Run tasks and scenarios against remote servers over SSH, with rsync for file transfer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install d3ployer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This exposes the `dpl` CLI command.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
Create a `deployer.config.ts` in your project root:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { defineConfig } from 'd3ployer';
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
servers: {
|
|
22
|
+
prod: {
|
|
23
|
+
host: '192.168.1.10',
|
|
24
|
+
deployPath: '/opt/myapp',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
files: {
|
|
28
|
+
basePath: './dist',
|
|
29
|
+
exclude: ['node_modules', '.git'],
|
|
30
|
+
},
|
|
31
|
+
symlinks: [
|
|
32
|
+
{ path: 'config.json', target: '/etc/myapp/config.json' },
|
|
33
|
+
],
|
|
34
|
+
tasks: {
|
|
35
|
+
restart: async (ctx) => {
|
|
36
|
+
await ctx.runRemote('systemctl restart myapp');
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
scenarios: {
|
|
40
|
+
deploy: ['upload', 'symlinks', 'depInstall', 'restart'],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then deploy:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
dpl deploy # run "deploy" scenario on all servers
|
|
49
|
+
dpl deploy prod # run on specific server(s)
|
|
50
|
+
dpl upload # run a single task
|
|
51
|
+
dpl list # list available scenarios, tasks, and servers
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## CLI
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
dpl <name> [servers...] Run a scenario or task
|
|
58
|
+
dpl list List scenarios, tasks, and servers
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
-c, --config <path> Path to deployer.config.ts
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If `<name>` matches a scenario, it runs all tasks in that scenario sequentially. Otherwise it runs the matching task directly.
|
|
65
|
+
|
|
66
|
+
## Config
|
|
67
|
+
|
|
68
|
+
### `servers`
|
|
69
|
+
|
|
70
|
+
Define target servers. Only `host` and `deployPath` are required.
|
|
71
|
+
|
|
72
|
+
| Field | Default | Description |
|
|
73
|
+
| --------------- | -------------------- | ------------------------------------ |
|
|
74
|
+
| `host` | (required) | Server hostname or IP |
|
|
75
|
+
| `deployPath` | (required) | Remote path to deploy to |
|
|
76
|
+
| `port` | `22` | SSH port |
|
|
77
|
+
| `username` | Current OS user | SSH username |
|
|
78
|
+
| `authMethod` | `'agent'` | `'agent'`, `'key'`, or `'password'` |
|
|
79
|
+
| `privateKey` | - | Path to private key (for `'key'`) |
|
|
80
|
+
| `password` | - | SSH password (for `'password'`) |
|
|
81
|
+
| `agent` | `SSH_AUTH_SOCK` | SSH agent socket path |
|
|
82
|
+
| `packageManager`| - | Override package manager per server |
|
|
83
|
+
| `initCmd` | - | Command to run on connect |
|
|
84
|
+
|
|
85
|
+
### `files`
|
|
86
|
+
|
|
87
|
+
Configure rsync file upload.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
files: {
|
|
91
|
+
basePath: './dist', // local directory to sync (default: '.')
|
|
92
|
+
include: ['src/**'], // rsync include patterns
|
|
93
|
+
exclude: ['node_modules'],// rsync exclude patterns
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `symlinks`
|
|
98
|
+
|
|
99
|
+
Create symlinks on the remote server.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
symlinks: [
|
|
103
|
+
{ path: 'config.json', target: '/etc/myapp/config.json' },
|
|
104
|
+
]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Relative paths are resolved against `deployPath`.
|
|
108
|
+
|
|
109
|
+
### `tasks`
|
|
110
|
+
|
|
111
|
+
Custom task functions receive a `TaskContext` and `Placeholders`:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
tasks: {
|
|
115
|
+
migrate: async (ctx, ph) => {
|
|
116
|
+
await ctx.runRemote(`cd ${ph.deployPath} && npm run migrate`);
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**TaskContext** provides:
|
|
122
|
+
- `runRemote(cmd)` - execute a command on the remote server
|
|
123
|
+
- `runLocal(cmd)` - execute a command locally
|
|
124
|
+
- `server` - current server config
|
|
125
|
+
- `ssh` - SSH2Promise connection
|
|
126
|
+
- `config` - full deployer config
|
|
127
|
+
|
|
128
|
+
**Placeholders** provide:
|
|
129
|
+
- `serverName` - name of the current server
|
|
130
|
+
- `deployPath` - remote deploy path
|
|
131
|
+
- `timestamp` - ISO timestamp (safe for filenames)
|
|
132
|
+
|
|
133
|
+
### `scenarios`
|
|
134
|
+
|
|
135
|
+
Named sequences of tasks:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
scenarios: {
|
|
139
|
+
deploy: ['upload', 'symlinks', 'depInstall', 'restart'],
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Built-in tasks
|
|
144
|
+
|
|
145
|
+
| Task | Description |
|
|
146
|
+
| ------------ | ---------------------------------------------- |
|
|
147
|
+
| `upload` | Rsync files to the remote server |
|
|
148
|
+
| `symlinks` | Create configured symlinks on the remote server |
|
|
149
|
+
| `depInstall` | Run package manager install on the remote server|
|
|
150
|
+
|
|
151
|
+
## Requirements
|
|
152
|
+
|
|
153
|
+
- Node.js (ESM)
|
|
154
|
+
- `rsync` installed locally (for the `upload` task)
|
|
155
|
+
- SSH access to target servers
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { loadConfig } from './configLoader.js';
|
|
5
|
+
import { runScenario, runTask } from './runner.js';
|
|
6
|
+
const program = new Command()
|
|
7
|
+
.name('deployer')
|
|
8
|
+
.description('TypeScript deployment tool')
|
|
9
|
+
.option('-c, --config <path>', 'path to deployer.config.ts');
|
|
10
|
+
program
|
|
11
|
+
.argument('<name>', 'scenario or task name')
|
|
12
|
+
.argument('[servers...]', 'target server(s)')
|
|
13
|
+
.action(async (name, servers) => {
|
|
14
|
+
try {
|
|
15
|
+
const config = await loadConfig(program.opts().config);
|
|
16
|
+
const serverList = servers.length > 0 ? servers : undefined;
|
|
17
|
+
if (config.scenarios?.[name]) {
|
|
18
|
+
await runScenario(config, name, serverList);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
await runTask(config, name, serverList);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.error(err.message);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
program
|
|
30
|
+
.command('list')
|
|
31
|
+
.description('list available scenarios, tasks and servers')
|
|
32
|
+
.action(async () => {
|
|
33
|
+
try {
|
|
34
|
+
const config = await loadConfig(program.opts().config);
|
|
35
|
+
console.log(chalk.bold('\nScenarios:'));
|
|
36
|
+
const scenarios = config.scenarios ?? {};
|
|
37
|
+
const scenarioKeys = Object.keys(scenarios);
|
|
38
|
+
if (scenarioKeys.length) {
|
|
39
|
+
for (const name of scenarioKeys) {
|
|
40
|
+
console.log(` ${chalk.cyan(name)} → [${scenarios[name].join(', ')}]`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
console.log(' (none)');
|
|
45
|
+
}
|
|
46
|
+
console.log(chalk.bold('\nTasks:'));
|
|
47
|
+
const taskKeys = Object.keys(config.tasks ?? {});
|
|
48
|
+
if (taskKeys.length) {
|
|
49
|
+
for (const name of taskKeys) {
|
|
50
|
+
console.log(` ${chalk.cyan(name)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
console.log(' (none)');
|
|
55
|
+
}
|
|
56
|
+
console.log(chalk.bold('\nServers:'));
|
|
57
|
+
for (const [name, s] of Object.entries(config.servers)) {
|
|
58
|
+
console.log(` ${chalk.cyan(name)} → ${s.username}@${s.host}:${s.port ?? 22} (${s.deployPath})`);
|
|
59
|
+
}
|
|
60
|
+
console.log();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error(err.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
program.parse();
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defaultsDeep } from 'lodash-es';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { defaultTasks } from './defaultTasks.js';
|
|
4
|
+
const SERVER_DEFAULTS = {
|
|
5
|
+
port: 22,
|
|
6
|
+
username: os.userInfo().username,
|
|
7
|
+
authMethod: 'agent',
|
|
8
|
+
};
|
|
9
|
+
function resolveServer(input) {
|
|
10
|
+
return defaultsDeep({}, input, SERVER_DEFAULTS);
|
|
11
|
+
}
|
|
12
|
+
export function defineConfig(input) {
|
|
13
|
+
const servers = {};
|
|
14
|
+
for (const [name, serverInput] of Object.entries(input.servers)) {
|
|
15
|
+
servers[name] = resolveServer(serverInput);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
...input,
|
|
19
|
+
rootDir: '',
|
|
20
|
+
servers,
|
|
21
|
+
tasks: {
|
|
22
|
+
...defaultTasks,
|
|
23
|
+
...input.tasks,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { defineConfig } from './config.js';
|
|
4
|
+
import { Exception } from './utils/Exception.js';
|
|
5
|
+
const CONFIG_FILENAME = 'deployer.config.ts';
|
|
6
|
+
export function findConfigFile(startDir = process.cwd()) {
|
|
7
|
+
let dir = path.resolve(startDir);
|
|
8
|
+
let parent = dir;
|
|
9
|
+
do {
|
|
10
|
+
dir = parent;
|
|
11
|
+
const candidate = path.join(dir, CONFIG_FILENAME);
|
|
12
|
+
if (fs.existsSync(candidate)) {
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
parent = path.dirname(dir);
|
|
16
|
+
} while (parent !== dir);
|
|
17
|
+
throw new Exception(`Could not find ${CONFIG_FILENAME} in ${startDir} or any parent directory`, 1774741892462);
|
|
18
|
+
}
|
|
19
|
+
export async function loadConfig(configPath) {
|
|
20
|
+
const resolvedPath = configPath ?? findConfigFile();
|
|
21
|
+
const absolutePath = path.resolve(resolvedPath);
|
|
22
|
+
if (!fs.existsSync(absolutePath)) {
|
|
23
|
+
throw new Exception(`Config file not found: ${absolutePath}`, 1774741902017);
|
|
24
|
+
}
|
|
25
|
+
const module = await import(absolutePath);
|
|
26
|
+
const raw = module.default ?? module;
|
|
27
|
+
const config = defineConfig(raw);
|
|
28
|
+
config.rootDir = path.dirname(absolutePath);
|
|
29
|
+
if (!config.servers || Object.keys(config.servers).length === 0) {
|
|
30
|
+
throw new Exception('Config must define at least one server', 1774741913430);
|
|
31
|
+
}
|
|
32
|
+
return config;
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import SSH2Promise from 'ssh2-promise';
|
|
3
|
+
import { Exception } from './utils/Exception.js';
|
|
4
|
+
export function createSSHConnection(server) {
|
|
5
|
+
const sshConfig = {
|
|
6
|
+
host: server.host,
|
|
7
|
+
port: server.port ?? 22,
|
|
8
|
+
username: server.username,
|
|
9
|
+
reconnect: false,
|
|
10
|
+
};
|
|
11
|
+
switch (server.authMethod ?? 'agent') {
|
|
12
|
+
case 'key':
|
|
13
|
+
if (!server.privateKey) {
|
|
14
|
+
throw new Exception(`Server "${server.host}": privateKey is required for key auth`, 1774741923779);
|
|
15
|
+
}
|
|
16
|
+
sshConfig.privateKey = fs.readFileSync(server.privateKey);
|
|
17
|
+
break;
|
|
18
|
+
case 'password':
|
|
19
|
+
if (!server.password) {
|
|
20
|
+
throw new Exception(`Server "${server.host}": password is required for password auth`, 1774741926213);
|
|
21
|
+
}
|
|
22
|
+
sshConfig.password = server.password;
|
|
23
|
+
break;
|
|
24
|
+
case 'agent':
|
|
25
|
+
sshConfig.agent = server.agent ?? process.env.SSH_AUTH_SOCK;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
console.log(`Connecting to ${server.username}@${server.host}:${sshConfig.port}`);
|
|
29
|
+
return new SSH2Promise(sshConfig);
|
|
30
|
+
}
|
package/dist/def.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type SSH2Promise from 'ssh2-promise';
|
|
2
|
+
export type AuthMethod = 'key' | 'password' | 'agent';
|
|
3
|
+
export interface ServerConfig {
|
|
4
|
+
host: string;
|
|
5
|
+
port: number;
|
|
6
|
+
username: string;
|
|
7
|
+
authMethod: AuthMethod;
|
|
8
|
+
privateKey?: string;
|
|
9
|
+
password?: string;
|
|
10
|
+
agent?: string;
|
|
11
|
+
deployPath: string;
|
|
12
|
+
}
|
|
13
|
+
export type ServerConfigInput = Partial<ServerConfig> & Pick<ServerConfig, 'host' | 'deployPath'>;
|
|
14
|
+
export interface FilesConfig {
|
|
15
|
+
basePath?: string;
|
|
16
|
+
include?: string[];
|
|
17
|
+
exclude?: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface SymlinkConfig {
|
|
20
|
+
path: string;
|
|
21
|
+
target: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Placeholders {
|
|
24
|
+
serverName: string;
|
|
25
|
+
deployPath: string;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TaskContext {
|
|
29
|
+
server: ServerConfig & {
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
ssh: SSH2Promise;
|
|
33
|
+
config: DeployerConfig;
|
|
34
|
+
runRemote: (cmd: string) => Promise<string>;
|
|
35
|
+
runLocal: (cmd: string) => Promise<string>;
|
|
36
|
+
}
|
|
37
|
+
export type TaskFn = (ctx: TaskContext, ph: Placeholders) => Promise<void>;
|
|
38
|
+
export interface DeployerConfig {
|
|
39
|
+
rootDir: string;
|
|
40
|
+
servers: Record<string, ServerConfig>;
|
|
41
|
+
files?: FilesConfig;
|
|
42
|
+
symlinks?: SymlinkConfig[];
|
|
43
|
+
tasks?: Record<string, TaskFn>;
|
|
44
|
+
scenarios?: Record<string, string[]>;
|
|
45
|
+
}
|
|
46
|
+
export type DeployerConfigInput = Omit<DeployerConfig, 'servers' | 'rootDir'> & {
|
|
47
|
+
servers: Record<string, ServerConfigInput>;
|
|
48
|
+
};
|
package/dist/def.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { Exception } from './utils/Exception.js';
|
|
4
|
+
function buildRsyncCommand(server, source, dest, files) {
|
|
5
|
+
const args = ['rsync', '-avz', '--delete', '--progress=info2'];
|
|
6
|
+
// ssh shell
|
|
7
|
+
const sshParts = ['ssh'];
|
|
8
|
+
if (server.port && server.port !== 22) {
|
|
9
|
+
sshParts.push(`-p ${server.port}`);
|
|
10
|
+
}
|
|
11
|
+
if (server.authMethod === 'key' && server.privateKey) {
|
|
12
|
+
sshParts.push(`-i ${server.privateKey}`);
|
|
13
|
+
}
|
|
14
|
+
sshParts.push('-o StrictHostKeyChecking=no');
|
|
15
|
+
args.push('-e', `"${sshParts.join(' ')}"`);
|
|
16
|
+
// include/exclude
|
|
17
|
+
if (files.include) {
|
|
18
|
+
for (const pattern of files.include) {
|
|
19
|
+
args.push(`--include=${pattern}`);
|
|
20
|
+
}
|
|
21
|
+
args.push('--exclude=*');
|
|
22
|
+
}
|
|
23
|
+
if (files.exclude) {
|
|
24
|
+
for (const pattern of files.exclude) {
|
|
25
|
+
args.push(`--exclude=${pattern}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
args.push(source, dest);
|
|
29
|
+
return args.join(' ');
|
|
30
|
+
}
|
|
31
|
+
function execRsync(command) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn('sh', ['-c', command], {
|
|
34
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
35
|
+
});
|
|
36
|
+
const stderrChunks = [];
|
|
37
|
+
child.stdout.on('data', (data) => {
|
|
38
|
+
process.stdout.write(data);
|
|
39
|
+
});
|
|
40
|
+
child.stderr.on('data', (data) => {
|
|
41
|
+
stderrChunks.push(data.toString());
|
|
42
|
+
process.stderr.write(data);
|
|
43
|
+
});
|
|
44
|
+
child.on('close', (code) => {
|
|
45
|
+
if (code !== 0) {
|
|
46
|
+
const details = stderrChunks.length
|
|
47
|
+
? `\n${stderrChunks.join('')}`
|
|
48
|
+
: '';
|
|
49
|
+
reject(new Exception(`rsync exited with code ${code} (cmd: ${command})${details}`, 1774741947570));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
resolve();
|
|
53
|
+
});
|
|
54
|
+
child.on('error', (err) => {
|
|
55
|
+
reject(new Exception(`rsync failed: ${command}\n${err.message}`, 1774741947571));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const uploadTask = async (ctx, ph) => {
|
|
60
|
+
const files = ctx.config.files;
|
|
61
|
+
if (!files) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const localBase = files.basePath?.startsWith('/')
|
|
65
|
+
? files.basePath
|
|
66
|
+
: path.resolve(ctx.config.rootDir, files.basePath ?? '.');
|
|
67
|
+
const remotePath = ph.deployPath;
|
|
68
|
+
const dest = `${ctx.server.username}@${ctx.server.host}:${remotePath}`;
|
|
69
|
+
const source = localBase.endsWith('/') ? localBase : localBase + '/';
|
|
70
|
+
await ctx.runRemote(`mkdir -p ${remotePath}`);
|
|
71
|
+
const command = buildRsyncCommand(ctx.server, source, dest, files);
|
|
72
|
+
await execRsync(command);
|
|
73
|
+
};
|
|
74
|
+
const symlinksTask = async (ctx, ph) => {
|
|
75
|
+
const symlinks = ctx.config.symlinks;
|
|
76
|
+
if (!symlinks || symlinks.length === 0) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const link of symlinks) {
|
|
80
|
+
const target = link.target.startsWith('/')
|
|
81
|
+
? link.target
|
|
82
|
+
: `${ph.deployPath}/${link.target}`;
|
|
83
|
+
const path = link.path.startsWith('/')
|
|
84
|
+
? link.path
|
|
85
|
+
: `${ph.deployPath}/${link.path}`;
|
|
86
|
+
await ctx.runRemote(`ln -sfn ${target} ${path}`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
export const defaultTasks = {
|
|
90
|
+
upload: uploadTask,
|
|
91
|
+
symlinks: symlinksTask,
|
|
92
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { AuthMethod, DeployerConfig, DeployerConfigInput, FilesConfig, Placeholders, ServerConfig, ServerConfigInput, SymlinkConfig, TaskContext, TaskFn, } from './def.js';
|
|
2
|
+
export { defineConfig } from './config.js';
|
|
3
|
+
export { runScenario, runTask } from './runner.js';
|
|
4
|
+
export { loadConfig, findConfigFile } from './configLoader.js';
|
package/dist/index.js
ADDED
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DeployerConfig, ServerConfig, TaskFn } from './def.js';
|
|
2
|
+
export declare function resolveServers(config: DeployerConfig, serverNames?: string[]): Array<[string, ServerConfig]>;
|
|
3
|
+
export declare function resolveTaskFns(taskNames: string[], allTasks: Record<string, TaskFn>): Array<[string, TaskFn]>;
|
|
4
|
+
export declare function runScenario(config: DeployerConfig, scenarioName: string, serverNames?: string[]): Promise<void>;
|
|
5
|
+
export declare function runTask(config: DeployerConfig, taskName: string, serverNames?: string[]): Promise<void>;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createSSHConnection } from './connection.js';
|
|
5
|
+
import { Exception } from './utils/Exception.js';
|
|
6
|
+
function execLocal(command) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn('sh', ['-c', command], {
|
|
9
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
10
|
+
});
|
|
11
|
+
const chunks = [];
|
|
12
|
+
child.stdout.on('data', (data) => {
|
|
13
|
+
const text = data.toString();
|
|
14
|
+
chunks.push(text);
|
|
15
|
+
process.stdout.write(text);
|
|
16
|
+
});
|
|
17
|
+
child.stderr.on('data', (data) => {
|
|
18
|
+
const text = data.toString();
|
|
19
|
+
chunks.push(text);
|
|
20
|
+
process.stderr.write(text);
|
|
21
|
+
});
|
|
22
|
+
child.on('close', (code) => {
|
|
23
|
+
const output = chunks.join('');
|
|
24
|
+
if (code !== 0) {
|
|
25
|
+
reject(new Exception(`Local command failed (exit ${code}): ${command}\n${output}`, 1774742010146));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
resolve(output);
|
|
29
|
+
});
|
|
30
|
+
child.on('error', (err) => {
|
|
31
|
+
reject(new Exception(`Local command failed: ${command}\n${err.message}`, 1774742013175));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function execRemote(ssh, command) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
ssh.spawn(command)
|
|
38
|
+
.then((stream) => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
stream.on('data', (data) => {
|
|
41
|
+
const text = data.toString();
|
|
42
|
+
chunks.push(text);
|
|
43
|
+
process.stdout.write(text);
|
|
44
|
+
});
|
|
45
|
+
stream.stderr.on('data', (data) => {
|
|
46
|
+
const text = data.toString();
|
|
47
|
+
chunks.push(text);
|
|
48
|
+
process.stderr.write(text);
|
|
49
|
+
});
|
|
50
|
+
stream.on('close', (code) => {
|
|
51
|
+
const output = chunks.join('');
|
|
52
|
+
if (code !== 0) {
|
|
53
|
+
reject(new Exception(`Remote command failed (exit ${code}): ${command}\n${output}`, 1774742047909));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
resolve(output);
|
|
57
|
+
});
|
|
58
|
+
})
|
|
59
|
+
.catch((err) => {
|
|
60
|
+
reject(new Exception(`Remote command failed: ${command}\n${err.message}`, 1774742062700));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function buildPlaceholders(serverName, server) {
|
|
65
|
+
return {
|
|
66
|
+
serverName,
|
|
67
|
+
deployPath: server.deployPath,
|
|
68
|
+
timestamp: new Date().toISOString().replace(/[:.]/g, '-'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function buildTaskContext(serverName, server, ssh, config) {
|
|
72
|
+
return {
|
|
73
|
+
server: { ...server, name: serverName },
|
|
74
|
+
ssh,
|
|
75
|
+
config,
|
|
76
|
+
runRemote: (cmd) => execRemote(ssh, cmd),
|
|
77
|
+
runLocal: (cmd) => execLocal(cmd),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function resolveServers(config, serverNames) {
|
|
81
|
+
const allEntries = Object.entries(config.servers);
|
|
82
|
+
if (!serverNames || serverNames.length === 0) {
|
|
83
|
+
return allEntries;
|
|
84
|
+
}
|
|
85
|
+
const result = [];
|
|
86
|
+
for (const name of serverNames) {
|
|
87
|
+
const server = config.servers[name];
|
|
88
|
+
if (!server) {
|
|
89
|
+
const available = Object.keys(config.servers).join(', ');
|
|
90
|
+
throw new Exception(`Server "${name}" not found. Available: ${available}`, 1774742073310);
|
|
91
|
+
}
|
|
92
|
+
result.push([name, server]);
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
export function resolveTaskFns(taskNames, allTasks) {
|
|
97
|
+
return taskNames.map(name => {
|
|
98
|
+
const fn = allTasks[name];
|
|
99
|
+
if (!fn) {
|
|
100
|
+
const available = Object.keys(allTasks).join(', ');
|
|
101
|
+
throw new Exception(`Task "${name}" not found. Available: ${available}`, 1774742082083);
|
|
102
|
+
}
|
|
103
|
+
return [name, fn];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const listrOptions = {
|
|
107
|
+
concurrent: false,
|
|
108
|
+
renderer: 'simple',
|
|
109
|
+
rendererOptions: {
|
|
110
|
+
clearOutput: true,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
function buildServerListr(serverName, server, config, tasks) {
|
|
114
|
+
return new Listr([
|
|
115
|
+
{
|
|
116
|
+
task: async (ctx) => {
|
|
117
|
+
const ssh = createSSHConnection(server);
|
|
118
|
+
await ssh.connect();
|
|
119
|
+
ctx.ssh = ssh;
|
|
120
|
+
ctx.taskCtx = buildTaskContext(serverName, server, ssh, config);
|
|
121
|
+
ctx.ph = buildPlaceholders(serverName, server);
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
...tasks.map(([taskName, taskFn]) => ({
|
|
125
|
+
title: chalk.bgCyan.black(` ${taskName} `),
|
|
126
|
+
task: async (ctx, task) => taskFn(ctx.taskCtx, ctx.ph),
|
|
127
|
+
options: listrOptions,
|
|
128
|
+
})),
|
|
129
|
+
{
|
|
130
|
+
task: async (ctx) => {
|
|
131
|
+
if (ctx.ssh) {
|
|
132
|
+
await ctx.ssh.close();
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
], listrOptions);
|
|
137
|
+
}
|
|
138
|
+
export async function runScenario(config, scenarioName, serverNames) {
|
|
139
|
+
const scenarioTasks = config.scenarios?.[scenarioName];
|
|
140
|
+
if (!scenarioTasks) {
|
|
141
|
+
const available = Object.keys(config.scenarios ?? {}).join(', ') || 'none';
|
|
142
|
+
throw new Exception(`Scenario "${scenarioName}" not found. Available: ${available}`, 1774742090385);
|
|
143
|
+
}
|
|
144
|
+
const allTasks = config.tasks ?? {};
|
|
145
|
+
const tasks = resolveTaskFns(scenarioTasks, allTasks);
|
|
146
|
+
const servers = resolveServers(config, serverNames);
|
|
147
|
+
const listr = new Listr(servers.map(([name, server]) => ({
|
|
148
|
+
title: chalk.bgMagenta.black(` ${name} (${server.host}) `),
|
|
149
|
+
task: () => buildServerListr(name, server, config, tasks),
|
|
150
|
+
options: listrOptions,
|
|
151
|
+
})), listrOptions);
|
|
152
|
+
await listr.run();
|
|
153
|
+
}
|
|
154
|
+
export async function runTask(config, taskName, serverNames) {
|
|
155
|
+
const allTasks = config.tasks ?? {};
|
|
156
|
+
const taskFn = allTasks[taskName];
|
|
157
|
+
if (!taskFn) {
|
|
158
|
+
const available = Object.keys(allTasks).join(', ') || 'none';
|
|
159
|
+
throw new Exception(`Task "${taskName}" not found. Available: ${available}`, 1774742100356);
|
|
160
|
+
}
|
|
161
|
+
const servers = resolveServers(config, serverNames);
|
|
162
|
+
const listr = new Listr(servers.map(([name, server]) => ({
|
|
163
|
+
title: chalk.bgMagenta.black(` ${name} (${server.host}) `),
|
|
164
|
+
task: () => buildServerListr(name, server, config, [[taskName, taskFn]]),
|
|
165
|
+
options: listrOptions,
|
|
166
|
+
})), listrOptions);
|
|
167
|
+
await listr.run();
|
|
168
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type Reason = Error | string;
|
|
2
|
+
export declare class Exception extends Error {
|
|
3
|
+
code: number;
|
|
4
|
+
protected _reasons: Reason[];
|
|
5
|
+
get reasons(): Reason[];
|
|
6
|
+
constructor(message: string, code?: number, error?: Reason);
|
|
7
|
+
toString(): string;
|
|
8
|
+
protected _initErrorMessage(message: any, error: any): void;
|
|
9
|
+
}
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class Exception extends Error {
|
|
2
|
+
code;
|
|
3
|
+
_reasons = [];
|
|
4
|
+
get reasons() { return this._reasons; }
|
|
5
|
+
constructor(message, code = -1, error) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
if (error) {
|
|
9
|
+
this._reasons = error instanceof Exception
|
|
10
|
+
? error.reasons.concat([error])
|
|
11
|
+
: [error];
|
|
12
|
+
this._initErrorMessage(this.message, error);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
toString() {
|
|
16
|
+
return `Exception #${this.code}: ${this.message}`;
|
|
17
|
+
}
|
|
18
|
+
_initErrorMessage(message, error) {
|
|
19
|
+
// @ts-ignore - it depends on the environment
|
|
20
|
+
const captureStackTrace = Error.captureStackTrace;
|
|
21
|
+
if (typeof captureStackTrace === 'function') {
|
|
22
|
+
captureStackTrace(this, this.constructor);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
this.stack = (new Error(message)).stack;
|
|
26
|
+
}
|
|
27
|
+
const messageLines = (this.message.match(/\n/g) || []).length + 1;
|
|
28
|
+
this.stack = this.constructor.name + ': [' + this.code + '] ' + message + '\n' +
|
|
29
|
+
this.stack.split('\n').slice(1, messageLines + 1).join('\n')
|
|
30
|
+
+ '\n'
|
|
31
|
+
+ error.stack;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Exception.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Exception.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "d3ployer",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dpl": "dist/bin.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.build.json",
|
|
21
|
+
"dev": "tsx src/main.ts",
|
|
22
|
+
"dev:watch": "tsx --watch src/main.ts",
|
|
23
|
+
"dev:inspect": "tsx --inspect-brk src/main.ts",
|
|
24
|
+
"script": "tsx src/script.ts",
|
|
25
|
+
"deploy": "tsx src/cli.ts",
|
|
26
|
+
"test": "mocha --import=tsx",
|
|
27
|
+
"test:inspect": "mocha --import=tsx --inspect-brk",
|
|
28
|
+
"coverage": "c8 mocha --import=tsx",
|
|
29
|
+
"prepare": "husky"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"chalk": "^5.6.2",
|
|
33
|
+
"commander": "^14.0.3",
|
|
34
|
+
"listr2": "^10.2.1",
|
|
35
|
+
"lodash-es": "^4.17.23",
|
|
36
|
+
"ssh2-promise": "^1.0.3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^9.39.3",
|
|
40
|
+
"@stylistic/eslint-plugin": "^2.13.0",
|
|
41
|
+
"@tsconfig/node24": "^24.0.4",
|
|
42
|
+
"@types/chai": "^5.2.3",
|
|
43
|
+
"@types/chai-as-promised": "^8.0.2",
|
|
44
|
+
"@types/lodash-es": "^4.17.12",
|
|
45
|
+
"@types/mocha": "^10.0.10",
|
|
46
|
+
"@types/node": "^24.10.13",
|
|
47
|
+
"c8": "^10.1.3",
|
|
48
|
+
"chai": "^6.2.2",
|
|
49
|
+
"chai-as-promised": "^8.0.2",
|
|
50
|
+
"eslint": "^8.57.1",
|
|
51
|
+
"eslint-plugin-import": "^2.32.0",
|
|
52
|
+
"husky": "^9.1.7",
|
|
53
|
+
"lint-staged": "^15.5.2",
|
|
54
|
+
"mocha": "^11.3.0",
|
|
55
|
+
"patch-package": "^8.0.1",
|
|
56
|
+
"tsx": "^4.21.0",
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"typescript-eslint": "^8.56.0"
|
|
59
|
+
},
|
|
60
|
+
"lint-staged": {
|
|
61
|
+
"*.{js,jsx,ts,tsx}": [
|
|
62
|
+
"eslint --fix",
|
|
63
|
+
"git add"
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|