@runeya/apps-cli 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/Dockerfile ADDED
@@ -0,0 +1,54 @@
1
+ ARG BASE_TAG=latest
2
+
3
+ FROM alpine:3.23 AS base
4
+ RUN apk --no-cache add nodejs yarn git curl bash npm openssh
5
+ ENV NODE_ENV production
6
+ LABEL org.opencontainers.image.source https://github.com/runeya/runeya
7
+ RUN yarn set version 4.0.1 && \
8
+ yarn config set httpTimeout 500000 && \
9
+ yarn config set compressionLevel mixed && \
10
+ yarn config set enableGlobalCache false && \
11
+ yarn config set nmHoistingLimits none && \
12
+ yarn config set nodeLinker node-modules && \
13
+ rm -f package.json
14
+
15
+ FROM base AS build
16
+ RUN apk --no-cache add gcc g++ make python3 vips-dev musl
17
+
18
+ FROM build AS prune
19
+ WORKDIR /app
20
+ COPY . .
21
+ RUN yarn dlx turbo prune @runeya/apps-cli --docker
22
+
23
+ FROM build AS builder
24
+ WORKDIR /app
25
+ COPY .gitignore .gitignore
26
+ COPY tsconfig.json tsconfig.json
27
+ COPY packages/tsconfig/ packages/tsconfig/
28
+ COPY --from=prune /app/out/json/ .
29
+ COPY --from=prune /app/.turbo/ .
30
+ COPY --from=prune /app/out/yarn.lock ./yarn.lock
31
+ COPY --from=prune /app/.yarn/cach[e] .yarn/cache
32
+ RUN yarn install
33
+ COPY --from=prune /app/out/full/ .
34
+ ARG TURBO_TEAM=""
35
+ ARG TURBO_TOKEN=""
36
+ RUN yarn turbo run build --filter=@runeya/apps-cli
37
+ RUN node -e "const p=require('./package.json'); delete p.scripts.postinstall; delete p.scripts.prepare; require('fs').writeFileSync('package.json', JSON.stringify(p, null, 2))" && \
38
+ yarn workspaces focus -A --production
39
+ RUN rm -rf .yarn/cache
40
+ RUN rm -rf cache
41
+
42
+ FROM base
43
+ WORKDIR /app
44
+ COPY --from=builder /app .
45
+ RUN chmod +x /app/apps/cli/dist/index.js && \
46
+ ln -s /app/apps/cli/dist/index.js /usr/local/bin/runeya
47
+ RUN mkdir /src
48
+ WORKDIR /src
49
+ ENV PATH="/home/runeya/.local/bin:$PATH"
50
+ ARG BUILD_DATE
51
+ ENV BUILD_DATE=$BUILD_DATE
52
+ ## Security: Running as root is intentional — Runeya needs to install
53
+ ## packages, manage processes, and write to arbitrary paths at runtime.
54
+ CMD ["runeya"] # nosemgrep: dockerfile.security.missing-user.missing-user
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # @runeya/apps-cli
2
+
3
+ **CLI tout-en-un** pour Runeya qui rassemble le serveur, l'agent et le frontend web dans un seul package (~2.3MB) prêt à l'emploi.
4
+
5
+ ## Architecture
6
+
7
+ Le CLI orchestre les trois composants principaux de Runeya:
8
+
9
+ 1. **Serveur** (`@runeya/apps-server`) — API tRPC, orchestration, persistence JSON
10
+ 2. **Agent local** — Spawné automatiquement par le serveur pour exécuter les processus
11
+ 3. **Frontend web** (`@runeya/apps-web`) — Interface Vue 3, servie en fichiers statiques
12
+
13
+ ## Installation en développement
14
+
15
+ ```bash
16
+ # Depuis la racine du monorepo
17
+ yarn install
18
+ yarn build
19
+ ```
20
+
21
+ ## Utilisation
22
+
23
+ ### Lancer Runeya (tout-en-un)
24
+
25
+ ```bash
26
+ # Depuis la racine du monorepo
27
+ node apps/cli/dist/index.js
28
+
29
+ # Ou via yarn
30
+ yarn workspace @runeya/apps-cli start
31
+ ```
32
+
33
+ Le CLI démarrera:
34
+ - Le serveur sur `http://127.0.0.1:4000` (API + frontend)
35
+ - L'agent local sur `http://127.0.0.1:4001`
36
+
37
+ Ouvrez votre navigateur à `http://127.0.0.1:4000` pour accéder à l'interface.
38
+
39
+ ### Variables d'environnement
40
+
41
+ ```bash
42
+ RUNEYA_HTTP_PORT=3000 HOST=127.0.0.1 node apps/cli/dist/index.js
43
+ ```
44
+
45
+ - `RUNEYA_HTTP_PORT` — Port du serveur (défaut: `4000`) — prend la priorité sur `PORT`
46
+ - `PORT` — Port du serveur (rétrocompat, défaut: `4000`) — utilisez `RUNEYA_HTTP_PORT` de préférence
47
+ - `HOST` — Host du serveur (défaut: `127.0.0.1`)
48
+ - `NODE_ENV` — Mode (`development` ou `production`)
49
+
50
+ Les variables pour l'agent sont héritées automatiquement (voir `apps/server/src/services/agent-spawner.ts`).
51
+
52
+ ## Distribution
53
+
54
+ Le CLI nécessite Node.js 22+ installé sur la machine cible.
55
+
56
+ ### Option 1: Distribuer le dossier `dist/` (recommandé)
57
+
58
+ Le plus simple est de distribuer tout le dossier `apps/cli/dist/` qui contient déjà tout:
59
+
60
+ ```bash
61
+ # Après yarn build, créer une archive
62
+ cd apps/cli
63
+ tar -czf runeya-cli.tar.gz dist/
64
+
65
+ # Sur la machine cible
66
+ tar -xzf runeya-cli.tar.gz
67
+ node dist/index.js
68
+ ```
69
+
70
+ **Structure distribuée (~2.3MB):**
71
+ ```
72
+ dist/
73
+ ├── index.js (point d'entrée)
74
+ ├── dist-*.js (serveur bundlé)
75
+ ├── agent/ (agent embarqué)
76
+ └── web/ (frontend embarqué)
77
+ ```
78
+
79
+ ### Option 2: Créer un wrapper shell
80
+
81
+ Créer un script `runeya` à la racine pour faciliter l'utilisation:
82
+
83
+ ```bash
84
+ #!/usr/bin/env bash
85
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
86
+ exec node "$DIR/dist/index.js" "$@"
87
+ ```
88
+
89
+ Rendre exécutable: `chmod +x runeya`
90
+
91
+ Utiliser: `./runeya` ou `/usr/local/bin/runeya`
92
+
93
+ ### Note sur les binaires standalone
94
+
95
+ Les outils comme `pkg` ne supportent pas encore Node.js 22+. Pour créer un vrai binaire standalone, il faudrait:
96
+ - Downgrade à Node.js 20 (dernière version supportée par pkg)
97
+ - Ou attendre que `pkg` / `@yao-pkg/pkg` supporte Node 22+
98
+ - Ou utiliser des solutions alternatives comme Deno compile ou Bun build
99
+
100
+ ## Architecture interne
101
+
102
+ Le CLI:
103
+ 1. Importe `createLocalServer()` de `@runeya/apps-server`
104
+ 2. Configure Express pour servir les fichiers statiques de `@runeya/apps-web/dist`
105
+ 3. Le serveur spawn automatiquement l'agent local via `AgentSpawner`
106
+ 4. Gère le graceful shutdown (SIGTERM/SIGINT)
107
+
108
+ ### Ordre de démarrage
109
+
110
+ ```
111
+ CLI démarre
112
+
113
+ createLocalServer()
114
+ ├─> Initialise les stores (projects, services, agents, settings)
115
+ ├─> Lance les migrations de schéma
116
+ ├─> Configure tRPC (HTTP + WebSocket)
117
+ └─> Spawn l'agent local (via AgentSpawner)
118
+
119
+ Agent démarre sur port 4001
120
+
121
+ Agent envoie son passphrase sur stdout
122
+
123
+ Serveur récupère le passphrase et enregistre l'agent
124
+
125
+ Serveur prêt sur port 4000
126
+
127
+ CLI configure Express pour servir le frontend web
128
+
129
+ Tout est prêt! 🚀
130
+ ```
131
+
132
+ ## Notes
133
+
134
+ - Le CLI est conçu pour l'exécution locale (mode `local-simple`)
135
+ - Le frontend est servi depuis `apps/web/dist/` — assurez-vous de builder le web avant le CLI
136
+ - Hot reload en dev: le serveur watch `apps/agent/dist/index.js` et restart l'agent automatiquement
137
+
138
+ ## Voir aussi
139
+
140
+ - [apps/server/README.md](../server/README.md) — Documentation du serveur
141
+ - [apps/agent/README.md](../agent/README.md) — Documentation de l'agent
142
+ - [apps/web/README.md](../web/README.md) — Documentation du frontend
143
+ - [CLAUDE.md](/CLAUDE.md) — Guide complet du projet
package/dist/index.js ADDED
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { fileURLToPath, URL as NodeURL } from "url";
5
+ import { dirname, join, resolve } from "path";
6
+ import { readFileSync } from "fs";
7
+ import express from "express";
8
+ import dotenv from "dotenv";
9
+ import open from "open";
10
+
11
+ // src/port-resolution.ts
12
+ function resolvePort() {
13
+ const raw = process.env.RUNEYA_HTTP_PORT ?? process.env.PORT;
14
+ if (raw === void 0) return 4e3;
15
+ const port = Number(raw);
16
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
17
+ throw new Error(
18
+ `Invalid port: ${JSON.stringify(raw)}. Must be an integer between 1 and 65535.`
19
+ );
20
+ }
21
+ return port;
22
+ }
23
+
24
+ // src/index.ts
25
+ var __dirname = dirname(fileURLToPath(import.meta.url));
26
+ var { version } = JSON.parse(
27
+ readFileSync(new NodeURL("../package.json", import.meta.url), "utf8")
28
+ );
29
+ function printStartupTable(port, appUrl) {
30
+ const rows = [
31
+ ["Version", version],
32
+ ["Port", String(port)],
33
+ ["Url", appUrl]
34
+ ];
35
+ const col0 = Math.max(...rows.map(([k]) => k.length));
36
+ const col1 = Math.max(...rows.map(([, v]) => v.length));
37
+ const pad = (s, n) => s.padEnd(n);
38
+ const hr = (l, m, r, f) => l + f.repeat(col0 + 2) + m + f.repeat(col1 + 2) + r;
39
+ console.log("");
40
+ console.log(hr("\u250C", "\u252C", "\u2510", "\u2500"));
41
+ for (const [key, value] of rows) {
42
+ console.log(`\u2502 ${pad(key, col0)} \u2502 ${pad(value, col1)} \u2502`);
43
+ }
44
+ console.log(hr("\u2514", "\u2534", "\u2518", "\u2500"));
45
+ console.log("");
46
+ }
47
+ var args = process.argv.slice(2);
48
+ var flagsWithValues = /* @__PURE__ */ new Set(["--host", "--port", "-e", "--environment", "-s", "--service"]);
49
+ var targetDir = args.find((a, i) => !a.startsWith("-") && !flagsWithValues.has(args[i - 1] ?? ""));
50
+ if (targetDir) {
51
+ const resolved = resolve(targetDir);
52
+ process.chdir(resolved);
53
+ console.log(`[cli] Working directory: ${resolved}`);
54
+ }
55
+ dotenv.config();
56
+ if (args.includes("--help") || args.includes("-h")) {
57
+ console.log(`Usage: runeya [path-to-your-stack] [options]
58
+
59
+ Options:
60
+ --version Show version number
61
+ --pe, --pull-env Print env vars for a service to stdout (requires -e and -s)
62
+ -e, --environment <n> Environment name or id
63
+ -s, --service <name> Service name or id
64
+ --lan Expose server on the local network (shortcut for --host 0.0.0.0)
65
+ --host <address> Host to bind to (default: 127.0.0.1)
66
+ --no-open Do not open browser on startup
67
+ --agent Run as agent only (no server, no UI) \u2014 for remote machines
68
+ -h, --help Show this help message
69
+ `);
70
+ process.exit(0);
71
+ }
72
+ if (args.includes("--version")) {
73
+ console.log(version);
74
+ process.exit(0);
75
+ }
76
+ var noOpen = args.includes("--no-open") || !!process.env["VITEST"] || !!process.env["CI"] || process.env["NODE_ENV"] === "test";
77
+ var isPullEnv = args.includes("--pull-env") || args.includes("--pe");
78
+ if (isPullEnv) {
79
+ const toStderr = (...a) => process.stderr.write(a.join(" ") + "\n");
80
+ console.log = toStderr;
81
+ console.info = toStderr;
82
+ }
83
+ function getFlagValue(flags) {
84
+ for (const flag of flags) {
85
+ const idx = args.indexOf(flag);
86
+ if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
87
+ }
88
+ return void 0;
89
+ }
90
+ var serviceArg = getFlagValue(["-s", "--service"]);
91
+ var environmentArg = getFlagValue(["-e", "--environment"]);
92
+ if (process.env.RUNEYA_HTTP_PORT && !process.env.PORT) {
93
+ process.env.PORT = process.env.RUNEYA_HTTP_PORT;
94
+ }
95
+ if (process.env.AGENT_PORT && !process.env.RUNEYA_AGENT_PORT) {
96
+ process.env.RUNEYA_AGENT_PORT = process.env.AGENT_PORT;
97
+ }
98
+ var isLan = args.includes("--lan");
99
+ var hostFlag = getFlagValue(["--host"]);
100
+ var isLanProxy = isLan || process.env.RUNEYA_LAN_PROXY === "true" || process.env.RUNEYA_LAN_PROXY === "1";
101
+ if (hostFlag) {
102
+ process.env.HOST = hostFlag;
103
+ } else if (isLanProxy) {
104
+ process.env.HOST = "0.0.0.0";
105
+ }
106
+ if (isLanProxy) {
107
+ process.env.RUNEYA_LAN_PROXY = "true";
108
+ }
109
+ process.env.AGENT_BINARY_PATH = join(__dirname, "agent/index.js");
110
+ var isAgentOnly = args.includes("--agent");
111
+ if (isAgentOnly) {
112
+ const agentPort = getFlagValue(["--port"]);
113
+ const agentHost = getFlagValue(["--host"]);
114
+ if (agentPort) process.env.RUNEYA_AGENT_PORT = agentPort;
115
+ if (agentHost) {
116
+ process.env.RUNEYA_AGENT_HOST = agentHost;
117
+ } else if (isLanProxy) {
118
+ process.env.RUNEYA_AGENT_HOST = "0.0.0.0";
119
+ }
120
+ }
121
+ function listenWithRetry(server, port, host, wss, maxRetries = 20, delay = 500) {
122
+ return new Promise((resolve2, reject) => {
123
+ let attempts = 0;
124
+ const wssErrorHandler = () => {
125
+ };
126
+ wss?.on("error", wssErrorHandler);
127
+ const tryListen = () => {
128
+ const onError = (err) => {
129
+ server.removeAllListeners("listening");
130
+ if (err.code === "EADDRINUSE" && attempts < maxRetries) {
131
+ attempts++;
132
+ console.log(`[cli] Port ${port} in use, retrying in ${delay}ms (${attempts}/${maxRetries})...`);
133
+ setTimeout(tryListen, delay);
134
+ } else {
135
+ wss?.removeListener("error", wssErrorHandler);
136
+ reject(err);
137
+ }
138
+ };
139
+ server.once("error", onError);
140
+ server.listen(port, host, () => {
141
+ server.removeListener("error", onError);
142
+ wss?.removeListener("error", wssErrorHandler);
143
+ resolve2();
144
+ });
145
+ };
146
+ tryListen();
147
+ });
148
+ }
149
+ async function main() {
150
+ const { createLocalServer, pullEnv, PullEnvError } = await import("./dist-MFM5N25P.js");
151
+ if (isPullEnv) {
152
+ if (!serviceArg) {
153
+ console.error("Error: --service (-s) is required with --pull-env");
154
+ process.exit(1);
155
+ }
156
+ if (!environmentArg) {
157
+ console.error("Error: --environment (-e) is required with --pull-env");
158
+ process.exit(1);
159
+ }
160
+ try {
161
+ const output = await pullEnv({ service: serviceArg, environment: environmentArg });
162
+ if (output) process.stdout.write(output + "\n");
163
+ process.exit(0);
164
+ } catch (err) {
165
+ if (err instanceof PullEnvError) {
166
+ console.error(`Error: ${err.message}`);
167
+ process.exit(err.exitCode);
168
+ }
169
+ throw err;
170
+ }
171
+ }
172
+ if (isAgentOnly) {
173
+ const { spawn } = await import("child_process");
174
+ const agentBin = join(__dirname, "agent/index.js");
175
+ const child = spawn(process.execPath, [agentBin], { stdio: "inherit" });
176
+ child.on("exit", (code) => process.exit(code ?? 0));
177
+ process.on("SIGINT", () => {
178
+ });
179
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
180
+ return;
181
+ }
182
+ console.log("[cli] Starting Runeya...");
183
+ const { app, server, wss, shutdown } = await createLocalServer();
184
+ const webDistPath = join(__dirname, "web");
185
+ app.use(express.static(webDistPath));
186
+ app.use((_req, res) => {
187
+ res.sendFile(join(webDistPath, "index.html"));
188
+ });
189
+ const port = resolvePort();
190
+ const host = process.env.HOST ?? "127.0.0.1";
191
+ await listenWithRetry(server, port, host, wss);
192
+ const appUrl = `http://${host}:${port}`;
193
+ if (!noOpen) {
194
+ await open(appUrl);
195
+ }
196
+ console.log("\n\u{1F680} Runeya is running!");
197
+ if (isLanProxy) {
198
+ console.log("\u{1F310} LAN mode: server and service ports exposed on local network");
199
+ }
200
+ printStartupTable(port, appUrl);
201
+ console.log("[cli] Press Ctrl+C to stop");
202
+ let shuttingDown = false;
203
+ const gracefulShutdown = async (signal) => {
204
+ if (shuttingDown) {
205
+ console.log(`[cli] Forced exit (second ${signal})`);
206
+ process.exit(1);
207
+ }
208
+ shuttingDown = true;
209
+ console.log(`
210
+ [cli] Received ${signal}, shutting down gracefully...`);
211
+ if (process.env.NODE_ENV === "development") {
212
+ process.exit(0);
213
+ }
214
+ await shutdown();
215
+ console.log("[cli] Shutdown complete");
216
+ process.exit(0);
217
+ };
218
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
219
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
220
+ }
221
+ main().catch((err) => {
222
+ console.error("[cli] Fatal error:", err);
223
+ process.exit(1);
224
+ });
225
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@runeya/apps-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "runeya": "./dist/index.js"
7
+ },
8
+ "repository": "https://github.com/runeya/runeya",
9
+ "scripts": {
10
+ "build": "tsup && node scripts/post-build.mjs",
11
+ "dev": "tsup --watch --onSuccess 'node dist/index.js'",
12
+ "start": "node dist/index.js",
13
+ "test": "vitest run --passWithNoTests"
14
+ },
15
+ "dependencies": {
16
+ "@runeya/apps-agent": "0.1.0",
17
+ "@runeya/apps-server": "0.1.0",
18
+ "@runeya/apps-web": "0.1.0",
19
+ "dotenv": "^17.3.1",
20
+ "express": "^5.1.0",
21
+ "open": "^11.0.0",
22
+ "ws": "^8.18.0"
23
+ },
24
+ "devDependencies": {
25
+ "@runeya/scripts-tsup-config": "0.1.0",
26
+ "@types/express": "^5.0.0",
27
+ "tsup": "^8.5.0",
28
+ "typescript": "^5.8.3",
29
+ "vitest": "^3.2.0"
30
+ }
31
+ }
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { cp, mkdir, writeFile } from 'node:fs/promises';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const cliRoot = join(__dirname, '..');
8
+ const monorepoRoot = join(cliRoot, '../..');
9
+
10
+ async function copyDist(src, dest) {
11
+ console.log(`Copying ${src} -> ${dest}`);
12
+ await mkdir(dirname(dest), { recursive: true });
13
+ await cp(src, dest, { recursive: true, force: true });
14
+ }
15
+
16
+ async function main() {
17
+ console.log('[post-build] Copying agent and web builds into CLI dist...');
18
+
19
+ const cliDist = join(cliRoot, 'dist');
20
+
21
+ // Copy agent build
22
+ await copyDist(
23
+ join(monorepoRoot, 'apps/agent/dist'),
24
+ join(cliDist, 'agent')
25
+ );
26
+
27
+ // Copy web build
28
+ await copyDist(
29
+ join(monorepoRoot, 'apps/web/dist'),
30
+ join(cliDist, 'web')
31
+ );
32
+
33
+ // Create shell wrapper for easy execution
34
+ const wrapperContent = `#!/usr/bin/env bash
35
+ # Runeya CLI launcher
36
+ DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
37
+ exec node "$DIR/index.js" "$@"
38
+ `;
39
+
40
+ const wrapperPath = join(cliDist, 'runeya');
41
+ await writeFile(wrapperPath, wrapperContent, { mode: 0o755 });
42
+ console.log('[post-build] Created shell wrapper: runeya');
43
+
44
+ console.log('[post-build] ✅ All builds copied successfully');
45
+ console.log(`[post-build] CLI dist structure:`);
46
+ console.log(` ${cliDist}/`);
47
+ console.log(` ├── index.js (CLI entry point)`);
48
+ console.log(` ├── runeya (shell wrapper)`);
49
+ console.log(` ├── dist-*.js (bundled server code)`);
50
+ console.log(` ├── agent/ (agent build)`);
51
+ console.log(` │ └── index.js`);
52
+ console.log(` └── web/ (web frontend build)`);
53
+ console.log(` └── index.html`);
54
+ console.log('');
55
+ console.log('[post-build] Usage: ./apps/cli/dist/runeya');
56
+ }
57
+
58
+ main().catch((err) => {
59
+ console.error('[post-build] Failed:', err);
60
+ process.exit(1);
61
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Tests for the CLI port resolution utility (port-resolution.ts).
3
+ *
4
+ * These tests are GREEN as soon as port-resolution.ts exists with the right
5
+ * implementation. They also serve as the contract that the CLI's index.ts
6
+ * must satisfy when it calls resolvePort() to determine the listen port.
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import { resolvePort } from '../port-resolution.js';
10
+
11
+ describe('resolvePort', () => {
12
+ const managedKeys = ['RUNEYA_HTTP_PORT', 'PORT'] as const;
13
+ const saved: Record<string, string | undefined> = {};
14
+
15
+ beforeEach(() => {
16
+ for (const k of managedKeys) {
17
+ saved[k] = process.env[k];
18
+ delete process.env[k];
19
+ }
20
+ });
21
+
22
+ afterEach(() => {
23
+ for (const k of managedKeys) {
24
+ if (saved[k] === undefined) delete process.env[k];
25
+ else process.env[k] = saved[k];
26
+ }
27
+ });
28
+
29
+ // --- defaults ---
30
+
31
+ it('returns 4000 when no port env vars are set', () => {
32
+ expect(resolvePort()).toBe(4000);
33
+ });
34
+
35
+ // --- PORT only (backwards compat) ---
36
+
37
+ it('uses PORT when only PORT is set', () => {
38
+ process.env.PORT = '3333';
39
+ expect(resolvePort()).toBe(3333);
40
+ });
41
+
42
+ // --- RUNEYA_HTTP_PORT ---
43
+
44
+ it('uses RUNEYA_HTTP_PORT when only RUNEYA_HTTP_PORT is set', () => {
45
+ process.env.RUNEYA_HTTP_PORT = '3333';
46
+ expect(resolvePort()).toBe(3333);
47
+ });
48
+
49
+ it('RUNEYA_HTTP_PORT wins over PORT when both are set', () => {
50
+ process.env.RUNEYA_HTTP_PORT = '3333';
51
+ process.env.PORT = '9999';
52
+ expect(resolvePort()).toBe(3333);
53
+ });
54
+
55
+ // --- edge: extreme valid ports ---
56
+
57
+ it('accepts the minimum valid port (1)', () => {
58
+ process.env.RUNEYA_HTTP_PORT = '1';
59
+ expect(resolvePort()).toBe(1);
60
+ });
61
+
62
+ it('accepts the maximum valid port (65535)', () => {
63
+ process.env.RUNEYA_HTTP_PORT = '65535';
64
+ expect(resolvePort()).toBe(65535);
65
+ });
66
+
67
+ // --- validation errors ---
68
+
69
+ it('throws for RUNEYA_HTTP_PORT=0 (below minimum)', () => {
70
+ process.env.RUNEYA_HTTP_PORT = '0';
71
+ expect(() => resolvePort()).toThrow();
72
+ });
73
+
74
+ it('throws for RUNEYA_HTTP_PORT=99999 (above maximum)', () => {
75
+ process.env.RUNEYA_HTTP_PORT = '99999';
76
+ expect(() => resolvePort()).toThrow();
77
+ });
78
+
79
+ it('throws for RUNEYA_HTTP_PORT=abc (non-numeric)', () => {
80
+ process.env.RUNEYA_HTTP_PORT = 'abc';
81
+ expect(() => resolvePort()).toThrow();
82
+ });
83
+
84
+ it('throws for PORT=abc (non-numeric, no RUNEYA_HTTP_PORT set)', () => {
85
+ process.env.PORT = 'abc';
86
+ expect(() => resolvePort()).toThrow();
87
+ });
88
+
89
+ it('throws for a decimal value like 80.5', () => {
90
+ process.env.RUNEYA_HTTP_PORT = '80.5';
91
+ expect(() => resolvePort()).toThrow();
92
+ });
93
+ });
package/src/index.ts ADDED
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ import type { Server } from 'node:http';
3
+ import type { WebSocketServer } from 'ws';
4
+ import type { Request, Response } from 'express';
5
+ import { fileURLToPath, URL as NodeURL } from 'node:url';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { readFileSync } from 'node:fs';
8
+ import express from 'express';
9
+ import dotenv from 'dotenv'
10
+ import open from 'open';
11
+ import { resolvePort } from './port-resolution.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ const { version } = JSON.parse(
16
+ readFileSync(new NodeURL('../package.json', import.meta.url), 'utf8'),
17
+ ) as { version: string };
18
+
19
+ function printStartupTable(port: number, appUrl: string): void {
20
+ const rows: [string, string][] = [
21
+ ['Version', version],
22
+ ['Port', String(port)],
23
+ ['Url', appUrl],
24
+ ];
25
+
26
+ const col0 = Math.max(...rows.map(([k]) => k.length));
27
+ const col1 = Math.max(...rows.map(([, v]) => v.length));
28
+
29
+ const pad = (s: string, n: number) => s.padEnd(n);
30
+ const hr = (l: string, m: string, r: string, f: string) =>
31
+ l + f.repeat(col0 + 2) + m + f.repeat(col1 + 2) + r;
32
+
33
+ console.log('');
34
+ console.log(hr('┌', '┬', '┐', '─'));
35
+ for (const [key, value] of rows) {
36
+ console.log(`│ ${pad(key, col0)} │ ${pad(value, col1)} │`);
37
+ }
38
+ console.log(hr('└', '┴', '┘', '─'));
39
+ console.log('');
40
+ }
41
+
42
+ // Parse CLI flags
43
+ const args = process.argv.slice(2);
44
+
45
+ // Resolve target directory (first positional arg, skipping flag values)
46
+ const flagsWithValues = new Set(['--host', '--port', '-e', '--environment', '-s', '--service']);
47
+ const targetDir = args.find((a, i) => !a.startsWith('-') && !flagsWithValues.has(args[i - 1] ?? ''));
48
+ if (targetDir) {
49
+ const resolved = resolve(targetDir);
50
+ process.chdir(resolved);
51
+ console.log(`[cli] Working directory: ${resolved}`);
52
+ }
53
+
54
+ dotenv.config();
55
+
56
+ if (args.includes('--help') || args.includes('-h')) {
57
+ console.log(`Usage: runeya [path-to-your-stack] [options]
58
+
59
+ Options:
60
+ --version Show version number
61
+ --pe, --pull-env Print env vars for a service to stdout (requires -e and -s)
62
+ -e, --environment <n> Environment name or id
63
+ -s, --service <name> Service name or id
64
+ --lan Expose server on the local network (shortcut for --host 0.0.0.0)
65
+ --host <address> Host to bind to (default: 127.0.0.1)
66
+ --no-open Do not open browser on startup
67
+ --agent Run as agent only (no server, no UI) — for remote machines
68
+ -h, --help Show this help message
69
+ `);
70
+ process.exit(0);
71
+ }
72
+
73
+ if (args.includes('--version')) {
74
+ console.log(version);
75
+ process.exit(0);
76
+ }
77
+
78
+ const noOpen =
79
+ args.includes('--no-open') ||
80
+ !!process.env['VITEST'] ||
81
+ !!process.env['CI'] ||
82
+ process.env['NODE_ENV'] === 'test';
83
+
84
+ // --pull-env flag — detect early so we can silence stdout before any log
85
+ const isPullEnv = args.includes('--pull-env') || args.includes('--pe');
86
+
87
+ // In pull-env mode, redirect console.log/info to stderr so only KEY=VALUE goes to stdout
88
+ if (isPullEnv) {
89
+ const toStderr = (...a: unknown[]) => process.stderr.write(a.join(' ') + '\n');
90
+ console.log = toStderr;
91
+ console.info = toStderr;
92
+ }
93
+
94
+ function getFlagValue(flags: string[]): string | undefined {
95
+ for (const flag of flags) {
96
+ const idx = args.indexOf(flag);
97
+ if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ const serviceArg = getFlagValue(['-s', '--service']);
103
+ const environmentArg = getFlagValue(['-e', '--environment']);
104
+
105
+ // Configure environment BEFORE importing server (env is parsed at module load)
106
+ // RUNEYA_HTTP_PORT takes priority over PORT
107
+ if (process.env.RUNEYA_HTTP_PORT && !process.env.PORT) {
108
+ process.env.PORT = process.env.RUNEYA_HTTP_PORT;
109
+ }
110
+ // AGENT_PORT is an alias for RUNEYA_AGENT_PORT (used by e2e test-server and legacy tooling)
111
+ if (process.env.AGENT_PORT && !process.env.RUNEYA_AGENT_PORT) {
112
+ process.env.RUNEYA_AGENT_PORT = process.env.AGENT_PORT;
113
+ }
114
+ // --lan shortcut sets host to 0.0.0.0; --host flag takes priority over HOST env var
115
+ const isLan = args.includes('--lan');
116
+ const hostFlag = getFlagValue(['--host']);
117
+ const isLanProxy = isLan || process.env.RUNEYA_LAN_PROXY === 'true' || process.env.RUNEYA_LAN_PROXY === '1';
118
+ if (hostFlag) {
119
+ process.env.HOST = hostFlag;
120
+ } else if (isLanProxy) {
121
+ process.env.HOST = '0.0.0.0';
122
+ }
123
+ if (isLanProxy) {
124
+ process.env.RUNEYA_LAN_PROXY = 'true';
125
+ }
126
+ // Agent binary path (embedded in CLI dist during build)
127
+ process.env.AGENT_BINARY_PATH = join(__dirname, 'agent/index.js');
128
+
129
+ // --agent mode: run as standalone agent only (no server, no UI)
130
+ const isAgentOnly = args.includes('--agent');
131
+ if (isAgentOnly) {
132
+ const agentPort = getFlagValue(['--port']);
133
+ const agentHost = getFlagValue(['--host']);
134
+ if (agentPort) process.env.RUNEYA_AGENT_PORT = agentPort;
135
+ // --host in agent mode binds the agent (default 127.0.0.1 — use --host 0.0.0.0 for remote access)
136
+ // --lan also applies: sets agent host to 0.0.0.0 unless --host overrides it
137
+ if (agentHost) {
138
+ process.env.RUNEYA_AGENT_HOST = agentHost;
139
+ } else if (isLanProxy) {
140
+ process.env.RUNEYA_AGENT_HOST = '0.0.0.0';
141
+ }
142
+ }
143
+ // Data directory defaults to CWD/.runeya (server env.ts default)
144
+ // so Runeya data lives alongside the target project.
145
+
146
+ /**
147
+ * Listen with retry for EADDRINUSE — handles the race condition when the CLI
148
+ * restarts the server before the old process has fully released the port.
149
+ *
150
+ * The `wss` parameter is needed because the `ws` library re-emits HTTP server
151
+ * errors on the WebSocketServer — without a handler the error becomes unhandled.
152
+ */
153
+ function listenWithRetry(
154
+ server: Server,
155
+ port: number,
156
+ host: string,
157
+ wss?: WebSocketServer,
158
+ maxRetries = 20,
159
+ delay = 500,
160
+ ): Promise<void> {
161
+ return new Promise((resolve, reject) => {
162
+ let attempts = 0;
163
+
164
+ // Absorb EADDRINUSE errors re-emitted by the ws library on the WSS
165
+ // during the retry window. Without this, Node.js crashes on unhandled error.
166
+ const wssErrorHandler = () => { /* absorbed — handled via server 'error' */ };
167
+ wss?.on('error', wssErrorHandler);
168
+
169
+ const tryListen = () => {
170
+ const onError = (err: NodeJS.ErrnoException) => {
171
+ // Clean up the 'listening' callback that server.listen() registered
172
+ server.removeAllListeners('listening');
173
+
174
+ if (err.code === 'EADDRINUSE' && attempts < maxRetries) {
175
+ attempts++;
176
+ console.log(`[cli] Port ${port} in use, retrying in ${delay}ms (${attempts}/${maxRetries})...`);
177
+ setTimeout(tryListen, delay);
178
+ } else {
179
+ wss?.removeListener('error', wssErrorHandler);
180
+ reject(err);
181
+ }
182
+ };
183
+
184
+ server.once('error', onError);
185
+ server.listen(port, host, () => {
186
+ server.removeListener('error', onError);
187
+ wss?.removeListener('error', wssErrorHandler);
188
+ resolve();
189
+ });
190
+ };
191
+
192
+ tryListen();
193
+ });
194
+ }
195
+
196
+ async function main() {
197
+ // Dynamic import ensures env is parsed AFTER we configure it
198
+ const { createLocalServer, pullEnv, PullEnvError } = await import('@runeya/apps-server');
199
+
200
+ // Handle --pull-env mode: resolve env vars and print, then exit
201
+ if (isPullEnv) {
202
+ if (!serviceArg) {
203
+ console.error('Error: --service (-s) is required with --pull-env');
204
+ process.exit(1);
205
+ }
206
+ if (!environmentArg) {
207
+ console.error('Error: --environment (-e) is required with --pull-env');
208
+ process.exit(1);
209
+ }
210
+ try {
211
+ const output = await pullEnv({ service: serviceArg, environment: environmentArg });
212
+ if (output) process.stdout.write(output + '\n');
213
+ process.exit(0);
214
+ } catch (err: unknown) {
215
+ if (err instanceof PullEnvError) {
216
+ console.error(`Error: ${err.message}`);
217
+ process.exit(err.exitCode);
218
+ }
219
+ throw err;
220
+ }
221
+ }
222
+
223
+ // --agent mode: standalone agent, no server, no UI
224
+ if (isAgentOnly) {
225
+ const { spawn } = await import('node:child_process');
226
+ const agentBin = join(__dirname, 'agent/index.js');
227
+ const child = spawn(process.execPath, [agentBin], { stdio: 'inherit' });
228
+ child.on('exit', (code) => process.exit(code ?? 0));
229
+ // SIGINT is already delivered to the child by the terminal (same process group).
230
+ // Intercept it here only to suppress Node's default exit so we stay alive
231
+ // until the child finishes its graceful shutdown.
232
+ process.on('SIGINT', () => { /* wait for child to exit */ });
233
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
234
+ return;
235
+ }
236
+
237
+ console.log('[cli] Starting Runeya...');
238
+
239
+ // Create server (automatically spawns local agent)
240
+ const { app, server, wss, shutdown } = await createLocalServer();
241
+
242
+ // Serve static files from web frontend (embedded in CLI dist during build)
243
+ const webDistPath = join(__dirname, 'web');
244
+ app.use(express.static(webDistPath));
245
+
246
+ // Fallback to index.html for client-side routing (Vue Router)
247
+ // Express 5 requires explicit path patterns (not '*')
248
+ app.use((_req: Request, res: Response) => {
249
+ res.sendFile(join(webDistPath, 'index.html'));
250
+ });
251
+
252
+ // Start server — RUNEYA_HTTP_PORT takes priority over PORT
253
+ const port = resolvePort();
254
+ const host = process.env.HOST ?? '127.0.0.1';
255
+
256
+ await listenWithRetry(server, port, host, wss);
257
+
258
+ const appUrl = `http://${host}:${port}`;
259
+
260
+ if (!noOpen) {
261
+ await open(appUrl);
262
+ }
263
+
264
+ console.log('\n🚀 Runeya is running!');
265
+ if (isLanProxy) {
266
+ console.log('🌐 LAN mode: server and service ports exposed on local network');
267
+ }
268
+ printStartupTable(port, appUrl);
269
+ console.log('[cli] Press Ctrl+C to stop');
270
+
271
+ let shuttingDown = false;
272
+ const gracefulShutdown = async (signal: string) => {
273
+ if (shuttingDown) {
274
+ console.log(`[cli] Forced exit (second ${signal})`);
275
+ process.exit(1);
276
+ }
277
+ shuttingDown = true;
278
+ console.log(`\n[cli] Received ${signal}, shutting down gracefully...`);
279
+
280
+ // In dev, exit immediately — ports are freed on process death,
281
+ // and the 'exit' handler in agent-spawner SIGKILLs the agent.
282
+ if (process.env.NODE_ENV === 'development') {
283
+ process.exit(0);
284
+ }
285
+
286
+ await shutdown();
287
+ console.log('[cli] Shutdown complete');
288
+ process.exit(0);
289
+ };
290
+
291
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
292
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
293
+ }
294
+
295
+ main().catch((err) => {
296
+ console.error('[cli] Fatal error:', err);
297
+ process.exit(1);
298
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve the HTTP port Runeya should listen on.
3
+ *
4
+ * Priority: RUNEYA_HTTP_PORT > PORT > 4000 (default)
5
+ *
6
+ * Throws if the resolved value is not an integer in [1, 65535].
7
+ */
8
+ export function resolvePort(): number {
9
+ const raw = process.env.RUNEYA_HTTP_PORT ?? process.env.PORT;
10
+ if (raw === undefined) return 4000;
11
+
12
+ const port = Number(raw);
13
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
14
+ throw new Error(
15
+ `Invalid port: ${JSON.stringify(raw)}. Must be an integer between 1 and 65535.`,
16
+ );
17
+ }
18
+ return port;
19
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
@@ -0,0 +1,10 @@
1
+ // tsup.config.ts
2
+ import { defineConfig } from "tsup";
3
+ import { getAppConfig } from "@runeya/scripts-tsup-config";
4
+ var tsup_config_default = defineConfig(getAppConfig({
5
+ entry: ["src/index.ts"]
6
+ }));
7
+ export {
8
+ tsup_config_default as default
9
+ };
10
+ //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidHN1cC5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiL2hvbWUvY29jby9Qcm9qZWN0cy9ydW5leWEtcnVzdC9hcHBzL2NsaS90c3VwLmNvbmZpZy50c1wiO2NvbnN0IF9faW5qZWN0ZWRfZGlybmFtZV9fID0gXCIvaG9tZS9jb2NvL1Byb2plY3RzL3J1bmV5YS1ydXN0L2FwcHMvY2xpXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9ob21lL2NvY28vUHJvamVjdHMvcnVuZXlhLXJ1c3QvYXBwcy9jbGkvdHN1cC5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd0c3VwJztcbmltcG9ydCB7IGdldEFwcENvbmZpZyB9IGZyb20gJ0BydW5leWEvc2NyaXB0cy10c3VwLWNvbmZpZyc7XG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyhnZXRBcHBDb25maWcoe1xuICBlbnRyeTogWydzcmMvaW5kZXgudHMnXSxcbn0pKTtcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBc1EsU0FBUyxvQkFBb0I7QUFDblMsU0FBUyxvQkFBb0I7QUFFN0IsSUFBTyxzQkFBUSxhQUFhLGFBQWE7QUFBQSxFQUN2QyxPQUFPLENBQUMsY0FBYztBQUN4QixDQUFDLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
@@ -0,0 +1,10 @@
1
+ // tsup.config.ts
2
+ import { defineConfig } from "tsup";
3
+ import { getAppConfig } from "@runeya/scripts-tsup-config";
4
+ var tsup_config_default = defineConfig(getAppConfig({
5
+ entry: ["src/index.ts"]
6
+ }));
7
+ export {
8
+ tsup_config_default as default
9
+ };
10
+ //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidHN1cC5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiL2hvbWUvY29jby9Qcm9qZWN0cy9ydW5leWEtcnVzdC9hcHBzL2NsaS90c3VwLmNvbmZpZy50c1wiO2NvbnN0IF9faW5qZWN0ZWRfZGlybmFtZV9fID0gXCIvaG9tZS9jb2NvL1Byb2plY3RzL3J1bmV5YS1ydXN0L2FwcHMvY2xpXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9ob21lL2NvY28vUHJvamVjdHMvcnVuZXlhLXJ1c3QvYXBwcy9jbGkvdHN1cC5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd0c3VwJztcbmltcG9ydCB7IGdldEFwcENvbmZpZyB9IGZyb20gJ0BydW5leWEvc2NyaXB0cy10c3VwLWNvbmZpZyc7XG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyhnZXRBcHBDb25maWcoe1xuICBlbnRyeTogWydzcmMvaW5kZXgudHMnXSxcbn0pKTtcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBc1EsU0FBUyxvQkFBb0I7QUFDblMsU0FBUyxvQkFBb0I7QUFFN0IsSUFBTyxzQkFBUSxhQUFhLGFBQWE7QUFBQSxFQUN2QyxPQUFPLENBQUMsY0FBYztBQUN4QixDQUFDLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
package/tsup.config.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from 'tsup';
2
+ import { getAppConfig } from '@runeya/scripts-tsup-config';
3
+
4
+ export default defineConfig(getAppConfig({
5
+ entry: ['src/index.ts'],
6
+ }));