deploymaster-panel 0.0.0-stage β†’ 1.0.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.
@@ -0,0 +1,152 @@
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>DeployMaster β€” Panel Deployment Node.js & Next.js</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>tailwind.config={theme:{extend:{colors:{bg:'#0b1220',card:'#111c33',acc:'#38bdf8'}}}}</script>
9
+ <style>
10
+ body{background:#0b1220;color:#e2e8f0;font-family:Inter,system-ui,sans-serif}
11
+ .card{background:#111c33;border:1px solid #1e3a5f;border-radius:14px}
12
+ .btn{border-radius:10px;padding:.5rem 1rem;font-weight:600}
13
+ .term{background:#020617;border:1px solid #1e293b;border-radius:12px;font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap;max-height:380px;overflow:auto;padding:12px}
14
+ .badge{font-size:11px;padding:2px 10px;border-radius:999px;font-weight:700}
15
+ dialog::backdrop{background:rgba(0,0,0,.65)}
16
+ .step-dot{width:28px;height:28px;border-radius:999px;display:flex;align-items:center;justify-content:center;font-weight:800;background:#1e293b}
17
+ .step-on{background:#38bdf8;color:#020617}
18
+ input,select,textarea{background:#0b1220!important;border:1px solid #1e3a5f!important;border-radius:10px!important;color:#e2e8f0!important;padding:.55rem .8rem!important;width:100%}
19
+ label{font-size:12px;color:#94a3b8;font-weight:600}
20
+ .check-ok{color:#4ade80}.check-bad{color:#f87171}
21
+ .drop{border:2px dashed #38bdf8;border-radius:14px}
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <div class="flex min-h-screen">
26
+ <!-- sidebar -->
27
+ <aside class="w-60 shrink-0 p-4 hidden md:flex flex-col gap-2 border-r border-slate-800">
28
+ <div class="text-2xl font-black">πŸš€ Deploy<span class="text-sky-400">Master</span></div>
29
+ <div class="text-xs text-slate-400 mb-4">On-premise β€’ Win & Ubuntu β€’ HTTP/HTTPS</div>
30
+ <button data-nav="dash" class="navbtn btn bg-sky-500 text-slate-950 text-left">πŸ“Š Dashboard</button>
31
+ <button data-nav="deploy" class="navbtn btn bg-slate-800 text-left">οΌ‹ Deploy Baru</button>
32
+ <button data-nav="system" class="navbtn btn bg-slate-800 text-left">πŸ–₯ Sistem & HTTPS</button>
33
+ <a href="/docs.html" class="btn bg-slate-800 text-left">πŸ“– Dokumentasi HTML</a>
34
+ <div class="mt-auto text-xs text-slate-500" id="srvInfo">…</div>
35
+ </aside>
36
+ <!-- main -->
37
+ <main class="flex-1 p-4 md:p-8 max-w-6xl mx-auto w-full">
38
+ <header class="flex flex-wrap items-center gap-3 mb-6">
39
+ <h1 class="text-2xl font-extrabold" id="title">Dashboard</h1>
40
+ <div class="ml-auto flex gap-2">
41
+ <span id="httpBadge" class="badge bg-emerald-500/20 text-emerald-300"></span>
42
+ <span id="httpsBadge" class="badge bg-sky-500/20 text-sky-300"></span>
43
+ <button onclick="loadAll()" class="btn bg-slate-800">↻ Refresh</button>
44
+ </div>
45
+ </header>
46
+
47
+ <!-- DASHBOARD -->
48
+ <section id="view-dash">
49
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5" id="stats"></div>
50
+ <div class="card p-4">
51
+ <div class="flex items-center mb-3"><h2 class="font-bold">πŸ“¦ Aplikasi Terdeploy</h2>
52
+ <input id="q" placeholder="Cari aplikasi…" class="ml-auto" style="max-width:220px" oninput="renderApps()"></div>
53
+ <div id="apps" class="grid md:grid-cols-2 gap-3"></div>
54
+ </div>
55
+ </section>
56
+
57
+ <!-- DEPLOY -->
58
+ <section id="view-deploy" class="hidden">
59
+ <div class="card p-5">
60
+ <div class="flex gap-2 mb-5" id="steps">
61
+ <div class="flex items-center gap-2"><div class="step-dot step-on" id="d1">1</div><span class="text-sm font-bold">Upload ZIP</span></div>
62
+ <div class="flex items-center gap-2"><div class="step-dot" id="d2">2</div><span class="text-sm font-bold">Manifest Otomatis</span></div>
63
+ <div class="flex items-center gap-2"><div class="step-dot" id="d3">3</div><span class="text-sm font-bold">Deploy</span></div>
64
+ </div>
65
+ <div id="step1">
66
+ <label>Nama aplikasi</label>
67
+ <input id="fName" placeholder="mis. toko-online" class="mb-3">
68
+ <label>Tipe service</label>
69
+ <select id="fType" class="mb-3" onchange="document.getElementById('portWrap').style.display=this.value==='worker'?'none':''">
70
+ <option value="web">🌐 Web β€” ada port / HTTP (Node.js, Next.js, Express)</option>
71
+ <option value="worker">βš™οΈ Worker β€” background saja, tanpa port (olah data, cron, consumer)</option>
72
+ </select>
73
+ <div id="drop" class="drop p-8 text-center cursor-pointer mb-2">
74
+ <div class="text-4xl">πŸ“</div>
75
+ <div class="font-bold">Drag & drop .zip project ke sini / klik untuk pilih</div>
76
+ <div class="text-xs text-slate-400">Upload via <b>HTTP</b> (testing) maupun <b>HTTPS</b> (produksi) β€” sama saja.</div>
77
+ <input type="file" id="fFile" accept=".zip" class="hidden">
78
+ <div id="fName2" class="text-sky-300 text-sm mt-2"></div>
79
+ </div>
80
+ <div id="portWrap">
81
+ <label>Port (opsional β€” auto bila kosong; tidak dipakai untuk Worker)</label>
82
+ <input id="fPort" type="number" placeholder="cth. 3001 (kosongkan = otomatis)">
83
+ </div>
84
+ <div class="flex gap-2 mt-4">
85
+ <a href="/docs.html#syarat" class="btn bg-slate-700">❓ Apa syarat project agar bisa deploy?</a>
86
+ <button onclick="analyze()" class="btn bg-sky-500 text-slate-950 ml-auto">Analisis & Buatkan Manifest β†’</button>
87
+ </div>
88
+ </div>
89
+ <div id="step2" class="hidden">
90
+ <div id="checklist" class="mb-3"></div>
91
+ <div class="grid md:grid-cols-2 gap-3">
92
+ <div><label>Manifest (auto β€” bisa edit)</label><textarea id="fManifest" rows="16" spellcheck="false"></textarea></div>
93
+ <div><label>Env vars (JSON)</label><textarea id="fEnv" rows="8">{"NODE_ENV":"production"}</textarea>
94
+ <div class="text-xs text-slate-400 mt-2">PORT otomatis di-inject. Next.js tidak perlu config tambahan. Express pastikan <code>process.env.PORT</code>.</div>
95
+ </div>
96
+ </div>
97
+ <div class="flex gap-2 mt-4">
98
+ <button onclick="gotoStep(1)" class="btn bg-slate-700">← Kembali</button>
99
+ <button onclick="doDeploy()" class="btn bg-emerald-500 text-slate-950 ml-auto">πŸš€ Deploy Sekarang</button>
100
+ </div>
101
+ </div>
102
+ <div id="step3" class="hidden">
103
+ <div id="deployResult"></div>
104
+ <button onclick="resetWizard()" class="btn bg-slate-700 mt-3">Deploy lagi</button>
105
+ </div>
106
+ </div>
107
+ </section>
108
+
109
+ <!-- SYSTEM -->
110
+ <section id="view-system" class="hidden">
111
+ <div class="card p-5 mb-3"><h2 class="font-bold mb-2">πŸ–₯ Info Server (Windows / Ubuntu)</h2><div id="sys"></div></div>
112
+ <div class="card p-5"><h2 class="font-bold mb-2">πŸ”’ HTTP / HTTPS</h2>
113
+ <p class="text-sm text-slate-300 mb-2">HTTP untuk <b>testing/upload lokal</b>, HTTPS untuk <b>produksi</b>. Upload project bisa lewat keduanya.</p>
114
+ <div class="flex flex-wrap gap-2">
115
+ <button onclick="genTLS()" class="btn bg-sky-500 text-slate-950">⚑ Generate Self-Signed (testing)</button>
116
+ <a href="/docs.html#https" class="btn bg-slate-700">Cara pasang SSL asli</a>
117
+ </div>
118
+ <div id="tlsMsg" class="text-sm mt-2"></div></div>
119
+ </section>
120
+ </main>
121
+ </div>
122
+
123
+ <!-- modal logs -->
124
+ <dialog id="logModal" class="card p-5 w-full" style="max-width:820px">
125
+ <h3 class="font-bold mb-1" id="logTitle">Log</h3>
126
+ <div class="flex gap-2 mb-2 text-sm">
127
+ <button onclick="refreshLogs(true)" class="btn bg-slate-700 text-xs">↻ Refresh log</button>
128
+ <button onclick="document.getElementById('logModal').close()" class="btn bg-slate-700 text-xs ml-auto">Tutup</button>
129
+ </div>
130
+ <div class="term" id="logBody">…</div>
131
+ </dialog>
132
+
133
+ <!-- modal manifest -->
134
+ <dialog id="manModal" class="card p-5 w-full" style="max-width:640px">
135
+ <h3 class="font-bold mb-2">πŸ“„ Manifest & Versi</h3>
136
+ <div class="flex gap-2 mb-2">
137
+ <button onclick="showTab('man')" class="btn bg-slate-700 text-xs">Manifest</button>
138
+ <button onclick="showTab('rel')" class="btn bg-slate-700 text-xs">↩ Rollback / Riwayat</button>
139
+ <button onclick="document.getElementById('manModal').close()" class="btn bg-slate-700 text-xs ml-auto">Tutup</button>
140
+ </div>
141
+ <div id="tab-man"><textarea id="manEdit" rows="14"></textarea>
142
+ <div class="flex gap-2 mt-2">
143
+ <button onclick="saveManifest()" class="btn bg-sky-500 text-slate-950 text-xs">πŸ’Ύ Simpan manifest</button>
144
+ <button onclick="redeployUp()" class="btn bg-amber-400 text-slate-950 text-xs">⬆ Redeploy versi baru (upload ZIP)</button>
145
+ <input type="file" id="reFile" accept=".zip" class="hidden">
146
+ </div></div>
147
+ <div id="tab-rel" class="hidden"><div id="relList"></div></div>
148
+ </dialog>
149
+
150
+ <script src="/app.js"></script>
151
+ </body>
152
+ </html>
@@ -0,0 +1,8 @@
1
+ #!/bin/bash
2
+ # DeployMaster - install & jalan di Ubuntu
3
+ set -e
4
+ cd "$(dirname "$0")/.."
5
+ [ -f .env ] || cp .env.example .env
6
+ command -v node >/dev/null || { echo "Install Node.js 20 dulu: https://nodejs.org"; exit 1; }
7
+ npm install
8
+ node src/server.js
@@ -0,0 +1,6 @@
1
+ @echo off
2
+ REM DeployMaster - jalankan di Windows (HTTP testing + HTTPS)
3
+ cd /d %~dp0..
4
+ if not exist .env copy .env.example .env
5
+ if not exist node_modules call npm install
6
+ node src/server.js
package/src/config.js ADDED
@@ -0,0 +1,32 @@
1
+ require('dotenv').config();
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ const ROOT = path.resolve(__dirname, '..');
6
+
7
+ function resolveDir(v, fallback) {
8
+ const p = v || fallback;
9
+ return path.isAbsolute(p) ? p : path.join(ROOT, p);
10
+ }
11
+
12
+ const cfg = {
13
+ ROOT,
14
+ PORT: parseInt(process.env.PORT || '9000', 10),
15
+ HOST: process.env.HOST || '0.0.0.0',
16
+ HTTPS_PORT: parseInt(process.env.HTTPS_PORT || '9443', 10),
17
+ HTTPS_ENABLED: String(process.env.HTTPS_ENABLED || 'true') === 'true',
18
+ AUTO_SELF_SIGNED: String(process.env.AUTO_SELF_SIGNED || 'true') === 'true',
19
+ TLS_CERT_PATH: process.env.TLS_CERT_PATH || '',
20
+ TLS_KEY_PATH: process.env.TLS_KEY_PATH || '',
21
+ DATA_DIR: resolveDir(process.env.DATA_DIR, './data'),
22
+ APPS_DIR: resolveDir(process.env.APPS_DIR, './apps'),
23
+ UPLOAD_DIR: resolveDir(process.env.UPLOAD_DIR, './uploads'),
24
+ CERTS_DIR: path.join(ROOT, 'certs'),
25
+ MAX_RELEASES: 5,
26
+ };
27
+
28
+ for (const d of [cfg.DATA_DIR, cfg.APPS_DIR, cfg.UPLOAD_DIR, cfg.CERTS_DIR]) {
29
+ fs.mkdirSync(d, { recursive: true });
30
+ }
31
+
32
+ module.exports = cfg;
@@ -0,0 +1,203 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync, spawn } = require('child_process');
4
+ const AdmZip = require('adm-zip');
5
+ const cfg = require('./config');
6
+ const { analyzeProject, suggestManifest } = require('./detector');
7
+ const proc = require('./proc');
8
+ const store = require('./store');
9
+
10
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || ('app-' + Date.now());
11
+
12
+ function appDirOf(id) { return path.join(cfg.APPS_DIR, id); }
13
+ function releasesDirOf(id) { return path.join(appDirOf(id), 'releases'); }
14
+ function releaseDirOf(id, rel) { return path.join(releasesDirOf(id), rel); }
15
+
16
+ function normalizeExtract(tmpDir) {
17
+ // Jika ZIP berisi satu folder yang di dalamnya ada package.json β†’ naikkan satu level
18
+ const entries = fs.readdirSync(tmpDir);
19
+ if (entries.length === 1) {
20
+ const only = path.join(tmpDir, entries[0]);
21
+ if (fs.statSync(only).isDirectory() && fs.existsSync(path.join(only, 'package.json'))) {
22
+ const inner = fs.readdirSync(only);
23
+ for (const f of inner) fs.renameSync(path.join(only, f), path.join(tmpDir, f));
24
+ fs.rmdirSync(only);
25
+ }
26
+ }
27
+ }
28
+
29
+ function extractZip(zipPath, destDir) {
30
+ fs.mkdirSync(destDir, { recursive: true });
31
+ const zip = new AdmZip(zipPath);
32
+ zip.extractAllTo(destDir, true);
33
+ normalizeExtract(destDir);
34
+ }
35
+
36
+ function runCmd(cmd, cwd, appId, appDir, timeoutMs = 10 * 60 * 1000) {
37
+ return new Promise((resolve, reject) => {
38
+ if (!cmd) return resolve();
39
+ proc.writeLog(appId, appDir, `$ ${cmd}`);
40
+ const isWin = process.platform === 'win32';
41
+ const child = isWin
42
+ ? spawn('cmd.exe', ['/d', '/s', '/c', cmd], { cwd, windowsHide: true })
43
+ : spawn('sh', ['-lc', cmd], { cwd });
44
+ const t = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} reject(new Error('Timeout: ' + cmd)); }, timeoutMs);
45
+ child.stdout?.on('data', d => proc.writeLog(appId, appDir, d.toString()));
46
+ child.stderr?.on('data', d => proc.writeLog(appId, appDir, d.toString()));
47
+ child.on('error', e => { clearTimeout(t); reject(e); });
48
+ child.on('exit', code => { clearTimeout(t); code === 0 ? resolve() : reject(new Error(`Command gagal (${code}): ${cmd}. Cek log.`)); });
49
+ });
50
+ }
51
+
52
+ function listReleases(id) {
53
+ const dir = releasesDirOf(id);
54
+ if (!fs.existsSync(dir)) return [];
55
+ return fs.readdirSync(dir)
56
+ .filter(f => fs.statSync(path.join(dir, f)).isDirectory())
57
+ .sort()
58
+ .reverse();
59
+ }
60
+
61
+ function readReleaseManifest(id, rel) {
62
+ try { return JSON.parse(fs.readFileSync(path.join(releaseDirOf(id, rel), 'deployment-manifest.json'), 'utf8')); }
63
+ catch { return null; }
64
+ }
65
+
66
+ async function createReleaseFromZip({ appId, appName, zipPath, port, envExtra, manifestOverride }) {
67
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
68
+ const rel = ts;
69
+ const relDir = releaseDirOf(appId, rel);
70
+ fs.mkdirSync(relDir, { recursive: true });
71
+ extractZip(zipPath, relDir);
72
+
73
+ const serviceType = (manifestOverride && manifestOverride.serviceType) || 'web';
74
+ const isWorker = serviceType === 'worker';
75
+ const finalPort = isWorker ? null : port;
76
+ const analysis = analyzeProject(relDir, serviceType);
77
+ const manifest = {
78
+ ...suggestManifest({ name: appName, framework: analysis.framework, scripts: analysis.scripts, pkg: analysis.pkg, port: finalPort, serviceType }),
79
+ ...(manifestOverride || {}),
80
+ serviceType,
81
+ port: finalPort,
82
+ env: isWorker
83
+ ? { ...((manifestOverride && manifestOverride.env) || {}), ...(envExtra || {}), NODE_ENV: 'production' }
84
+ : { ...((manifestOverride && manifestOverride.env) || {}), ...(envExtra || {}), PORT: String(finalPort), NODE_ENV: 'production' },
85
+ release: rel,
86
+ createdAt: new Date().toISOString(),
87
+ };
88
+ if (isWorker) delete manifest.env.PORT;
89
+ fs.writeFileSync(path.join(relDir, 'deployment-manifest.json'), JSON.stringify({ ...manifest, analysis: { framework: analysis.framework, checks: analysis.checks } }, null, 2));
90
+
91
+ // prune lama
92
+ const all = listReleases(appId);
93
+ for (const old of all.slice(cfg.MAX_RELEASES)) {
94
+ try { fs.rmSync(releaseDirOf(appId, old), { recursive: true, force: true }); } catch {}
95
+ }
96
+ return { rel, relDir, manifest, analysis };
97
+ }
98
+
99
+ async function buildRelease(appId, rel, manifest) {
100
+ const appDir = appDirOf(appId);
101
+ const relDir = releaseDirOf(appId, rel);
102
+ if (manifest.installCommand) await runCmd(manifest.installCommand, relDir, appId, appDir);
103
+ else await runCmd('npm install', relDir, appId, appDir);
104
+ if (manifest.buildCommand) await runCmd(manifest.buildCommand, relDir, appId, appDir);
105
+ }
106
+
107
+ function pointCurrentTo(appId, rel) {
108
+ const appDir = appDirOf(appId);
109
+ const curFile = path.join(appDir, 'current.txt');
110
+ fs.writeFileSync(curFile, rel);
111
+ return path.join(releasesDirOf(appId), rel);
112
+ }
113
+ function getCurrentRel(appId) {
114
+ try { return fs.readFileSync(path.join(appDirOf(appId), 'current.txt'), 'utf8').trim(); } catch { return null; }
115
+ }
116
+
117
+ async function deployNew({ name, zipPath, port, envExtra, manifestOverride }) {
118
+ const id = `${slug(name)}-${Date.now().toString(36)}`;
119
+ const appDir = appDirOf(id);
120
+ fs.mkdirSync(appDir, { recursive: true });
121
+ const svc = (manifestOverride && manifestOverride.serviceType) || 'web';
122
+ const app = { id, name, port: svc === 'worker' ? null : port, serviceType: svc, status: 'building', pid: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), currentRelease: null, history: [], appDir, currentDir: null };
123
+ store.upsertApp(app);
124
+ proc.writeLog(id, appDir, svc === 'worker' ? `══ Deploy baru (worker): ${name} β€” tanpa port ══` : `══ Deploy baru: ${name} β†’ :${port} ══`);
125
+
126
+ const { rel, manifest, analysis } = await createReleaseFromZip({ appId: id, appName: name, zipPath, port, envExtra, manifestOverride });
127
+ if (!analysis.canDeploy) throw Object.assign(new Error('Syarat deploy belum terpenuhi: ' + analysis.requiredFailed.join(', ')), { analysis, appId: id });
128
+
129
+ await buildRelease(id, rel, manifest);
130
+ pointCurrentTo(id, rel);
131
+ const currentDir = releaseDirOf(id, rel);
132
+ await proc.stopApp({ id, pid: null }).catch(() => {});
133
+ const child = await proc.startApp({ id, appDir, currentDir }, manifest);
134
+ const isWorker = manifest.serviceType === 'worker' || manifest.healthCheck?.mode === 'none' || !manifest.port;
135
+ const healthy = isWorker ? proc.isRunning(id) : await proc.healthCheck(manifest.port, manifest.healthCheck?.timeoutSec || 30);
136
+
137
+ const final = { ...store.getApp(id), status: healthy ? 'running' : 'running-unhealthy', pid: child.pid, port: manifest.port, serviceType: manifest.serviceType || 'web', manifest, currentRelease: rel, currentDir, updatedAt: new Date().toISOString(), history: [{ release: rel, at: new Date().toISOString(), action: 'deploy' }] };
138
+ store.upsertApp(final);
139
+ proc.writeLog(id, appDir, isWorker ? (healthy ? 'βœ“ WORKER running (cek log untuk output)' : '⚠ worker exit β€” cek log') : (healthy ? `βœ“ LIVE http://localhost:${manifest.port}` : `⚠ running tapi healthcheck gagal β€” cek log / path "/"`));
140
+ return final;
141
+ }
142
+
143
+ async function redeploy(appId, { zipPath, manifestOverride, envExtra }) {
144
+ const app = store.getApp(appId);
145
+ if (!app) throw new Error('App tidak ditemukan');
146
+ const prev = { ...app, status: 'building', updatedAt: new Date().toISOString() };
147
+ store.upsertApp(prev);
148
+ const appDir = appDirOf(appId);
149
+ proc.writeLog(appId, appDir, `══ Redeploy ══`);
150
+
151
+ // merge manifest lama sebagai basis
152
+ const baseManifest = app.manifest || {};
153
+ const svc2 = (manifestOverride && manifestOverride.serviceType) || baseManifest.serviceType || app.serviceType || 'web';
154
+ const { rel, manifest, analysis } = await createReleaseFromZip({
155
+ appId, appName: app.name, zipPath, port: manifestOverride?.port ?? app.port,
156
+ envExtra: { ...(baseManifest.env || {}), ...(envExtra || {}) },
157
+ manifestOverride: { ...baseManifest, ...(manifestOverride || {}), serviceType: svc2 },
158
+ });
159
+ if (!analysis.canDeploy) throw Object.assign(new Error('Syarat deploy belum terpenuhi'), { analysis });
160
+ await buildRelease(appId, rel, manifest);
161
+ // switch: stop lama β†’ point β†’ start baru. Jika start baru gagal, otomatis rollback ke release sebelumnya
162
+ const prevRel = app.currentRelease;
163
+ await proc.stopApp({ id: appId, pid: app.pid, appDir }).catch(() => {});
164
+ pointCurrentTo(appId, rel);
165
+ try {
166
+ const child = await proc.startApp({ id: appId, appDir, currentDir: releaseDirOf(appId, rel) }, manifest);
167
+ const isW = manifest.serviceType === 'worker' || manifest.healthCheck?.mode === 'none' || !manifest.port;
168
+ const healthy = isW ? proc.isRunning(appId) : await proc.healthCheck(manifest.port, manifest.healthCheck?.timeoutSec || 30);
169
+ const next = { ...store.getApp(appId), status: healthy ? 'running' : 'running-unhealthy', pid: child.pid, port: manifest.port, serviceType: manifest.serviceType || 'web', manifest, currentRelease: rel, currentDir: releaseDirOf(appId, rel), updatedAt: new Date().toISOString(), history: [...(app.history || []), { release: rel, at: new Date().toISOString(), action: 'redeploy' }] };
170
+ store.upsertApp(next);
171
+ return next;
172
+ } catch (e) {
173
+ proc.writeLog(appId, appDir, 'βœ– Deploy baru gagal, rollback otomatis ke ' + prevRel);
174
+ if (prevRel) {
175
+ pointCurrentTo(appId, prevRel);
176
+ const child = await proc.startApp({ id: appId, appDir, currentDir: releaseDirOf(appId, prevRel) }, baseManifest);
177
+ const rolled = { ...store.getApp(appId), status: 'running', pid: child.pid, manifest: baseManifest, currentRelease: prevRel, currentDir: releaseDirOf(appId, prevRel), history: [...(app.history || []), { release: rel, at: new Date().toISOString(), action: 'redeploy-gagal-rollback' }] };
178
+ store.upsertApp(rolled);
179
+ }
180
+ throw e;
181
+ }
182
+ }
183
+
184
+ async function rollback(appId, targetRel) {
185
+ const app = store.getApp(appId);
186
+ if (!app) throw new Error('App tidak ditemukan');
187
+ const rels = listReleases(appId);
188
+ const target = targetRel || rels.find(r => r !== app.currentRelease);
189
+ if (!target) throw new Error('Tidak ada release lain untuk rollback');
190
+ if (!rels.includes(target)) throw new Error('Release tidak ditemukan: ' + target);
191
+ const manifest = readReleaseManifest(appId, target) || app.manifest;
192
+ const appDir = appDirOf(appId);
193
+ proc.writeLog(appId, appDir, `↩ Rollback ke ${target}`);
194
+ await proc.stopApp({ id: appId, pid: app.pid, appDir }).catch(() => {});
195
+ pointCurrentTo(appId, target);
196
+ const child = await proc.startApp({ id: appId, appDir, currentDir: releaseDirOf(appId, target) }, manifest);
197
+ const next = { ...app, status: 'running', pid: child.pid, port: manifest.port, serviceType: manifest.serviceType || 'web', manifest, currentRelease: target, currentDir: releaseDirOf(appId, target), updatedAt: new Date().toISOString(), history: [...(app.history || []), { release: target, at: new Date().toISOString(), action: 'rollback' }] };
198
+ store.upsertApp(next);
199
+ proc.writeLog(appId, appDir, manifest.port ? `βœ“ Rollback sukses β†’ ${target} (port ${manifest.port})` : `βœ“ Rollback sukses β†’ ${target} (worker)`);
200
+ return next;
201
+ }
202
+
203
+ module.exports = { deployNew, redeploy, rollback, listReleases, readReleaseManifest, getCurrentRel, pointCurrentTo, appDirOf, releaseDirOf, extractZip, analyzeProject };
@@ -0,0 +1,126 @@
1
+ // Analisis project Node.js / Next.js + checklist syarat deploy + saran manifest
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ function readJSON(p) {
6
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
7
+ }
8
+ function exists(p) { return fs.existsSync(p); }
9
+
10
+ function detectFramework(pkg) {
11
+ const dep = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
12
+ if (dep.next) return 'nextjs';
13
+ if (dep.nuxt) return 'nuxt';
14
+ if (dep['@nestjs/core']) return 'nestjs';
15
+ if (dep.express || dep.fastify || dep.koa || dep.hono) return 'nodejs-express';
16
+ if (dep.vite) return 'vite';
17
+ return 'nodejs';
18
+ }
19
+
20
+ function analyzeProject(projectDir, serviceType = 'web') {
21
+ const isWorker = serviceType === 'worker';
22
+ const pkgPath = path.join(projectDir, 'package.json');
23
+ const pkg = exists(pkgPath) ? readJSON(pkgPath) : null;
24
+
25
+ const checks = [];
26
+ const push = (id, label, ok, hint, required = true) => checks.push({ id, label, ok, hint, required });
27
+
28
+ // 1. package.json
29
+ push('package-json', 'package.json ditemukan', !!pkg,
30
+ pkg ? 'OK' : 'Wajib ada package.json di root ZIP. Pastikan ZIP berisi file project, bukan folder ganda. Lihat Docs.');
31
+
32
+ const scripts = (pkg && pkg.scripts) || {};
33
+ const deps = pkg ? { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } : {};
34
+ const framework = pkg ? detectFramework(pkg) : 'unknown';
35
+
36
+ // 2. start script
37
+ const hasStart = !!scripts.start;
38
+ push('start-script', 'Script "start" tersedia', hasStart,
39
+ hasStart ? `start: ${scripts.start}` : 'Tambahkan "scripts.start", contoh: "next start" atau "node server.js". Wajib untuk deploy.');
40
+
41
+ // 3. build script (khusus nextjs/vite/nest)
42
+ const needBuild = ['nextjs', 'vite', 'nestjs', 'nuxt'].includes(framework);
43
+ const hasBuild = !!scripts.build;
44
+ push('build-script', 'Script "build" tersedia' + (needBuild ? ' (wajib framework ini)' : ' (opsional)'),
45
+ needBuild ? hasBuild : true,
46
+ hasBuild ? `build: ${scripts.build}` : (needBuild ? 'Framework ini butuh "scripts.build" (mis. "next build").' : 'Opsional untuk Node polos.'),
47
+ needBuild);
48
+
49
+ // 4. engines / node version
50
+ const engines = pkg && pkg.engines && pkg.engines.node;
51
+ push('node-version', 'Versi Node kompatibel', true,
52
+ engines ? `engines.node = ${engines}. Panel memakai Node runtime lokal (${process.version}).` : `Panel memakai Node lokal ${process.version}. Tambahkan "engines.node" bila perlu versi spesifik.`,
53
+ false);
54
+
55
+ // 5. PORT support
56
+ const startCmd = scripts.start || '';
57
+ const mentionsPort = /process\.env\.PORT|PORT/i.test(startCmd);
58
+ const hasServerJs = exists(path.join(projectDir, 'server.js')) || exists(path.join(projectDir, 'src', 'server.js')) || exists(path.join(projectDir, 'app.js')) || exists(path.join(projectDir, 'index.js'));
59
+ // Untuk nextjs otomatis support PORT, untuk express perlu cek
60
+ let portOk = true, portHint = '';
61
+ if (framework === 'nextjs' || framework === 'nuxt') {
62
+ portOk = true; portHint = 'Next/Nuxt otomatis memakai env PORT dari Panel (3001, 3002, ...).';
63
+ } else if (framework === 'nodejs-express' || framework === 'nodejs') {
64
+ portOk = hasServerJs || mentionsPort || true;
65
+ portHint = 'Pastikan aplikasi listen ke process.env.PORT (contoh: app.listen(process.env.PORT || 3000)). Panel meng-inject PORT otomatis.';
66
+ }
67
+ push('port-env', 'Mendukung env PORT' + (isWorker ? ' (opsional β€” worker tanpa HTTP)' : ''), isWorker ? true : portOk, isWorker ? 'Worker background tidak butuh PORT / HTTP. Kosongkan port.' : portHint, !isWorker);
68
+
69
+ // 6. lockfile
70
+ const hasLock = exists(path.join(projectDir, 'package-lock.json')) || exists(path.join(projectDir, 'pnpm-lock.yaml')) || exists(path.join(projectDir, 'yarn.lock')) || exists(path.join(projectDir, 'bun.lockb'));
71
+ push('lockfile', 'Lockfile ada (install reproducible)', hasLock,
72
+ hasLock ? 'OK β€” install akan reproducible.' : 'Opsional tapi disarankan (package-lock.json / pnpm-lock / yarn.lock). Tanpa ini Panel pakai "npm install".',
73
+ false);
74
+
75
+ // 7. next.config
76
+ if (framework === 'nextjs') {
77
+ const hasNextCfg = exists(path.join(projectDir, 'next.config.js')) || exists(path.join(projectDir, 'next.config.mjs')) || exists(path.join(projectDir, 'next.config.ts'));
78
+ push('next-config', 'next.config.* ditemukan', hasNextCfg,
79
+ hasNextCfg ? 'OK' : 'Opsional. Jika tidak ada, Next memakai default (tetap bisa deploy).',
80
+ false);
81
+ }
82
+
83
+ // 8. struktur zip benar (tidak nested ganda)
84
+ const rootFiles = fs.existsSync(projectDir) ? fs.readdirSync(projectDir) : [];
85
+ const nestedSingle = rootFiles.length === 1 && fs.statSync(path.join(projectDir, rootFiles[0])).isDirectory() && exists(path.join(projectDir, rootFiles[0], 'package.json'));
86
+ push('zip-structure', 'Struktur ZIP benar (package.json di root)', !nestedSingle,
87
+ nestedSingle ? `ZIP berisi satu folder "${rootFiles[0]}" β€” Panel otomatis menormalisasi, tapi sebaiknya ZIP langsung berisi package.json.` : 'OK β€” package.json di root.',
88
+ false);
89
+
90
+ const requiredFailed = checks.filter(c => c.required && !c.ok);
91
+ const canDeploy = !!pkg && requiredFailed.length === 0;
92
+
93
+ return { framework, pkg, scripts, deps, checks, canDeploy, requiredFailed: requiredFailed.map(c => c.id), serviceType, hasServerJs };
94
+ }
95
+
96
+ function suggestManifest({ name, framework, scripts, pkg, port, serviceType = 'web' }) {
97
+ const isWorker = serviceType === 'worker';
98
+ const isNext = framework === 'nextjs';
99
+ const isNest = framework === 'nestjs';
100
+ const isVite = framework === 'vite';
101
+ let installCommand = 'npm install';
102
+ if (pkg && pkg.packageManager && pkg.packageManager.startsWith('pnpm')) installCommand = 'pnpm install';
103
+ else if (pkg && pkg.packageManager && pkg.packageManager.startsWith('yarn')) installCommand = 'yarn install';
104
+
105
+ let buildCommand = scripts.build ? 'npm run build' : '';
106
+ let startCommand = scripts.start ? 'npm start' : 'node server.js';
107
+ if (isNext) { if (!scripts.build) buildCommand = 'npm run build'; if (!scripts.start) startCommand = 'npm start'; }
108
+ if (isNest) { if (!buildCommand) buildCommand = 'npm run build'; }
109
+ if (isVite) { buildCommand = scripts.build ? 'npm run build' : 'npm run build'; startCommand = scripts.start ? 'npm start' : 'npx vite preview --port $PORT'; }
110
+
111
+ return {
112
+ version: 1,
113
+ name,
114
+ framework,
115
+ serviceType,
116
+ nodeVersion: (pkg && pkg.engines && pkg.engines.node) || process.version.replace('v', ''),
117
+ port: isWorker ? null : port,
118
+ installCommand,
119
+ buildCommand,
120
+ startCommand,
121
+ healthCheck: isWorker ? { mode: 'none' } : { mode: 'http', path: '/', timeoutSec: 30 },
122
+ env: isWorker ? { NODE_ENV: 'production' } : { NODE_ENV: 'production', PORT: String(port) },
123
+ };
124
+ }
125
+
126
+ module.exports = { analyzeProject, suggestManifest, detectFramework };
package/src/proc.js ADDED
@@ -0,0 +1,116 @@
1
+ const { spawn, execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ // pid -> ChildProcess
6
+ const procs = new Map();
7
+ const tailLogs = new Map(); // appId -> string[]
8
+
9
+ function logPath(appDir) { return path.join(appDir, 'app.log'); }
10
+
11
+ function appendMem(appId, text) {
12
+ if (!tailLogs.has(appId)) tailLogs.set(appId, []);
13
+ const arr = tailLogs.get(appId);
14
+ arr.push(text);
15
+ if (arr.length > 500) arr.splice(0, arr.length - 500);
16
+ }
17
+ function appendFile(appDir, text) {
18
+ try { fs.appendFileSync(logPath(appDir), text); } catch {}
19
+ }
20
+ function writeLog(appId, appDir, text) {
21
+ const line = text.endsWith('\n') ? text : text + '\n';
22
+ appendMem(appId, line);
23
+ appendFile(appDir, `[${new Date().toISOString()}] ${line}`);
24
+ }
25
+
26
+ function isRunning(appId) {
27
+ const c = procs.get(appId);
28
+ return !!c && c.exitCode === null && !c.killed;
29
+ }
30
+
31
+ function killProc(child) {
32
+ return new Promise((resolve) => {
33
+ if (!child || child.exitCode !== null) return resolve();
34
+ try {
35
+ if (process.platform === 'win32') {
36
+ // taskkill seluruh tree agar Next.js child ikut mati
37
+ const { exec } = require('child_process');
38
+ exec(`taskkill /pid ${child.pid} /T /F`, () => resolve());
39
+ setTimeout(resolve, 3000);
40
+ } else {
41
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch {} }
42
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} resolve(); }, 2500);
43
+ }
44
+ } catch { resolve(); }
45
+ });
46
+ }
47
+
48
+ async function stopApp(app) {
49
+ const c = procs.get(app.id);
50
+ if (c) {
51
+ writeLog(app.id, app.appDir, 'β–  Stopping process...');
52
+ await killProc(c);
53
+ procs.delete(app.id);
54
+ } else if (app.pid) {
55
+ // fallback: bunuh by pid (mis. setelah restart panel)
56
+ try {
57
+ if (process.platform === 'win32') execSync(`taskkill /pid ${app.pid} /T /F`, { stdio: 'ignore' });
58
+ else process.kill(app.pid, 'SIGTERM');
59
+ } catch {}
60
+ }
61
+ }
62
+
63
+ function startApp(app, manifest) {
64
+ // app: { id, appDir, currentDir }, manifest: { startCommand, env, port, serviceType }
65
+ return new Promise((resolve, reject) => {
66
+ const cwd = app.currentDir;
67
+ const env = { ...process.env, ...(manifest.env || {}), NODE_ENV: manifest.env?.NODE_ENV || 'production' };
68
+ if (manifest.port) env.PORT = String(manifest.port);
69
+ const cmd = manifest.startCommand || 'npm start';
70
+ const isWin = process.platform === 'win32';
71
+ const child = isWin
72
+ ? spawn('cmd.exe', ['/d', '/s', '/c', cmd], { cwd, env, windowsHide: true })
73
+ : spawn('sh', ['-lc', cmd], { cwd, env, detached: true });
74
+
75
+ procs.set(app.id, child);
76
+ writeLog(app.id, app.appDir, `β–Ά start: ${cmd}${manifest.port ? ` (PORT=${manifest.port})` : ' (worker, tanpa PORT)'} pid=${child.pid}`);
77
+
78
+ child.stdout?.on('data', d => writeLog(app.id, app.appDir, d.toString()));
79
+ child.stderr?.on('data', d => writeLog(app.id, app.appDir, d.toString()));
80
+ child.on('error', (e) => { writeLog(app.id, app.appDir, `βœ– process error: ${e.message}`); });
81
+ child.on('exit', (code, sig) => {
82
+ writeLog(app.id, app.appDir, `β–  exited code=${code} signal=${sig}`);
83
+ if (procs.get(app.id) === child) procs.delete(app.id);
84
+ });
85
+ // anggap sukses jika tidak langsung exit dalam 1.5 detik
86
+ setTimeout(() => {
87
+ if (child.exitCode !== null && child.exitCode !== 0) reject(new Error('Proses langsung exit code ' + child.exitCode + '. Cek log.'));
88
+ else resolve(child);
89
+ }, 1500);
90
+ });
91
+ }
92
+
93
+ async function healthCheck(port, timeoutSec = 30) {
94
+ const start = Date.now();
95
+ const url = `http://127.0.0.1:${port}/`;
96
+ while (Date.now() - start < timeoutSec * 1000) {
97
+ try {
98
+ const r = await fetch(url, { signal: AbortSignal.timeout(3000) });
99
+ if (r.status < 500) return true;
100
+ } catch {}
101
+ await new Promise(r => setTimeout(r, 1500));
102
+ }
103
+ return false;
104
+ }
105
+
106
+ function getMemLog(appId) { return (tailLogs.get(appId) || []).join(''); }
107
+ function readFileLog(appDir, lines = 300) {
108
+ try {
109
+ const p = logPath(appDir);
110
+ if (!fs.existsSync(p)) return '';
111
+ const content = fs.readFileSync(p, 'utf8').split('\n');
112
+ return content.slice(-lines).join('\n');
113
+ } catch { return ''; }
114
+ }
115
+
116
+ module.exports = { procs, startApp, stopApp, isRunning, healthCheck, writeLog, getMemLog, readFileLog };