@naviyra/cloud-panel 0.0.0-stage → 0.0.2

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.
Files changed (56) hide show
  1. package/.env.example +40 -0
  2. package/README.md +145 -2
  3. package/apps/agent/package.json +13 -0
  4. package/apps/agent/src/cloudflare.mjs +733 -0
  5. package/apps/agent/src/native-worker.mjs +52 -0
  6. package/apps/agent/src/native.mjs +457 -0
  7. package/apps/agent/src/runtime.mjs +760 -0
  8. package/apps/agent/src/server.mjs +195 -0
  9. package/apps/agent/src/static-server.mjs +53 -0
  10. package/apps/panel/AGENTS.md +9 -0
  11. package/apps/panel/CLAUDE.md +1 -0
  12. package/apps/panel/app/api/[...path]/route.js +445 -0
  13. package/apps/panel/app/globals.css +2057 -0
  14. package/apps/panel/app/icon.png +0 -0
  15. package/apps/panel/app/layout.jsx +18 -0
  16. package/apps/panel/app/login/page.jsx +108 -0
  17. package/apps/panel/app/page.jsx +33 -0
  18. package/apps/panel/app/ui/cloudflare.jsx +926 -0
  19. package/apps/panel/app/ui/dashboard.jsx +1487 -0
  20. package/apps/panel/app/ui/databases.jsx +293 -0
  21. package/apps/panel/app/ui/skeleton.jsx +196 -0
  22. package/apps/panel/app/ui/theme.jsx +35 -0
  23. package/apps/panel/lib/agent.mjs +83 -0
  24. package/apps/panel/lib/auth.mjs +48 -0
  25. package/apps/panel/lib/database-runtime.mjs +243 -0
  26. package/apps/panel/lib/databases.mjs +292 -0
  27. package/apps/panel/lib/db.mjs +6 -0
  28. package/apps/panel/next.config.mjs +8 -0
  29. package/apps/panel/package.json +22 -0
  30. package/apps/panel/public/naviyra-logo.png +0 -0
  31. package/bin/naviyra.mjs +215 -0
  32. package/db/001_init.sql +28 -0
  33. package/db/002_native_runtimes.sql +3 -0
  34. package/db/003_databases.sql +8 -0
  35. package/db/004_database_engines.sql +4 -0
  36. package/deploy/naviyra-agent.service +17 -0
  37. package/docker-compose.yml +18 -0
  38. package/docs/managed-runtime.md +52 -0
  39. package/docs/native-hosts.md +48 -0
  40. package/package.json +61 -4
  41. package/packages/runtime/artifacts.json +32 -0
  42. package/packages/runtime/index.cjs +526 -0
  43. package/packages/runtime/index.mjs +10 -0
  44. package/packages/runtime/package.json +6 -0
  45. package/packages/shared/index.mjs +85 -0
  46. package/packages/shared/package.json +9 -0
  47. package/scripts/migrate.mjs +18 -0
  48. package/scripts/panel-control.mjs +195 -0
  49. package/scripts/postgres-local.mjs +97 -0
  50. package/scripts/runtime-download-manifest.mjs +30 -0
  51. package/scripts/runtime.mjs +20 -0
  52. package/scripts/seed.mjs +35 -0
  53. package/scripts/service-supervisor.mjs +164 -0
  54. package/scripts/setup-local.mjs +215 -0
  55. package/scripts/start-naviyra.mjs +303 -0
  56. package/scripts/windows-boot.mjs +259 -0
@@ -0,0 +1,52 @@
1
+ // An IPC watchdog owns the entire process tree. Agent exit/disconnect stops apps too.
2
+ import { spawn } from "node:child_process";
3
+ let child,
4
+ stopping = false;
5
+ async function stop() {
6
+ if (stopping) return;
7
+ stopping = true;
8
+ if (child?.pid) {
9
+ if (process.platform === "win32")
10
+ await new Promise((resolve) => {
11
+ const killer = spawn(
12
+ `${process.env.SystemRoot || "C:\\Windows"}\\System32\\taskkill.exe`,
13
+ ["/PID", String(child.pid), "/T", "/F"],
14
+ { windowsHide: true, stdio: "ignore" },
15
+ );
16
+ killer.on("error", resolve);
17
+ killer.on("close", resolve);
18
+ });
19
+ else {
20
+ try {
21
+ process.kill(-child.pid, "SIGKILL");
22
+ } catch {}
23
+ }
24
+ }
25
+ process.exit(0);
26
+ }
27
+ process.on("disconnect", stop);
28
+ process.on("SIGTERM", stop);
29
+ process.on("SIGINT", stop);
30
+ process.on("message", (message) => {
31
+ if (message.stop) return void stop();
32
+ if (child || !message.spec) return;
33
+ const { file, args, cwd, env } = message.spec;
34
+ child = spawn(file, args, {
35
+ cwd,
36
+ env,
37
+ windowsHide: true,
38
+ shell: false,
39
+ detached: process.platform !== "win32",
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ });
42
+ child.stdout.pipe(process.stdout);
43
+ child.stderr.pipe(process.stderr);
44
+ child.on("error", (error) => {
45
+ process.stderr.write(error.message);
46
+ process.exit(1);
47
+ });
48
+ child.on("exit", (code) => {
49
+ if (!stopping) process.exit(code ?? 1);
50
+ });
51
+ process.send?.({ started: true, pid: child.pid });
52
+ });
@@ -0,0 +1,457 @@
1
+ import { spawn, fork } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ readFile,
5
+ writeFile,
6
+ mkdir,
7
+ readdir,
8
+ access,
9
+ realpath,
10
+ } from "node:fs/promises";
11
+ import { fileURLToPath } from "node:url";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import net from "node:net";
15
+ const windows = process.platform === "win32";
16
+ const binaries = {
17
+ node: process.execPath,
18
+ git: process.env.GIT_BIN || "git",
19
+ python: process.env.PYTHON_BIN || (windows ? "python" : "python3"),
20
+ php: process.env.PHP_BIN || "php",
21
+ phpcgi: process.env.PHP_CGI_BIN || "php-cgi",
22
+ caddy: process.env.CADDY_BIN || "caddy",
23
+ go: process.env.GO_BIN || "go",
24
+ cloudflared: process.env.CLOUDFLARED_BIN || "cloudflared",
25
+ };
26
+ export function safeEnvironment(extra = {}) {
27
+ const env = {};
28
+ for (const key of [
29
+ "PATH",
30
+ "Path",
31
+ "SystemRoot",
32
+ "WINDIR",
33
+ "COMSPEC",
34
+ "PATHEXT",
35
+ "TEMP",
36
+ "TMP",
37
+ "HOME",
38
+ "USERPROFILE",
39
+ "APPDATA",
40
+ "LOCALAPPDATA",
41
+ "LANG",
42
+ "SSL_CERT_FILE",
43
+ "SSL_CERT_DIR",
44
+ ])
45
+ if (process.env[key]) env[key] = process.env[key];
46
+ return { ...env, GIT_TERMINAL_PROMPT: "0", PYTHONUNBUFFERED: "1", ...extra };
47
+ }
48
+ export async function nativeCommand(name, args, options = {}) {
49
+ const file = binaries[name];
50
+ if (!file) throw Error("Native command is not allowed");
51
+ return run(file, args, options);
52
+ }
53
+ export function run(
54
+ file,
55
+ args,
56
+ { cwd, env = safeEnvironment(), log = () => {}, timeout = 600000 } = {},
57
+ ) {
58
+ return new Promise((resolve, reject) => {
59
+ const worker = fork(
60
+ fileURLToPath(new URL("./native-worker.mjs", import.meta.url)),
61
+ [],
62
+ { env: safeEnvironment(), silent: true, windowsHide: true },
63
+ );
64
+ let output = "",
65
+ settled = false;
66
+ const timer = setTimeout(() => {
67
+ settled = true;
68
+ worker.send({ stop: true });
69
+ reject(Error("Native command timed out"));
70
+ }, timeout);
71
+ const collect = (chunk) => {
72
+ output = (output + chunk.toString()).slice(-100000);
73
+ log(chunk.toString());
74
+ };
75
+ worker.stdout.on("data", collect);
76
+ worker.stderr.on("data", collect);
77
+ worker.on("error", (error) => {
78
+ clearTimeout(timer);
79
+ if (!settled) reject(error);
80
+ });
81
+ worker.on("exit", (code) => {
82
+ clearTimeout(timer);
83
+ if (!settled) {
84
+ code === 0
85
+ ? resolve(output)
86
+ : reject(
87
+ Error(`${path.basename(file)} failed: ${output.slice(-3000)}`),
88
+ );
89
+ }
90
+ });
91
+ worker.send({ spec: { file, args, cwd, env } });
92
+ });
93
+ }
94
+ export class NativeSupervisor {
95
+ constructor(log, onFailure) {
96
+ this.children = new Map();
97
+ this.log = log;
98
+ this.onFailure = onFailure;
99
+ }
100
+ async start(key, spec, attempt = 0) {
101
+ await this.stop(key);
102
+ const worker = fork(
103
+ fileURLToPath(new URL("./native-worker.mjs", import.meta.url)),
104
+ [],
105
+ { env: safeEnvironment(), silent: true, windowsHide: true },
106
+ );
107
+ const record = { worker, spec, attempt, stopping: false, started: false };
108
+ this.children.set(key, record);
109
+ worker.stdout.on("data", (data) => this.log(key, data.toString()));
110
+ worker.stderr.on("data", (data) => this.log(key, data.toString()));
111
+ worker.on("message", (m) => {
112
+ if (m.started) record.started = true;
113
+ });
114
+ worker.on("error", (error) => this.log(key, error.message));
115
+ worker.on("exit", () => {
116
+ if (record.stopping) return;
117
+ record.started = false;
118
+ if (attempt < 3) {
119
+ this.log(key, `Process exited; restart attempt ${attempt + 1}/3`);
120
+ record.timer = setTimeout(
121
+ () =>
122
+ this.start(key, spec, attempt + 1).catch((error) =>
123
+ this.onFailure(key, error),
124
+ ),
125
+ 2000,
126
+ );
127
+ } else {
128
+ this.children.delete(key);
129
+ this.onFailure(
130
+ key,
131
+ Error("Process stopped after three restart attempts"),
132
+ );
133
+ }
134
+ });
135
+ worker.send({ spec });
136
+ }
137
+ active(key) {
138
+ return !!this.children.get(key)?.started;
139
+ }
140
+ async stop(key) {
141
+ const record = this.children.get(key);
142
+ if (!record) return;
143
+ record.stopping = true;
144
+ clearTimeout(record.timer);
145
+ this.children.delete(key);
146
+ if (record.worker.exitCode !== null) return;
147
+ await new Promise((resolve) => {
148
+ record.worker.once("exit", resolve);
149
+ if (record.worker.connected) record.worker.send({ stop: true });
150
+ else resolve();
151
+ });
152
+ }
153
+ async stopProject(id) {
154
+ for (const key of [...this.children.keys()])
155
+ if (key.startsWith(`${id}:`)) await this.stop(key);
156
+ }
157
+ async shutdown() {
158
+ await Promise.all([...this.children.keys()].map((key) => this.stop(key)));
159
+ }
160
+ }
161
+ async function exists(file) {
162
+ try {
163
+ await access(file);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+ export function npmCommand(args) {
170
+ const candidates = [
171
+ process.env.NPM_CLI_PATH,
172
+ process.env.npm_execpath,
173
+ path.join(
174
+ path.dirname(process.execPath),
175
+ "node_modules/npm/bin/npm-cli.js",
176
+ ),
177
+ "/usr/share/nodejs/npm/bin/npm-cli.js",
178
+ "/usr/local/lib/node_modules/npm/bin/npm-cli.js",
179
+ "/usr/lib/node_modules/npm/bin/npm-cli.js",
180
+ ].filter(Boolean);
181
+ const cli = candidates.find((file) => existsSync(file));
182
+ if (!cli)
183
+ throw Error(
184
+ "Cannot locate npm-cli.js. Set NPM_CLI_PATH to the installed npm CLI file.",
185
+ );
186
+ return {
187
+ file: process.execPath,
188
+ args: [cli, ...args],
189
+ };
190
+ }
191
+ export async function prepareNative(p, source, log = () => {}) {
192
+ const config = p.runtime_config || {};
193
+ const env = safeEnvironment({
194
+ PORT: String(p.port),
195
+ HOST: "127.0.0.1",
196
+ HOSTNAME: "127.0.0.1",
197
+ NODE_ENV: "production",
198
+ });
199
+ const execute = (file, args) => run(file, args, { cwd: source, env, log });
200
+ const npm = async (args) => {
201
+ const spec = npmCommand(args);
202
+ return execute(spec.file, spec.args);
203
+ };
204
+ const type = p.type || "nodejs";
205
+ if (type === "nodejs" || type === "static") {
206
+ if (await exists(path.join(source, "package.json"))) {
207
+ const manifest = JSON.parse(
208
+ await readFile(path.join(source, "package.json"), "utf8"),
209
+ );
210
+ if (type === "nodejs" && !manifest.scripts?.start)
211
+ throw Error("Node.js projects require a package.json start script");
212
+ await npm([
213
+ (await exists(path.join(source, "package-lock.json")))
214
+ ? "ci"
215
+ : "install",
216
+ "--include=dev",
217
+ "--no-audit",
218
+ "--no-fund",
219
+ ]);
220
+ if (manifest.scripts?.build) await npm(["run", "build"]);
221
+ } else if (type === "nodejs")
222
+ throw Error("package.json is missing at the source root");
223
+ if (type === "nodejs")
224
+ return [
225
+ { ...npmCommand(["run", "start"]), cwd: source, env, key: "app" },
226
+ ];
227
+ const root = await containedDirectory(source, config.public_dir || ".");
228
+ if (!(await exists(path.join(root, "index.html"))))
229
+ throw Error("Static public directory must contain index.html");
230
+ return [
231
+ {
232
+ key: "app",
233
+ file: process.execPath,
234
+ args: [
235
+ fileURLToPath(new URL("./static-server.mjs", import.meta.url)),
236
+ root,
237
+ ],
238
+ cwd: source,
239
+ env,
240
+ },
241
+ ];
242
+ }
243
+ if (type === "python") {
244
+ const venv = path.join(p.folder_path, "venv");
245
+ await execute(binaries.python, ["-m", "venv", venv]);
246
+ const python = path.join(
247
+ venv,
248
+ windows ? "Scripts/python.exe" : "bin/python",
249
+ );
250
+ if (await exists(path.join(source, "requirements.txt")))
251
+ await execute(python, ["-m", "pip", "install", "-r", "requirements.txt"]);
252
+ const wsgi = config.python_server === "wsgi";
253
+ await execute(python, [
254
+ "-m",
255
+ "pip",
256
+ "install",
257
+ wsgi ? "waitress" : "uvicorn",
258
+ ]);
259
+ return [
260
+ {
261
+ key: "app",
262
+ file: python,
263
+ args: wsgi
264
+ ? [
265
+ "-m",
266
+ "waitress",
267
+ `--listen=127.0.0.1:${p.port}`,
268
+ config.entrypoint || "app:app",
269
+ ]
270
+ : [
271
+ "-m",
272
+ "uvicorn",
273
+ config.entrypoint || "app:app",
274
+ "--host",
275
+ "127.0.0.1",
276
+ "--port",
277
+ String(p.port),
278
+ ],
279
+ cwd: source,
280
+ env,
281
+ },
282
+ ];
283
+ }
284
+ if (type === "go") {
285
+ const target = path.join(
286
+ p.folder_path,
287
+ windows ? "application.exe" : "application",
288
+ );
289
+ await execute(binaries.go, ["build", "-o", target, "."]);
290
+ return [{ key: "app", file: target, args: [], cwd: source, env }];
291
+ }
292
+ if (type === "php") {
293
+ if (await exists(path.join(source, "composer.json"))) {
294
+ if (!process.env.COMPOSER_PHAR)
295
+ throw Error("Set COMPOSER_PHAR to composer.phar for Composer projects");
296
+ await execute(binaries.php, [
297
+ process.env.COMPOSER_PHAR,
298
+ "install",
299
+ "--no-dev",
300
+ "--no-interaction",
301
+ "--prefer-dist",
302
+ ]);
303
+ }
304
+ const root = await containedDirectory(source, config.public_dir || ".");
305
+ const caddy = path.join(p.folder_path, "Caddyfile");
306
+ await writeFile(
307
+ caddy,
308
+ `{\n admin off\n auto_https off\n}\nhttp://127.0.0.1:${p.port} {\n root * ${JSON.stringify(root.replaceAll("\\", "/"))}\n @private path /.git/* /.env /composer.json /composer.lock\n respond @private 404\n php_fastcgi 127.0.0.1:${p.php_port}\n file_server {\n hide .* composer.json composer.lock\n }\n}\n`,
309
+ );
310
+ return [
311
+ {
312
+ key: "php",
313
+ file: binaries.phpcgi,
314
+ args: ["-b", `127.0.0.1:${p.php_port}`],
315
+ cwd: source,
316
+ env: { ...env, PHP_FCGI_MAX_REQUESTS: "0" },
317
+ },
318
+ {
319
+ key: "app",
320
+ file: binaries.caddy,
321
+ args: ["run", "--config", caddy, "--adapter", "caddyfile"],
322
+ cwd: source,
323
+ env,
324
+ },
325
+ ];
326
+ }
327
+ if (type === "custom") {
328
+ const allowed = JSON.parse(process.env.NATIVE_EXECUTABLES || "{}");
329
+ const file = allowed[config.executable];
330
+ if (typeof file !== "string" || !path.isAbsolute(file))
331
+ throw Error(
332
+ "Choose an executable alias configured by the host owner in NATIVE_EXECUTABLES",
333
+ );
334
+ return [
335
+ {
336
+ key: "app",
337
+ file,
338
+ args: (config.args || []).map((arg) =>
339
+ arg.replaceAll("{PORT}", String(p.port)),
340
+ ),
341
+ cwd: source,
342
+ env,
343
+ },
344
+ ];
345
+ }
346
+ throw Error("Unsupported native runtime");
347
+ }
348
+ export async function containedDirectory(source, relative) {
349
+ const base = await realpath(source),
350
+ target = await realpath(path.resolve(source, relative));
351
+ const rel = path.relative(base, target);
352
+ if (rel.startsWith("..") || path.isAbsolute(rel))
353
+ throw Error("Public directory escapes the project root");
354
+ return target;
355
+ }
356
+ export async function ready(port, supervisor, key) {
357
+ for (let i = 0; i < 60; i++) {
358
+ const listening = await new Promise((resolve) => {
359
+ const socket = net.connect(port, "127.0.0.1");
360
+ socket.setTimeout(500);
361
+ socket.once("connect", () => {
362
+ socket.destroy();
363
+ resolve(true);
364
+ });
365
+ socket.once("error", () => resolve(false));
366
+ socket.once("timeout", () => {
367
+ socket.destroy();
368
+ resolve(false);
369
+ });
370
+ });
371
+ if (listening && supervisor.active(key)) return;
372
+ await new Promise((resolve) => setTimeout(resolve, 500));
373
+ }
374
+ throw Error(
375
+ `Application did not start on port ${port}. Check the logs and required host runtimes.`,
376
+ );
377
+ }
378
+ export async function provisionNativeTunnel(runtime, p) {
379
+ if (process.env.NATIVE_TUNNELS === "false") {
380
+ // Retain existing tunnel metadata so a later delete can still clean up DNS.
381
+ p.tunnel ||= null;
382
+ runtime.log(p, "Local-only deployment: Cloudflare publishing is disabled.");
383
+ return;
384
+ }
385
+ const folder = path.join(runtime.root, "cloudflare");
386
+ await mkdir(folder, { recursive: true });
387
+ const credentials = path.join(folder, `${p.id}.json`),
388
+ config = path.join(folder, `${p.id}.yml`);
389
+ const cert =
390
+ process.env.CLOUDFLARE_CERT ||
391
+ path.join(os.homedir(), ".cloudflared", "cert.pem");
392
+ let credential;
393
+ if (await exists(credentials))
394
+ credential = JSON.parse(await readFile(credentials, "utf8"));
395
+ else {
396
+ await nativeCommand("cloudflared", [
397
+ "tunnel",
398
+ "--origincert",
399
+ cert,
400
+ "--credentials-file",
401
+ credentials,
402
+ "create",
403
+ `naviyra-${p.id}`,
404
+ ]);
405
+ credential = JSON.parse(await readFile(credentials, "utf8"));
406
+ }
407
+ p.tunnel = {
408
+ tunnel_id: credential.TunnelID,
409
+ tunnel_name: `naviyra-${p.id}`,
410
+ credentials_path: credentials,
411
+ config_path: config,
412
+ };
413
+ await runtime.save();
414
+ await writeFile(
415
+ config,
416
+ `tunnel: ${credential.TunnelID}\ncredentials-file: ${JSON.stringify(credentials)}\ningress:\n - hostname: ${p.subdomain}\n service: http://127.0.0.1:${p.port}\n - service: http_status:404\n`,
417
+ { mode: 0o600 },
418
+ );
419
+ await nativeCommand("cloudflared", [
420
+ "tunnel",
421
+ "--origincert",
422
+ cert,
423
+ "route",
424
+ "dns",
425
+ credential.TunnelID,
426
+ p.subdomain,
427
+ ]);
428
+ const spec = {
429
+ key: "tunnel",
430
+ file: binaries.cloudflared,
431
+ args: ["tunnel", "--config", config, "run", credential.TunnelID],
432
+ cwd: runtime.root,
433
+ env: safeEnvironment(),
434
+ };
435
+ p.native_specs.push(spec);
436
+ await runtime.supervisor.start(`${p.id}:tunnel`, spec);
437
+ }
438
+ export async function deleteNativeTunnel(p) {
439
+ if (!p.tunnel) return;
440
+ const { deleteTunnelCname } = await import("./cloudflare.mjs");
441
+ await deleteTunnelCname(p.subdomain, p.tunnel.tunnel_id);
442
+ const prefix = [
443
+ "tunnel",
444
+ "--origincert",
445
+ process.env.CLOUDFLARE_CERT ||
446
+ path.join(os.homedir(), ".cloudflared", "cert.pem"),
447
+ ];
448
+ const tunnels = JSON.parse(
449
+ await nativeCommand("cloudflared", [...prefix, "list", "--output", "json"]),
450
+ );
451
+ if (tunnels.some((t) => t.id === p.tunnel.tunnel_id))
452
+ await nativeCommand("cloudflared", [
453
+ ...prefix,
454
+ "delete",
455
+ p.tunnel.tunnel_id,
456
+ ]);
457
+ }