petadep 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -16,6 +16,14 @@ chmod +x bin/petadep.js
16
16
 
17
17
  ## Initialize config
18
18
 
19
+ ```bash
20
+ petadep
21
+ ```
22
+
23
+ Interactive setup will ask for port, webhook path, secret, deployment info, and where to save the config.
24
+
25
+ If you prefer the non-interactive command:
26
+
19
27
  ```bash
20
28
  petadep init --config ./data/config.json
21
29
  ```
@@ -39,6 +47,12 @@ petadep add --config ./data/config.json \
39
47
  petadep agent --config ./data/config.json
40
48
  ```
41
49
 
50
+ To install/use PM2 and run the agent under it:
51
+
52
+ ```bash
53
+ petadep agent --config ./data/config.json --pm2
54
+ ```
55
+
42
56
  The server exposes:
43
57
 
44
58
  - `POST /webhook` (or `config.path`)
@@ -63,6 +77,7 @@ The agent clones via SSH using `git@github.com:owner/repo.git`. Your VPS must ha
63
77
  "port": 8787,
64
78
  "path": "/webhook",
65
79
  "secret": "<random>",
80
+ "sshKeyPath": "/root/.ssh/petadep_ed25519",
66
81
  "deployments": [
67
82
  {
68
83
  "repo": "owner/repo",
@@ -76,3 +91,5 @@ The agent clones via SSH using `git@github.com:owner/repo.git`. Your VPS must ha
76
91
  "logsDir": "./logs"
77
92
  }
78
93
  ```
94
+
95
+ `sshKeyPath` is optional and forces git to use that SSH key for clone/fetch.
package/bin/petadep.js CHANGED
@@ -1,12 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import yargs from "yargs";
3
3
  import { hideBin } from "yargs/helpers";
4
- import { initConfig, addDeployment } from "../src/cli.js";
4
+ import { execa } from "execa";
5
+ import path from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { initConfig, initConfigInteractive, addDeployment } from "../src/cli.js";
5
8
  import { startAgent } from "../src/agent.js";
6
9
 
7
10
  async function main() {
8
11
  yargs(hideBin(process.argv))
9
12
  .scriptName("petadep")
13
+ .command(
14
+ "$0",
15
+ "Interactive setup",
16
+ () => {},
17
+ async () => {
18
+ await initConfigInteractive();
19
+ }
20
+ )
10
21
  .command(
11
22
  "init",
12
23
  "Initialize a config file",
@@ -70,21 +81,61 @@ async function main() {
70
81
  "agent",
71
82
  "Start the webhook server",
72
83
  (y) =>
73
- y.option("config", {
74
- type: "string",
75
- demandOption: true,
76
- describe: "Path to config JSON",
77
- }),
84
+ y
85
+ .option("config", {
86
+ type: "string",
87
+ demandOption: true,
88
+ describe: "Path to config JSON",
89
+ })
90
+ .option("pm2", {
91
+ type: "boolean",
92
+ default: false,
93
+ describe: "Install/use pm2 and run the agent under it",
94
+ }),
78
95
  async (argv) => {
96
+ if (argv.pm2) {
97
+ await startWithPm2({ configPath: argv.config });
98
+ return;
99
+ }
79
100
  await startAgent({ configPath: argv.config });
80
101
  }
81
102
  )
82
- .demandCommand(1, "Choose a command")
83
103
  .strict()
84
104
  .help()
85
105
  .parse();
86
106
  }
87
107
 
108
+ async function ensurePm2() {
109
+ try {
110
+ await execa("pm2", ["-v"], { stdio: "ignore" });
111
+ return;
112
+ } catch {
113
+ await execa("npm", ["i", "-g", "pm2"], { stdio: "inherit" });
114
+ }
115
+ }
116
+
117
+ async function startWithPm2({ configPath }) {
118
+ await ensurePm2();
119
+ const name = "petadep";
120
+ const resolvedConfig = path.resolve(configPath);
121
+ const binPath = fileURLToPath(import.meta.url);
122
+
123
+ try {
124
+ await execa("pm2", ["start", binPath, "--name", name, "--", "agent", "--config", resolvedConfig], {
125
+ stdio: "inherit",
126
+ });
127
+ } catch (err) {
128
+ await execa("pm2", ["restart", name], { stdio: "inherit" });
129
+ console.log(
130
+ `PM2 already had '${name}'. Restarted it; if you changed config path, run 'pm2 delete ${name}' then rerun.`
131
+ );
132
+ return;
133
+ }
134
+
135
+ await execa("pm2", ["save"], { stdio: "inherit" }).catch(() => {});
136
+ console.log(`PM2 running. Use 'pm2 status' to check.`);
137
+ }
138
+
88
139
  main().catch((err) => {
89
140
  console.error(err?.message || err);
90
141
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "petadep",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Deploy GitHub repos to a VPS on push via webhooks",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -31,6 +31,7 @@ const configSchema = z.object({
31
31
  secret: z.string().min(1),
32
32
  deployments: z.array(deploymentSchema).default([]),
33
33
  security: securitySchema,
34
+ sshKeyPath: z.string().min(1).optional(),
34
35
  logsDir: z.string().default("./logs"),
35
36
  });
36
37
 
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
+ import readline from "readline";
4
5
 
5
6
  function defaultConfig(secret) {
6
7
  return {
@@ -13,6 +14,7 @@ function defaultConfig(secret) {
13
14
  lockPerTarget: true,
14
15
  timeoutSeconds: 900,
15
16
  },
17
+ sshKeyPath: undefined,
16
18
  logsDir: "./logs",
17
19
  };
18
20
  }
@@ -37,6 +39,128 @@ export async function initConfig({ configPath }) {
37
39
  );
38
40
  }
39
41
 
42
+ function createInterface() {
43
+ return readline.createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ });
47
+ }
48
+
49
+ const colors = {
50
+ reset: "\u001b[0m",
51
+ bold: "\u001b[1m",
52
+ cyan: "\u001b[36m",
53
+ green: "\u001b[32m",
54
+ yellow: "\u001b[33m",
55
+ magenta: "\u001b[35m",
56
+ };
57
+
58
+ function color(text, code) {
59
+ return `${code}${text}${colors.reset}`;
60
+ }
61
+
62
+ function prompt(rl, question, defaultValue) {
63
+ const suffix = defaultValue ? ` (${defaultValue})` : "";
64
+ return new Promise((resolve) => {
65
+ rl.question(`${question}${suffix}: `, (answer) => {
66
+ const trimmed = answer.trim();
67
+ resolve(trimmed.length ? trimmed : defaultValue);
68
+ });
69
+ });
70
+ }
71
+
72
+ function promptHidden(question) {
73
+ return new Promise((resolve) => {
74
+ const input = process.stdin;
75
+ const output = process.stdout;
76
+ let value = "";
77
+
78
+ output.write(`${question}: `);
79
+ input.setRawMode(true);
80
+ input.resume();
81
+
82
+ function onData(chunk) {
83
+ const char = chunk.toString("utf8");
84
+ if (char === "\r" || char === "\n") {
85
+ input.setRawMode(false);
86
+ input.pause();
87
+ input.removeListener("data", onData);
88
+ output.write("\n");
89
+ resolve(value.trim());
90
+ return;
91
+ }
92
+ if (char === "\u0003") {
93
+ process.exit(1);
94
+ }
95
+ if (char === "\u007f") {
96
+ value = value.slice(0, -1);
97
+ return;
98
+ }
99
+ value += char;
100
+ }
101
+
102
+ input.on("data", onData);
103
+ });
104
+ }
105
+
106
+ async function promptRequired(rl, question) {
107
+ let value = "";
108
+ while (!value) {
109
+ const answer = await prompt(rl, question, "");
110
+ value = answer?.trim() || "";
111
+ if (!value) {
112
+ console.log(color("This value is required. Try again.", colors.yellow));
113
+ }
114
+ }
115
+ return value;
116
+ }
117
+
118
+ export async function initConfigInteractive() {
119
+ const rl = createInterface();
120
+ try {
121
+ console.log(color("petadep setup", colors.bold + colors.magenta));
122
+ console.log(color("Let's create your webhook config.", colors.cyan));
123
+ const port = await prompt(rl, "Port", "8787");
124
+ const hookPath = await prompt(rl, "Webhook path", "/webhook");
125
+ const secretInput = await promptHidden("Secret (leave blank to auto-generate)");
126
+ const repo = await promptRequired(rl, "Repo (owner/repo)");
127
+ const branch = await prompt(rl, "Branch", "main");
128
+ const env = await prompt(rl, "Env name", "production");
129
+ const workdir = await promptRequired(rl, "Workdir");
130
+ const script = await prompt(rl, "Script path", "./deploy.sh");
131
+ const logsDir = await prompt(rl, "Logs dir", "./logs");
132
+ const sshKeyPath = await prompt(rl, "SSH key path (optional)", "");
133
+ const savePath = await prompt(rl, "Save config to", "./config.json");
134
+
135
+ const secret =
136
+ secretInput && secretInput.length > 0
137
+ ? secretInput
138
+ : crypto.randomBytes(32).toString("hex");
139
+
140
+ const config = defaultConfig(secret);
141
+ config.port = Number(port);
142
+ config.path = hookPath;
143
+ config.logsDir = logsDir;
144
+ if (sshKeyPath) {
145
+ config.sshKeyPath = sshKeyPath;
146
+ } else {
147
+ delete config.sshKeyPath;
148
+ }
149
+
150
+ config.deployments = [{ repo, branch, env, workdir, script }];
151
+
152
+ const resolvedPath = path.resolve(savePath);
153
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
154
+ await fs.writeFile(resolvedPath, JSON.stringify(config, null, 2));
155
+
156
+ console.log(color("Config created:", colors.green), resolvedPath);
157
+ console.log(color("Webhook secret:", colors.green), secret);
158
+ console.log(color("Start agent:", colors.cyan), `petadep agent --config ${resolvedPath}`);
159
+ } finally {
160
+ rl.close();
161
+ }
162
+ }
163
+
40
164
  export async function addDeployment({
41
165
  configPath,
42
166
  repo,
package/src/deployer.js CHANGED
@@ -14,6 +14,14 @@ function toIsoLine(message) {
14
14
  return `[${new Date().toISOString()}] ${message}\n`;
15
15
  }
16
16
 
17
+ function buildGitEnv(config) {
18
+ if (!config?.sshKeyPath) {
19
+ return {};
20
+ }
21
+ const sshCommand = `ssh -i ${config.sshKeyPath} -o IdentitiesOnly=yes`;
22
+ return { GIT_SSH_COMMAND: sshCommand };
23
+ }
24
+
17
25
  async function runCommand(command, args, options, logStream) {
18
26
  const child = execa(command, args, options);
19
27
  if (child.stdout) {
@@ -40,25 +48,32 @@ async function ensureRepo(target, config, logStream, timeoutMs) {
40
48
  logPathForTarget(config, target),
41
49
  toIsoLine(`Cloning ${sshUrl} into ${target.workdir}`)
42
50
  );
51
+ const env = buildGitEnv(config);
43
52
  await runCommand(
44
53
  "git",
45
54
  ["clone", sshUrl, target.workdir],
46
- { timeout: timeoutMs },
55
+ { timeout: timeoutMs, env: { ...process.env, ...env } },
47
56
  logStream
48
57
  );
49
58
  }
50
59
  }
51
60
 
52
- async function checkoutBranch(target, logStream, timeoutMs) {
61
+ async function checkoutBranch(target, config, logStream, timeoutMs) {
53
62
  const cwd = target.workdir;
54
- await runCommand("git", ["fetch", "--all", "--prune"], { cwd, timeout: timeoutMs }, logStream);
63
+ const env = buildGitEnv(config);
64
+ await runCommand(
65
+ "git",
66
+ ["fetch", "--all", "--prune"],
67
+ { cwd, timeout: timeoutMs, env: { ...process.env, ...env } },
68
+ logStream
69
+ );
55
70
 
56
71
  let hasBranch = true;
57
72
  try {
58
73
  await runCommand(
59
74
  "git",
60
75
  ["rev-parse", "--verify", target.branch],
61
- { cwd, timeout: timeoutMs },
76
+ { cwd, timeout: timeoutMs, env: { ...process.env, ...env } },
62
77
  logStream
63
78
  );
64
79
  } catch {
@@ -69,14 +84,14 @@ async function checkoutBranch(target, logStream, timeoutMs) {
69
84
  await runCommand(
70
85
  "git",
71
86
  ["checkout", target.branch],
72
- { cwd, timeout: timeoutMs },
87
+ { cwd, timeout: timeoutMs, env: { ...process.env, ...env } },
73
88
  logStream
74
89
  );
75
90
  } else {
76
91
  await runCommand(
77
92
  "git",
78
93
  ["checkout", "-b", target.branch, `origin/${target.branch}`],
79
- { cwd, timeout: timeoutMs },
94
+ { cwd, timeout: timeoutMs, env: { ...process.env, ...env } },
80
95
  logStream
81
96
  );
82
97
  }
@@ -84,7 +99,7 @@ async function checkoutBranch(target, logStream, timeoutMs) {
84
99
  await runCommand(
85
100
  "git",
86
101
  ["reset", "--hard", `origin/${target.branch}`],
87
- { cwd, timeout: timeoutMs },
102
+ { cwd, timeout: timeoutMs, env: { ...process.env, ...env } },
88
103
  logStream
89
104
  );
90
105
  }
@@ -109,7 +124,7 @@ export async function deployTarget(target, config) {
109
124
  const logStream = fs.createWriteStream(logFile, { flags: "a" });
110
125
  try {
111
126
  await ensureRepo(target, config, logStream, timeoutMs);
112
- await checkoutBranch(target, logStream, timeoutMs);
127
+ await checkoutBranch(target, config, logStream, timeoutMs);
113
128
  await runScript(target, logStream, timeoutMs);
114
129
  await appendLog(logFile, toIsoLine("Deployment finished"));
115
130
  } catch (err) {