@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 +54 -0
- package/README.md +143 -0
- package/dist/index.js +225 -0
- package/package.json +31 -0
- package/scripts/post-build.mjs +61 -0
- package/src/__tests__/cli-port.test.ts +93 -0
- package/src/index.ts +298 -0
- package/src/port-resolution.ts +19 -0
- package/tsconfig.json +9 -0
- package/tsup.config.bundled_lh0xis3zdq.mjs +10 -0
- package/tsup.config.bundled_qj1zdvku09a.mjs +10 -0
- package/tsup.config.ts +6 -0
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,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==
|