easy-vps 0.1.0
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/bin/cli.js +180 -0
- package/client/assets/index-C4GZ_uMC.js +104 -0
- package/client/assets/index-Dq-W19hL.css +2 -0
- package/client/assets/poppins-devanagari-400-normal-CJDn6rn8.woff2 +0 -0
- package/client/assets/poppins-devanagari-400-normal-CqVvlrh5.woff +0 -0
- package/client/assets/poppins-latin-400-normal-BOb3E3N0.woff +0 -0
- package/client/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/client/assets/poppins-latin-ext-400-normal-DaBSavcJ.woff +0 -0
- package/client/assets/poppins-latin-ext-400-normal-by3JarPu.woff2 +0 -0
- package/client/favicon.svg +1 -0
- package/client/icons.svg +24 -0
- package/client/index.html +14 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +143 -0
- package/dist/routes/async-route.d.ts +3 -0
- package/dist/routes/async-route.js +9 -0
- package/dist/routes/auth.d.ts +5 -0
- package/dist/routes/auth.js +101 -0
- package/dist/routes/database.d.ts +2 -0
- package/dist/routes/database.js +192 -0
- package/dist/routes/deploy.d.ts +2 -0
- package/dist/routes/deploy.js +161 -0
- package/dist/routes/domain.d.ts +2 -0
- package/dist/routes/domain.js +54 -0
- package/dist/routes/firewall.d.ts +2 -0
- package/dist/routes/firewall.js +63 -0
- package/dist/routes/instances.d.ts +2 -0
- package/dist/routes/instances.js +77 -0
- package/dist/routes/logs.d.ts +2 -0
- package/dist/routes/logs.js +67 -0
- package/dist/routes/system.d.ts +2 -0
- package/dist/routes/system.js +83 -0
- package/dist/services/auth.d.ts +25 -0
- package/dist/services/auth.js +128 -0
- package/dist/services/backup-config.d.ts +16 -0
- package/dist/services/backup-config.js +51 -0
- package/dist/services/backup-scheduler.d.ts +2 -0
- package/dist/services/backup-scheduler.js +104 -0
- package/dist/services/daemon.d.ts +28 -0
- package/dist/services/daemon.js +180 -0
- package/dist/services/database.d.ts +60 -0
- package/dist/services/database.js +540 -0
- package/dist/services/deploy.d.ts +102 -0
- package/dist/services/deploy.js +540 -0
- package/dist/services/domain.d.ts +26 -0
- package/dist/services/domain.js +170 -0
- package/dist/services/firewall.d.ts +24 -0
- package/dist/services/firewall.js +64 -0
- package/dist/services/instances.d.ts +17 -0
- package/dist/services/instances.js +67 -0
- package/dist/services/logs.d.ts +8 -0
- package/dist/services/logs.js +61 -0
- package/dist/services/metrics-history.d.ts +13 -0
- package/dist/services/metrics-history.js +36 -0
- package/dist/services/packages.d.ts +22 -0
- package/dist/services/packages.js +124 -0
- package/dist/services/s3.d.ts +17 -0
- package/dist/services/s3.js +93 -0
- package/dist/services/ssh.d.ts +78 -0
- package/dist/services/ssh.js +309 -0
- package/dist/services/system.d.ts +74 -0
- package/dist/services/system.js +286 -0
- package/package.json +55 -0
- package/scripts/dev.js +88 -0
- package/scripts/postinstall.js +123 -0
- package/scripts/preuninstall.js +21 -0
- package/scripts/try-install.sh +65 -0
- package/scripts/ui.js +54 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.deploymentHistory = deploymentHistory;
|
|
37
|
+
exports.createJob = createJob;
|
|
38
|
+
exports.takeJob = takeJob;
|
|
39
|
+
exports.requeueJob = requeueJob;
|
|
40
|
+
exports.buildScript = buildScript;
|
|
41
|
+
exports.rebuildScript = rebuildScript;
|
|
42
|
+
exports.deployStream = deployStream;
|
|
43
|
+
exports.rebuildStream = rebuildStream;
|
|
44
|
+
exports.list = list;
|
|
45
|
+
exports.stop = stop;
|
|
46
|
+
exports.logStream = logStream;
|
|
47
|
+
exports.remove = remove;
|
|
48
|
+
exports.readEnv = readEnv;
|
|
49
|
+
exports.writeEnv = writeEnv;
|
|
50
|
+
// Git clone, PM2 runner, Docker runner.
|
|
51
|
+
//
|
|
52
|
+
// A deploy is one long bash script streamed to the browser line by line, so the
|
|
53
|
+
// user watches the same output they would see running these commands by hand.
|
|
54
|
+
// The script is assembled here from what the create form collected: everything
|
|
55
|
+
// after the clone is the user's own install/build/run commands.
|
|
56
|
+
const node_crypto_1 = require("node:crypto");
|
|
57
|
+
const domains = __importStar(require("./domain"));
|
|
58
|
+
const ssh_1 = require("./ssh");
|
|
59
|
+
/** Where clones live on the VPS, under the login user's home. */
|
|
60
|
+
const APPS_DIR = '"$HOME/easy-vps-apps"';
|
|
61
|
+
/** Names are validated on the way in; this is the guard for any other caller. */
|
|
62
|
+
const SAFE_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
63
|
+
// remote.writeFile()/readFile() single-quote their path argument, so a literal
|
|
64
|
+
// "$HOME" passed through them would never expand. Resolving it once up front
|
|
65
|
+
// keeps the rest of the code able to build ordinary absolute paths.
|
|
66
|
+
const homeDirs = new WeakMap();
|
|
67
|
+
async function homeDir(remote) {
|
|
68
|
+
let cached = homeDirs.get(remote);
|
|
69
|
+
if (!cached) {
|
|
70
|
+
cached = remote.execOrThrow('echo $HOME', 10_000).then((out) => out.trim());
|
|
71
|
+
homeDirs.set(remote, cached);
|
|
72
|
+
}
|
|
73
|
+
return cached;
|
|
74
|
+
}
|
|
75
|
+
async function appDir(remote, name) {
|
|
76
|
+
return `${await homeDir(remote)}/easy-vps-apps/${name}`;
|
|
77
|
+
}
|
|
78
|
+
/** Filename the deploy config is mirrored to inside the project's own clone,
|
|
79
|
+
* so Redeploy survives a panel restart instead of only living in `lastConfig`. */
|
|
80
|
+
const CONFIG_FILENAME = '.easy-vps.json';
|
|
81
|
+
async function writeConfigFile(remote, config) {
|
|
82
|
+
try {
|
|
83
|
+
const dir = await appDir(remote, config.name);
|
|
84
|
+
await remote.writeFile(`${dir}/${CONFIG_FILENAME}`, JSON.stringify(config));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Best-effort: if this fails, Redeploy just won't survive a panel restart
|
|
88
|
+
// until the next successful run.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function readConfigFile(remote, name) {
|
|
92
|
+
try {
|
|
93
|
+
const dir = await appDir(remote, name);
|
|
94
|
+
const path = `${dir}/${CONFIG_FILENAME}`;
|
|
95
|
+
if (!(await remote.exists(path)))
|
|
96
|
+
return null;
|
|
97
|
+
return JSON.parse(await remote.readFile(path));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const jobs = new Map();
|
|
104
|
+
const JOB_TTL_MS = 5 * 60 * 1000;
|
|
105
|
+
const HISTORY_LIMIT = 20;
|
|
106
|
+
/** Deploy history, newest first, keyed by site name. In memory only — it does
|
|
107
|
+
* not survive a panel restart, same as the sessions and job queue above. */
|
|
108
|
+
const history = new Map();
|
|
109
|
+
function pushHistory(name, record) {
|
|
110
|
+
const list = history.get(name) ?? [];
|
|
111
|
+
list.unshift(record);
|
|
112
|
+
list.length = Math.min(list.length, HISTORY_LIMIT);
|
|
113
|
+
history.set(name, list);
|
|
114
|
+
}
|
|
115
|
+
function deploymentHistory(name) {
|
|
116
|
+
return history.get(name) ?? [];
|
|
117
|
+
}
|
|
118
|
+
/** The config behind a site's most recent deploy, so Redeploy can resubmit it
|
|
119
|
+
* without asking the user to fill the form out again. */
|
|
120
|
+
const lastConfig = new Map();
|
|
121
|
+
function sweepJobs() {
|
|
122
|
+
const cutoff = Date.now() - JOB_TTL_MS;
|
|
123
|
+
for (const [id, job] of jobs) {
|
|
124
|
+
if (job.createdAt < cutoff)
|
|
125
|
+
jobs.delete(id);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Validates the form's payload and queues it; the id is what the UI streams. */
|
|
129
|
+
function createJob(sessionId, config, kind = 'deploy') {
|
|
130
|
+
sweepJobs();
|
|
131
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(config.name ?? '')) {
|
|
132
|
+
throw new Error('Invalid project name');
|
|
133
|
+
}
|
|
134
|
+
if (!/^https:\/\/github\.com\/.+\/.+/.test(config.repository ?? '')) {
|
|
135
|
+
throw new Error('Repository must be a GitHub URL');
|
|
136
|
+
}
|
|
137
|
+
if (config.branch && /[~^:?*[\\\s]/.test(config.branch)) {
|
|
138
|
+
throw new Error('Invalid branch name');
|
|
139
|
+
}
|
|
140
|
+
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
|
|
141
|
+
throw new Error('Invalid port');
|
|
142
|
+
}
|
|
143
|
+
if (config.runtime !== 'pm2' && config.runtime !== 'docker') {
|
|
144
|
+
throw new Error('Runtime must be pm2 or docker');
|
|
145
|
+
}
|
|
146
|
+
for (const command of [config.installCommand, config.buildCommand, config.runCommand]) {
|
|
147
|
+
if (!command?.trim())
|
|
148
|
+
throw new Error('Install, build and run commands are all required');
|
|
149
|
+
}
|
|
150
|
+
lastConfig.set(config.name, config);
|
|
151
|
+
const id = (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
152
|
+
jobs.set(id, { config, kind, sessionId, createdAt: Date.now() });
|
|
153
|
+
return id;
|
|
154
|
+
}
|
|
155
|
+
function takeJob(id, sessionId) {
|
|
156
|
+
const job = jobs.get(id);
|
|
157
|
+
if (!job || job.sessionId !== sessionId)
|
|
158
|
+
return null;
|
|
159
|
+
jobs.delete(id);
|
|
160
|
+
return { config: job.config, kind: job.kind };
|
|
161
|
+
}
|
|
162
|
+
/** The config behind a site's last deploy — checked in memory first, then in
|
|
163
|
+
* the file next to its clone, so a panel restart doesn't lose it. */
|
|
164
|
+
async function resolveConfig(remote, name) {
|
|
165
|
+
const cached = lastConfig.get(name);
|
|
166
|
+
if (cached)
|
|
167
|
+
return cached;
|
|
168
|
+
const config = await readConfigFile(remote, name);
|
|
169
|
+
if (config)
|
|
170
|
+
lastConfig.set(name, config);
|
|
171
|
+
return config;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Re-queues the config behind `name`'s last deploy — full pull, or build+restart
|
|
175
|
+
* only, depending on `kind`. Returns null when no config is known at all: the
|
|
176
|
+
* site was never deployed through here, or its only deploy never succeeded.
|
|
177
|
+
*/
|
|
178
|
+
async function requeueJob(remote, sessionId, name, kind) {
|
|
179
|
+
const config = await resolveConfig(remote, name);
|
|
180
|
+
return config ? createJob(sessionId, config, kind) : null;
|
|
181
|
+
}
|
|
182
|
+
/** `echo` of a step banner, so the log reads as phases rather than a wall of output. */
|
|
183
|
+
function step(label) {
|
|
184
|
+
return `echo ""; echo "==> ${label}"`;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Starts the app under PM2. Arbitrary run commands ("npm start", "node dist/x")
|
|
188
|
+
* are handed to bash rather than exec'd directly — pm2 can only start a file.
|
|
189
|
+
*/
|
|
190
|
+
function pm2Start(config) {
|
|
191
|
+
const name = (0, ssh_1.quote)(config.name);
|
|
192
|
+
return [
|
|
193
|
+
step('Starting with PM2'),
|
|
194
|
+
`pm2 delete ${name} >/dev/null 2>&1 || true`,
|
|
195
|
+
`PORT=${config.port} pm2 start bash --name ${name} -- -lc ${(0, ssh_1.quote)(config.runCommand)}`,
|
|
196
|
+
'pm2 save',
|
|
197
|
+
`pm2 describe ${name}`,
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
/** Builds and runs the repo's Dockerfile, replacing any container of the same name. */
|
|
201
|
+
function dockerStart(config) {
|
|
202
|
+
const name = (0, ssh_1.quote)(config.name);
|
|
203
|
+
const tag = (0, ssh_1.quote)(`easy-vps/${config.name}`);
|
|
204
|
+
return [
|
|
205
|
+
step('Building the Docker image'),
|
|
206
|
+
`docker build -t ${tag} .`,
|
|
207
|
+
step('Starting the container'),
|
|
208
|
+
`docker rm -f ${name} >/dev/null 2>&1 || true`,
|
|
209
|
+
`docker run -d --name ${name} --restart unless-stopped ` +
|
|
210
|
+
`-p ${config.port}:${config.port} -e PORT=${config.port} ${tag}`,
|
|
211
|
+
`docker ps --filter name=${name}`,
|
|
212
|
+
].join('\n');
|
|
213
|
+
}
|
|
214
|
+
/** Restarts the app under PM2 in place — no delete, just a fresh process. */
|
|
215
|
+
function pm2Restart(config) {
|
|
216
|
+
const name = (0, ssh_1.quote)(config.name);
|
|
217
|
+
return [step('Restarting with PM2'), `pm2 restart ${name} --update-env`, `pm2 describe ${name}`].join('\n');
|
|
218
|
+
}
|
|
219
|
+
/** Restarts the existing container in place — no rebuild, no re-create. */
|
|
220
|
+
function dockerRestart(config) {
|
|
221
|
+
const name = (0, ssh_1.quote)(config.name);
|
|
222
|
+
return [step('Restarting the container'), `docker restart ${name}`, `docker ps --filter name=${name}`].join('\n');
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The whole deploy as one script. `set -e` stops at the first failing step, so a
|
|
226
|
+
* broken build never reaches the run stage and the exit code reaches the UI.
|
|
227
|
+
*/
|
|
228
|
+
function buildScript(config) {
|
|
229
|
+
if (!SAFE_NAME.test(config.name))
|
|
230
|
+
throw new Error('Invalid project name');
|
|
231
|
+
const branch = config.branch?.trim() || 'main';
|
|
232
|
+
// Double quotes, not quote(): the shell has to expand $HOME itself.
|
|
233
|
+
const dir = `"$HOME/easy-vps-apps/${config.name}"`;
|
|
234
|
+
return [
|
|
235
|
+
'set -e',
|
|
236
|
+
`echo "==> Deploying ${config.name} (${config.domain ?? `port ${config.port}`})"`,
|
|
237
|
+
`mkdir -p ${APPS_DIR}`,
|
|
238
|
+
step(`Fetching ${config.repository} @ ${branch}`),
|
|
239
|
+
// Re-deploying an existing site updates the clone in place instead of failing.
|
|
240
|
+
`if [ -d "$HOME/easy-vps-apps/${config.name}/.git" ]; then
|
|
241
|
+
cd ${dir}
|
|
242
|
+
git remote set-url origin ${(0, ssh_1.quote)(config.repository)}
|
|
243
|
+
git fetch --depth 1 origin ${(0, ssh_1.quote)(branch)}
|
|
244
|
+
git checkout -B ${(0, ssh_1.quote)(branch)} FETCH_HEAD
|
|
245
|
+
else
|
|
246
|
+
git clone --depth 1 --branch ${(0, ssh_1.quote)(branch)} ${(0, ssh_1.quote)(config.repository)} ${dir}
|
|
247
|
+
cd ${dir}
|
|
248
|
+
fi`,
|
|
249
|
+
'git --no-pager log -1 --pretty="cloned %h %s"',
|
|
250
|
+
step('Installing dependencies'),
|
|
251
|
+
config.installCommand,
|
|
252
|
+
step('Building'),
|
|
253
|
+
config.buildCommand,
|
|
254
|
+
config.runtime === 'pm2' ? pm2Start(config) : dockerStart(config),
|
|
255
|
+
`echo ""; echo "==> ${config.name} is live on port ${config.port}"`,
|
|
256
|
+
].join('\n');
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Rebuilds and restarts a site without touching its clone — no fetch, no
|
|
260
|
+
* `npm install`. For when only the running process needs to pick up a change
|
|
261
|
+
* (a rebuild of already-pulled code, or a restart after an env upload) and a
|
|
262
|
+
* full pull isn't wanted.
|
|
263
|
+
*/
|
|
264
|
+
function rebuildScript(config) {
|
|
265
|
+
if (!SAFE_NAME.test(config.name))
|
|
266
|
+
throw new Error('Invalid project name');
|
|
267
|
+
const dir = `"$HOME/easy-vps-apps/${config.name}"`;
|
|
268
|
+
return [
|
|
269
|
+
'set -e',
|
|
270
|
+
`echo "==> Rebuilding ${config.name} (${config.domain ?? `port ${config.port}`})"`,
|
|
271
|
+
`if [ ! -d "$HOME/easy-vps-apps/${config.name}/.git" ]; then
|
|
272
|
+
echo "No existing clone for ${config.name} — use Pull & Redeploy instead." >&2
|
|
273
|
+
exit 1
|
|
274
|
+
fi`,
|
|
275
|
+
`cd ${dir}`,
|
|
276
|
+
step('Building'),
|
|
277
|
+
config.buildCommand,
|
|
278
|
+
config.runtime === 'pm2' ? pm2Restart(config) : dockerRestart(config),
|
|
279
|
+
`echo ""; echo "==> ${config.name} is live on port ${config.port}"`,
|
|
280
|
+
].join('\n');
|
|
281
|
+
}
|
|
282
|
+
/** The most recent commit hash recorded for a site, if any run has reported one. */
|
|
283
|
+
function lastKnownCommit(name) {
|
|
284
|
+
return history.get(name)?.find((record) => record.commit)?.commit;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Runs a queued script — full deploy or build-only rebuild — streaming every
|
|
288
|
+
* line and recording it to history. Returns a cancel function.
|
|
289
|
+
*/
|
|
290
|
+
function runTrackedStream(remote, config, kind, script, handlers) {
|
|
291
|
+
handlers.onOutput(`$ ssh ${remote.username}@${remote.host}`);
|
|
292
|
+
const record = {
|
|
293
|
+
id: (0, node_crypto_1.randomBytes)(8).toString('hex'),
|
|
294
|
+
kind,
|
|
295
|
+
branch: config.branch?.trim() || 'main',
|
|
296
|
+
// A rebuild doesn't re-fetch, so it's still running whatever was last pulled.
|
|
297
|
+
commit: kind === 'rebuild' ? lastKnownCommit(config.name) : undefined,
|
|
298
|
+
startedAt: Date.now(),
|
|
299
|
+
status: 'running',
|
|
300
|
+
};
|
|
301
|
+
pushHistory(config.name, record);
|
|
302
|
+
const tracked = {
|
|
303
|
+
onOutput: (line) => {
|
|
304
|
+
// buildScript's own `git log` step prints this once the clone lands.
|
|
305
|
+
const commit = /^cloned ([0-9a-f]{4,40})/.exec(line);
|
|
306
|
+
if (commit)
|
|
307
|
+
record.commit = commit[1];
|
|
308
|
+
handlers.onOutput(line);
|
|
309
|
+
},
|
|
310
|
+
onDone: () => {
|
|
311
|
+
record.status = 'success';
|
|
312
|
+
record.finishedAt = Date.now();
|
|
313
|
+
// Fire-and-forget: the clone now exists, so this is the point where the
|
|
314
|
+
// config can actually be written next to it.
|
|
315
|
+
void writeConfigFile(remote, config);
|
|
316
|
+
handlers.onDone();
|
|
317
|
+
},
|
|
318
|
+
onError: (message) => {
|
|
319
|
+
record.status = 'failed';
|
|
320
|
+
record.finishedAt = Date.now();
|
|
321
|
+
record.error = message;
|
|
322
|
+
handlers.onError(message);
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
// No timeout: installs and builds routinely outlast any exec() deadline.
|
|
326
|
+
return remote.execStream((0, ssh_1.loginShell)(script), tracked);
|
|
327
|
+
}
|
|
328
|
+
/** Runs a queued full deploy: fetch, install, build, recreate the process. */
|
|
329
|
+
function deployStream(remote, config, handlers) {
|
|
330
|
+
return runTrackedStream(remote, config, 'deploy', buildScript(config), handlers);
|
|
331
|
+
}
|
|
332
|
+
/** Runs a queued rebuild: build against the existing clone, restart in place. */
|
|
333
|
+
function rebuildStream(remote, config, handlers) {
|
|
334
|
+
return runTrackedStream(remote, config, 'rebuild', rebuildScript(config), handlers);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Every directory under the apps dir, with the repo it was cloned from. Printed
|
|
338
|
+
* as one tab-separated line per app so a single exec covers the whole list.
|
|
339
|
+
*/
|
|
340
|
+
async function clones(remote) {
|
|
341
|
+
const script = [
|
|
342
|
+
'shopt -s nullglob',
|
|
343
|
+
'for dir in "$HOME/easy-vps-apps"/*/; do',
|
|
344
|
+
' name=$(basename "$dir")',
|
|
345
|
+
' repo=$(git -C "$dir" config --get remote.origin.url 2>/dev/null)',
|
|
346
|
+
' branch=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null)',
|
|
347
|
+
' printf "%s\\t%s\\t%s\\n" "$name" "$repo" "$branch"',
|
|
348
|
+
'done',
|
|
349
|
+
].join('\n');
|
|
350
|
+
const { stdout, code } = await remote.exec((0, ssh_1.loginShell)(script), 15_000);
|
|
351
|
+
if (code !== 0)
|
|
352
|
+
return [];
|
|
353
|
+
return stdout
|
|
354
|
+
.split('\n')
|
|
355
|
+
.map((line) => line.split('\t'))
|
|
356
|
+
.filter((parts) => parts[0]?.trim())
|
|
357
|
+
.map(([name, repository, branch]) => ({
|
|
358
|
+
name: name.trim(),
|
|
359
|
+
repository: repository?.trim() ?? '',
|
|
360
|
+
branch: branch?.trim() || 'main',
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
/** PM2 processes by name, with the port they were started on. */
|
|
364
|
+
async function pm2Processes(remote) {
|
|
365
|
+
const found = new Map();
|
|
366
|
+
const { stdout, code } = await remote.exec((0, ssh_1.loginShell)('pm2 jlist'), 15_000);
|
|
367
|
+
if (code !== 0)
|
|
368
|
+
return found;
|
|
369
|
+
// pm2 prints update notices around the JSON; take the array itself.
|
|
370
|
+
const start = stdout.indexOf('[');
|
|
371
|
+
const end = stdout.lastIndexOf(']');
|
|
372
|
+
if (start === -1 || end <= start)
|
|
373
|
+
return found;
|
|
374
|
+
try {
|
|
375
|
+
const parsed = JSON.parse(stdout.slice(start, end + 1));
|
|
376
|
+
for (const process of parsed) {
|
|
377
|
+
if (!process.name)
|
|
378
|
+
continue;
|
|
379
|
+
const rawPort = process.pm2_env?.PORT ?? process.pm2_env?.env?.PORT;
|
|
380
|
+
const port = Number(rawPort);
|
|
381
|
+
found.set(process.name, {
|
|
382
|
+
status: process.pm2_env?.status ?? 'unknown',
|
|
383
|
+
port: Number.isInteger(port) && port > 0 ? port : undefined,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return found;
|
|
389
|
+
}
|
|
390
|
+
return found;
|
|
391
|
+
}
|
|
392
|
+
/** Docker containers by name, with the host port they publish. */
|
|
393
|
+
async function dockerContainers(remote) {
|
|
394
|
+
const found = new Map();
|
|
395
|
+
const command = 'docker ps -a --format "{{.Names}}\\t{{.State}}\\t{{.Ports}}"';
|
|
396
|
+
const { stdout, code } = await remote.exec((0, ssh_1.loginShell)(await remote.privileged(command)), 15_000);
|
|
397
|
+
if (code !== 0)
|
|
398
|
+
return found;
|
|
399
|
+
for (const line of stdout.split('\n')) {
|
|
400
|
+
const [name, state, ports] = line.split('\t');
|
|
401
|
+
if (!name?.trim())
|
|
402
|
+
continue;
|
|
403
|
+
// "0.0.0.0:4000->4000/tcp" — the published host port is the one before "->".
|
|
404
|
+
const published = /:(\d+)->/.exec(ports ?? '');
|
|
405
|
+
found.set(name.trim(), {
|
|
406
|
+
running: state?.trim() === 'running',
|
|
407
|
+
port: published ? Number(published[1]) : undefined,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return found;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Websites this panel deployed: the clones on disk, joined with whatever PM2 or
|
|
414
|
+
* Docker currently reports about them, and the domain pointing at their port.
|
|
415
|
+
*/
|
|
416
|
+
async function list(remote) {
|
|
417
|
+
const [cloned, pm2, docker, configured] = await Promise.all([
|
|
418
|
+
clones(remote),
|
|
419
|
+
pm2Processes(remote),
|
|
420
|
+
dockerContainers(remote),
|
|
421
|
+
domains.list(remote).catch(() => []),
|
|
422
|
+
]);
|
|
423
|
+
const domainForPort = new Map(configured.map((entry) => [entry.port, entry.domain]));
|
|
424
|
+
return cloned.map(({ name, repository, branch }) => {
|
|
425
|
+
const process = pm2.get(name);
|
|
426
|
+
const container = docker.get(name);
|
|
427
|
+
const runtime = container && !process ? 'docker' : 'pm2';
|
|
428
|
+
const port = process?.port ?? container?.port ?? 0;
|
|
429
|
+
let status = 'unknown';
|
|
430
|
+
if (process)
|
|
431
|
+
status = process.status === 'online' ? 'running' : 'stopped';
|
|
432
|
+
else if (container)
|
|
433
|
+
status = container.running ? 'running' : 'stopped';
|
|
434
|
+
return {
|
|
435
|
+
name,
|
|
436
|
+
repository,
|
|
437
|
+
branch,
|
|
438
|
+
domain: domainForPort.get(port),
|
|
439
|
+
runtime,
|
|
440
|
+
port,
|
|
441
|
+
status,
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async function stop(_name) {
|
|
446
|
+
throw new Error('stop() is not implemented yet');
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Live tail of a deployed site's own output — pm2's log for that process, or
|
|
450
|
+
* the container's stdout/stderr — starting from recent history and following
|
|
451
|
+
* as new lines arrive. Runtime is looked up fresh rather than trusted from the
|
|
452
|
+
* caller, since it's what's actually running that matters here.
|
|
453
|
+
*/
|
|
454
|
+
function logStream(remote, name, handlers) {
|
|
455
|
+
if (!SAFE_NAME.test(name)) {
|
|
456
|
+
handlers.onError('Invalid project name');
|
|
457
|
+
return () => undefined;
|
|
458
|
+
}
|
|
459
|
+
let cancel = () => undefined;
|
|
460
|
+
let cancelled = false;
|
|
461
|
+
void (async () => {
|
|
462
|
+
const [pm2, docker] = await Promise.all([pm2Processes(remote), dockerContainers(remote)]);
|
|
463
|
+
if (cancelled)
|
|
464
|
+
return;
|
|
465
|
+
if (pm2.has(name)) {
|
|
466
|
+
const command = `pm2 logs ${(0, ssh_1.quote)(name)} --lines 200 --raw`;
|
|
467
|
+
handlers.onOutput(`$ ${command}`);
|
|
468
|
+
cancel = remote.execStream((0, ssh_1.loginShell)(command), handlers);
|
|
469
|
+
}
|
|
470
|
+
else if (docker.has(name)) {
|
|
471
|
+
const command = `docker logs -f --tail 200 ${(0, ssh_1.quote)(name)}`;
|
|
472
|
+
handlers.onOutput(`$ ${command}`);
|
|
473
|
+
cancel = remote.execStream((0, ssh_1.loginShell)(command), handlers);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
handlers.onError(`"${name}" is not currently running under pm2 or Docker.`);
|
|
477
|
+
}
|
|
478
|
+
})().catch((error) => {
|
|
479
|
+
if (!cancelled)
|
|
480
|
+
handlers.onError(error.message);
|
|
481
|
+
});
|
|
482
|
+
return () => {
|
|
483
|
+
cancelled = true;
|
|
484
|
+
cancel();
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Tears down a deployed site: stops it under whichever runtime is running it —
|
|
489
|
+
* a name only ever exists in one of pm2 or docker, but both are tried since
|
|
490
|
+
* that runtime isn't known here — then deletes its clone from disk.
|
|
491
|
+
*/
|
|
492
|
+
async function remove(remote, name) {
|
|
493
|
+
if (!SAFE_NAME.test(name))
|
|
494
|
+
throw new Error('Invalid project name');
|
|
495
|
+
const script = [
|
|
496
|
+
`pm2 delete ${(0, ssh_1.quote)(name)} >/dev/null 2>&1 || true`,
|
|
497
|
+
'pm2 save >/dev/null 2>&1 || true',
|
|
498
|
+
`docker rm -f ${(0, ssh_1.quote)(name)} >/dev/null 2>&1 || true`,
|
|
499
|
+
`rm -rf "$HOME/easy-vps-apps/${name}"`,
|
|
500
|
+
].join('\n');
|
|
501
|
+
await remote.execOrThrow((0, ssh_1.loginShell)(script), 30_000);
|
|
502
|
+
}
|
|
503
|
+
/** Restarts whichever runtime is currently running `name`, so it re-reads its .env. */
|
|
504
|
+
async function restart(remote, name) {
|
|
505
|
+
const [pm2, docker] = await Promise.all([pm2Processes(remote), dockerContainers(remote)]);
|
|
506
|
+
if (pm2.has(name)) {
|
|
507
|
+
await remote.execOrThrow((0, ssh_1.loginShell)(`pm2 restart ${(0, ssh_1.quote)(name)} --update-env`), 30_000);
|
|
508
|
+
}
|
|
509
|
+
else if (docker.has(name)) {
|
|
510
|
+
await remote.execOrThrow((0, ssh_1.loginShell)(`docker restart ${(0, ssh_1.quote)(name)}`), 30_000);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
throw new Error(`"${name}" is not currently running under pm2 or Docker.`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** Raw .env contents at the project's root, or null when none has been uploaded. */
|
|
517
|
+
async function readEnv(remote, name) {
|
|
518
|
+
if (!SAFE_NAME.test(name))
|
|
519
|
+
throw new Error('Invalid project name');
|
|
520
|
+
const path = `${await appDir(remote, name)}/.env`;
|
|
521
|
+
if (!(await remote.exists(path)))
|
|
522
|
+
return null;
|
|
523
|
+
return remote.readFile(path);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Replaces the project's .env with the uploaded contents and restarts the site
|
|
527
|
+
* so it picks up the new values. Docker containers only see this if their
|
|
528
|
+
* Dockerfile reads .env from the project directory at container start — a
|
|
529
|
+
* container built to bake env vars in at image build time will need a
|
|
530
|
+
* re-deploy instead, not just a restart.
|
|
531
|
+
*/
|
|
532
|
+
async function writeEnv(remote, name, content) {
|
|
533
|
+
if (!SAFE_NAME.test(name))
|
|
534
|
+
throw new Error('Invalid project name');
|
|
535
|
+
const dir = await appDir(remote, name);
|
|
536
|
+
if (!(await remote.exists(dir)))
|
|
537
|
+
throw new Error(`"${name}" has not been deployed`);
|
|
538
|
+
await remote.writeFile(`${dir}/.env`, content);
|
|
539
|
+
await restart(remote, name);
|
|
540
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Remote } from './ssh';
|
|
2
|
+
export interface DomainConfig {
|
|
3
|
+
domain: string;
|
|
4
|
+
/** Local port the app listens on; Nginx proxies to it. */
|
|
5
|
+
port: number;
|
|
6
|
+
ssl?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Outcome of addDomain(). The domain is always in place; only SSL can partially fail. */
|
|
9
|
+
export interface AddDomainResult {
|
|
10
|
+
domain: string;
|
|
11
|
+
/** "skipped" when SSL was not requested. */
|
|
12
|
+
ssl: 'issued' | 'skipped' | 'failed';
|
|
13
|
+
/** Why SSL failed, when it did. */
|
|
14
|
+
sslError?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface DomainState {
|
|
17
|
+
domain: string;
|
|
18
|
+
port: number;
|
|
19
|
+
ssl: boolean;
|
|
20
|
+
active: boolean;
|
|
21
|
+
certificateActive: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function list(remote: Remote): Promise<DomainState[]>;
|
|
24
|
+
export declare function addDomain(remote: Remote, config: DomainConfig): Promise<AddDomainResult>;
|
|
25
|
+
export declare function removeDomain(remote: Remote, domain: string): Promise<void>;
|
|
26
|
+
export declare function issueCertificate(remote: Remote, domain: string): Promise<void>;
|