wispy-cli 1.1.2 → 1.2.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.
- package/bin/wispy.mjs +225 -0
- package/core/deploy.mjs +292 -0
- package/core/engine.mjs +112 -58
- package/core/harness.mjs +531 -0
- package/core/index.mjs +2 -0
- package/lib/channels/index.mjs +229 -4
- package/lib/wispy-tui.mjs +430 -30
- package/package.json +2 -2
package/bin/wispy.mjs
CHANGED
|
@@ -22,6 +22,231 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
22
22
|
|
|
23
23
|
const args = process.argv.slice(2);
|
|
24
24
|
|
|
25
|
+
// ── status sub-command ────────────────────────────────────────────────────────
|
|
26
|
+
if (args[0] === "status") {
|
|
27
|
+
const { readFile } = await import("node:fs/promises");
|
|
28
|
+
const { homedir } = await import("node:os");
|
|
29
|
+
const { join } = await import("node:path");
|
|
30
|
+
const { DeployManager } = await import(
|
|
31
|
+
path.join(__dirname, "..", "core", "deploy.mjs")
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const remotePath = join(homedir(), ".wispy", "remote.json");
|
|
35
|
+
let remote = null;
|
|
36
|
+
try {
|
|
37
|
+
remote = JSON.parse(await readFile(remotePath, "utf8"));
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
41
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
42
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
43
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
44
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
45
|
+
|
|
46
|
+
console.log(`\n🌿 ${bold("Wispy Status")}\n`);
|
|
47
|
+
|
|
48
|
+
if (remote?.url) {
|
|
49
|
+
console.log(` Mode: ${yellow("remote")}`);
|
|
50
|
+
console.log(` Server: ${cyan(remote.url)}`);
|
|
51
|
+
console.log(` Token: ${dim(remote.token ? remote.token.slice(0, 8) + "..." : "none")}`);
|
|
52
|
+
|
|
53
|
+
const dm = new DeployManager();
|
|
54
|
+
process.stdout.write(" Health: checking... ");
|
|
55
|
+
const status = await dm.checkRemote(remote.url);
|
|
56
|
+
if (status.alive) {
|
|
57
|
+
console.log(green("✓ alive"));
|
|
58
|
+
if (status.version) console.log(` Version: ${status.version}`);
|
|
59
|
+
if (status.uptime != null) console.log(` Uptime: ${Math.floor(status.uptime)}s`);
|
|
60
|
+
if (status.latency) console.log(` Latency: ${status.latency}ms`);
|
|
61
|
+
} else {
|
|
62
|
+
console.log(`\x1b[31m✗ unreachable\x1b[0m`);
|
|
63
|
+
if (status.error) console.log(` Error: ${dim(status.error)}`);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
console.log(` Mode: ${green("local")}`);
|
|
67
|
+
console.log(` Server: http://localhost:18790 ${dim("(when running wispy server)")}`);
|
|
68
|
+
console.log(dim("\n Tip: use `wispy connect <url> --token <token>` to use a remote server"));
|
|
69
|
+
}
|
|
70
|
+
console.log("");
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── connect sub-command ───────────────────────────────────────────────────────
|
|
75
|
+
if (args[0] === "connect" && args[1]) {
|
|
76
|
+
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
77
|
+
const { homedir } = await import("node:os");
|
|
78
|
+
const { join } = await import("node:path");
|
|
79
|
+
const { DeployManager } = await import(
|
|
80
|
+
path.join(__dirname, "..", "core", "deploy.mjs")
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const url = args[1].replace(/\/$/, "");
|
|
84
|
+
const tokenIdx = args.indexOf("--token");
|
|
85
|
+
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : "";
|
|
86
|
+
|
|
87
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
88
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
89
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
90
|
+
|
|
91
|
+
process.stdout.write(`\n🔗 Checking ${url}... `);
|
|
92
|
+
const dm = new DeployManager();
|
|
93
|
+
const status = await dm.checkRemote(url);
|
|
94
|
+
|
|
95
|
+
if (!status.alive) {
|
|
96
|
+
console.log(red("unreachable"));
|
|
97
|
+
if (status.error) console.log(dim(` ${status.error}`));
|
|
98
|
+
console.log(red("\n❌ Could not connect to remote wispy server."));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(green("✓ alive"));
|
|
103
|
+
|
|
104
|
+
const wispyDir = join(homedir(), ".wispy");
|
|
105
|
+
await mkdir(wispyDir, { recursive: true });
|
|
106
|
+
await writeFile(
|
|
107
|
+
join(wispyDir, "remote.json"),
|
|
108
|
+
JSON.stringify({ url, token, connectedAt: new Date().toISOString() }, null, 2),
|
|
109
|
+
"utf8"
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
console.log(green(`\n✅ Connected to ${url}`));
|
|
113
|
+
console.log(dim(" Local wispy will now proxy to the remote server."));
|
|
114
|
+
console.log(dim(" Run `wispy disconnect` to go back to local mode.\n"));
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── disconnect sub-command ────────────────────────────────────────────────────
|
|
119
|
+
if (args[0] === "disconnect") {
|
|
120
|
+
const { unlink } = await import("node:fs/promises");
|
|
121
|
+
const { homedir } = await import("node:os");
|
|
122
|
+
const { join } = await import("node:path");
|
|
123
|
+
|
|
124
|
+
const remotePath = join(homedir(), ".wispy", "remote.json");
|
|
125
|
+
try {
|
|
126
|
+
await unlink(remotePath);
|
|
127
|
+
console.log("\n✅ Disconnected. Wispy is back in local mode.\n");
|
|
128
|
+
} catch {
|
|
129
|
+
console.log("\n🌿 Already in local mode.\n");
|
|
130
|
+
}
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── deploy sub-command ────────────────────────────────────────────────────────
|
|
135
|
+
if (args[0] === "deploy") {
|
|
136
|
+
const { DeployManager } = await import(
|
|
137
|
+
path.join(__dirname, "..", "core", "deploy.mjs")
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const sub = args[1];
|
|
141
|
+
const dm = new DeployManager();
|
|
142
|
+
|
|
143
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
144
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
145
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
146
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
147
|
+
|
|
148
|
+
if (sub === "dockerfile") {
|
|
149
|
+
process.stdout.write(dm.generateDockerfile());
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (sub === "compose") {
|
|
154
|
+
process.stdout.write(dm.generateDockerCompose());
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (sub === "systemd") {
|
|
159
|
+
process.stdout.write(dm.generateSystemd());
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (sub === "railway") {
|
|
164
|
+
process.stdout.write(dm.generateRailwayConfig() + "\n");
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (sub === "fly") {
|
|
169
|
+
process.stdout.write(dm.generateFlyConfig());
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (sub === "render") {
|
|
174
|
+
process.stdout.write(dm.generateRenderConfig());
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (sub === "init") {
|
|
179
|
+
console.log("\n🌿 Initializing wispy deploy configs...\n");
|
|
180
|
+
const created = await dm.init(process.cwd());
|
|
181
|
+
for (const f of created) {
|
|
182
|
+
console.log(` ${f.includes("skipped") ? "⏭️ " : "✅"} ${f}`);
|
|
183
|
+
}
|
|
184
|
+
console.log(dim("\n Next steps:"));
|
|
185
|
+
console.log(dim(" 1. Copy .env.example → .env and fill in your API keys"));
|
|
186
|
+
console.log(dim(" 2. docker-compose up -d — for Docker"));
|
|
187
|
+
console.log(dim(" 3. wispy deploy vps user@host — for raw VPS (no Docker)\n"));
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (sub === "vps" && args[2]) {
|
|
192
|
+
const target = args[2];
|
|
193
|
+
const envIdx = args.indexOf("--env");
|
|
194
|
+
const envFile = envIdx !== -1 ? args[envIdx + 1] : null;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await dm.deployVPS({ target, envFile });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.error(`\n❌ Deploy failed: ${err.message}`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
process.exit(0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (sub === "status" && args[2]) {
|
|
206
|
+
const url = args[2];
|
|
207
|
+
process.stdout.write(`\n📡 Checking ${cyan(url)}... `);
|
|
208
|
+
const status = await dm.checkRemote(url);
|
|
209
|
+
if (status.alive) {
|
|
210
|
+
console.log(green("✓ alive"));
|
|
211
|
+
if (status.version) console.log(` Version: ${status.version}`);
|
|
212
|
+
if (status.uptime != null) console.log(` Uptime: ${Math.floor(status.uptime)}s`);
|
|
213
|
+
if (status.latency) console.log(` Latency: ${status.latency}ms`);
|
|
214
|
+
} else {
|
|
215
|
+
console.log(`\x1b[31m✗ unreachable\x1b[0m`);
|
|
216
|
+
if (status.error) console.log(` Error: ${dim(status.error)}`);
|
|
217
|
+
}
|
|
218
|
+
console.log("");
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Help
|
|
223
|
+
console.log(`
|
|
224
|
+
🚀 ${bold("Wispy Deploy Commands")}
|
|
225
|
+
|
|
226
|
+
${cyan("Config generators:")}
|
|
227
|
+
wispy deploy init — generate Dockerfile + compose + .env.example
|
|
228
|
+
wispy deploy dockerfile — print Dockerfile to stdout
|
|
229
|
+
wispy deploy compose — print docker-compose.yml
|
|
230
|
+
wispy deploy systemd — print systemd unit file
|
|
231
|
+
wispy deploy railway — print railway.json
|
|
232
|
+
wispy deploy fly — print fly.toml
|
|
233
|
+
wispy deploy render — print render.yaml
|
|
234
|
+
|
|
235
|
+
${cyan("Deploy:")}
|
|
236
|
+
wispy deploy vps user@host — SSH deploy: install + systemd setup
|
|
237
|
+
wispy deploy vps user@host --env .env — include env file
|
|
238
|
+
|
|
239
|
+
${cyan("Status:")}
|
|
240
|
+
wispy deploy status https://my.vps — check if remote wispy is alive
|
|
241
|
+
|
|
242
|
+
${cyan("Remote connect:")}
|
|
243
|
+
wispy connect https://my.vps:18790 --token <token> — use remote server
|
|
244
|
+
wispy disconnect — go back to local
|
|
245
|
+
wispy status — show current mode
|
|
246
|
+
`);
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
|
|
25
250
|
// ── cron sub-command ──────────────────────────────────────────────────────────
|
|
26
251
|
if (args[0] === "cron") {
|
|
27
252
|
const { WispyEngine, CronManager, WISPY_DIR } = await import(
|
package/core/deploy.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wispy Deploy Manager
|
|
3
|
+
* Helpers for deploying wispy server to VPS/cloud platforms.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { writeFile, mkdir, access } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { exec as execCb } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
const exec = promisify(execCb);
|
|
13
|
+
|
|
14
|
+
export class DeployManager {
|
|
15
|
+
constructor(config = {}) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.port = config.port ?? 18790;
|
|
18
|
+
this.appName = config.appName ?? "wispy";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Config generators ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
generateDockerfile() {
|
|
24
|
+
return `FROM node:20-slim
|
|
25
|
+
WORKDIR /app
|
|
26
|
+
RUN npm install -g wispy-cli
|
|
27
|
+
ENV WISPY_SERVER_PORT=${this.port}
|
|
28
|
+
ENV WISPY_SERVER_HOST=0.0.0.0
|
|
29
|
+
# User provides API keys via env vars
|
|
30
|
+
EXPOSE ${this.port}
|
|
31
|
+
CMD ["wispy", "server", "--host", "0.0.0.0"]
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
generateDockerCompose() {
|
|
36
|
+
return `version: "3.8"
|
|
37
|
+
services:
|
|
38
|
+
wispy:
|
|
39
|
+
build: .
|
|
40
|
+
ports:
|
|
41
|
+
- "${this.port}:${this.port}"
|
|
42
|
+
volumes:
|
|
43
|
+
- wispy-data:/root/.wispy
|
|
44
|
+
env_file: .env
|
|
45
|
+
restart: unless-stopped
|
|
46
|
+
volumes:
|
|
47
|
+
wispy-data:
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
generateSystemd() {
|
|
52
|
+
return `[Unit]
|
|
53
|
+
Description=Wispy AI Assistant Server
|
|
54
|
+
After=network.target
|
|
55
|
+
|
|
56
|
+
[Service]
|
|
57
|
+
Type=simple
|
|
58
|
+
User=wispy
|
|
59
|
+
ExecStart=/usr/bin/npx wispy-cli server --host 0.0.0.0
|
|
60
|
+
Restart=always
|
|
61
|
+
EnvironmentFile=/etc/wispy/env
|
|
62
|
+
|
|
63
|
+
[Install]
|
|
64
|
+
WantedBy=multi-user.target
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
generateRailwayConfig() {
|
|
69
|
+
return JSON.stringify(
|
|
70
|
+
{
|
|
71
|
+
build: { builder: "NIXPACKS" },
|
|
72
|
+
deploy: {
|
|
73
|
+
startCommand: "npx wispy-cli server --host 0.0.0.0 --port $PORT",
|
|
74
|
+
healthcheckPath: "/api/status",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
null,
|
|
78
|
+
2
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
generateFlyConfig() {
|
|
83
|
+
const app = this.appName;
|
|
84
|
+
return `app = "${app}"
|
|
85
|
+
|
|
86
|
+
[build]
|
|
87
|
+
image = "node:20-slim"
|
|
88
|
+
|
|
89
|
+
[deploy]
|
|
90
|
+
strategy = "immediate"
|
|
91
|
+
|
|
92
|
+
[[services]]
|
|
93
|
+
internal_port = ${this.port}
|
|
94
|
+
protocol = "tcp"
|
|
95
|
+
|
|
96
|
+
[[services.ports]]
|
|
97
|
+
port = 443
|
|
98
|
+
handlers = ["tls", "http"]
|
|
99
|
+
|
|
100
|
+
[[services.ports]]
|
|
101
|
+
port = 80
|
|
102
|
+
handlers = ["http"]
|
|
103
|
+
|
|
104
|
+
[[services.http_checks]]
|
|
105
|
+
interval = 10000
|
|
106
|
+
timeout = 2000
|
|
107
|
+
grace_period = "5s"
|
|
108
|
+
method = "get"
|
|
109
|
+
path = "/api/status"
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
generateRenderConfig() {
|
|
114
|
+
return `services:
|
|
115
|
+
- type: web
|
|
116
|
+
name: ${this.appName}
|
|
117
|
+
runtime: node
|
|
118
|
+
buildCommand: npm install -g wispy-cli
|
|
119
|
+
startCommand: wispy server --host 0.0.0.0 --port $PORT
|
|
120
|
+
healthCheckPath: /api/status
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
generateEnvExample() {
|
|
125
|
+
const token = randomBytes(24).toString("hex");
|
|
126
|
+
return `# Wispy Server Configuration
|
|
127
|
+
WISPY_SERVER_TOKEN=${token}
|
|
128
|
+
WISPY_SERVER_PORT=${this.port}
|
|
129
|
+
WISPY_SERVER_HOST=0.0.0.0
|
|
130
|
+
|
|
131
|
+
# AI Provider (at least one required)
|
|
132
|
+
GOOGLE_AI_KEY=
|
|
133
|
+
ANTHROPIC_API_KEY=
|
|
134
|
+
OPENAI_API_KEY=
|
|
135
|
+
|
|
136
|
+
# Optional: Channel bots
|
|
137
|
+
WISPY_TELEGRAM_TOKEN=
|
|
138
|
+
WISPY_DISCORD_TOKEN=
|
|
139
|
+
WISPY_SLACK_BOT_TOKEN=
|
|
140
|
+
WISPY_SLACK_APP_TOKEN=
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── init: write all config files to cwd ───────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async init(outputDir = process.cwd()) {
|
|
147
|
+
const files = {
|
|
148
|
+
"Dockerfile": this.generateDockerfile(),
|
|
149
|
+
"docker-compose.yml": this.generateDockerCompose(),
|
|
150
|
+
".env.example": this.generateEnvExample(),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const created = [];
|
|
154
|
+
for (const [name, content] of Object.entries(files)) {
|
|
155
|
+
const dest = join(outputDir, name);
|
|
156
|
+
// Don't overwrite existing files unless forced
|
|
157
|
+
let exists = false;
|
|
158
|
+
try { await access(dest); exists = true; } catch {}
|
|
159
|
+
if (!exists) {
|
|
160
|
+
await writeFile(dest, content, "utf8");
|
|
161
|
+
created.push(name);
|
|
162
|
+
} else {
|
|
163
|
+
created.push(`${name} (skipped — already exists)`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return created;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── VPS deploy via SSH ─────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
async deployVPS(opts = {}) {
|
|
172
|
+
const { target, envFile } = opts;
|
|
173
|
+
if (!target) throw new Error("target (user@host) is required");
|
|
174
|
+
|
|
175
|
+
const ssh = (cmd) => exec(`ssh ${target} '${cmd}'`);
|
|
176
|
+
const log = (msg) => console.log(msg);
|
|
177
|
+
|
|
178
|
+
log(`\n🚀 Deploying wispy to ${target}...\n`);
|
|
179
|
+
|
|
180
|
+
// 1. Check Node.js
|
|
181
|
+
log(" [1/7] Checking Node.js...");
|
|
182
|
+
try {
|
|
183
|
+
const { stdout } = await ssh("node --version");
|
|
184
|
+
log(` Node.js ${stdout.trim()} ✓`);
|
|
185
|
+
} catch {
|
|
186
|
+
log(" ❌ Node.js is not installed on the remote host.");
|
|
187
|
+
log(" Install with: curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs");
|
|
188
|
+
throw new Error("Node.js not found on remote host");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 2. Install wispy-cli
|
|
192
|
+
log(" [2/7] Installing wispy-cli...");
|
|
193
|
+
await ssh("npm install -g wispy-cli 2>&1 | tail -3");
|
|
194
|
+
log(" wispy-cli installed ✓");
|
|
195
|
+
|
|
196
|
+
// 3. Create wispy user if not exists
|
|
197
|
+
log(" [3/7] Setting up wispy user...");
|
|
198
|
+
await ssh("id -u wispy >/dev/null 2>&1 || useradd -r -s /bin/false wispy").catch(() => {});
|
|
199
|
+
log(" wispy user ready ✓");
|
|
200
|
+
|
|
201
|
+
// 4. Copy env file
|
|
202
|
+
log(" [4/7] Copying env file...");
|
|
203
|
+
await ssh("mkdir -p /etc/wispy");
|
|
204
|
+
if (envFile) {
|
|
205
|
+
const { stdout: envContent } = await exec(`cat "${envFile}"`);
|
|
206
|
+
// Write via ssh heredoc
|
|
207
|
+
await exec(`ssh ${target} 'cat > /etc/wispy/env' << 'HEREDOC'\n${envContent}\nHEREDOC`);
|
|
208
|
+
log(" .env copied ✓");
|
|
209
|
+
} else {
|
|
210
|
+
log(" No envFile provided — you'll need to manually create /etc/wispy/env");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 5. Install systemd service
|
|
214
|
+
log(" [5/7] Installing systemd service...");
|
|
215
|
+
const unit = this.generateSystemd();
|
|
216
|
+
await exec(`ssh ${target} 'cat > /etc/systemd/system/wispy.service' << 'HEREDOC'\n${unit}\nHEREDOC`);
|
|
217
|
+
await ssh("systemctl daemon-reload");
|
|
218
|
+
log(" systemd service installed ✓");
|
|
219
|
+
|
|
220
|
+
// 6. Start service
|
|
221
|
+
log(" [6/7] Starting wispy service...");
|
|
222
|
+
await ssh("systemctl enable wispy && systemctl restart wispy");
|
|
223
|
+
log(" service started ✓");
|
|
224
|
+
|
|
225
|
+
// 7. Health check
|
|
226
|
+
log(" [7/7] Verifying...");
|
|
227
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
228
|
+
const host = target.includes("@") ? target.split("@")[1] : target;
|
|
229
|
+
const url = `http://${host}:${this.port}`;
|
|
230
|
+
try {
|
|
231
|
+
const status = await this.checkRemote(url);
|
|
232
|
+
if (status.alive) {
|
|
233
|
+
log(`\n✅ Wispy is running at ${url}`);
|
|
234
|
+
log(` Version: ${status.version ?? "unknown"}`);
|
|
235
|
+
} else {
|
|
236
|
+
log(`\n⚠️ Health check failed — check logs with: ssh ${target} 'journalctl -u wispy -n 50'`);
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
log(`\n⚠️ Could not verify — check: ssh ${target} 'systemctl status wispy'`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Print token hint
|
|
243
|
+
try {
|
|
244
|
+
const { stdout: envOut } = await ssh("cat /etc/wispy/env 2>/dev/null | grep WISPY_SERVER_TOKEN || echo ''");
|
|
245
|
+
const tokenLine = envOut.trim();
|
|
246
|
+
if (tokenLine) {
|
|
247
|
+
const token = tokenLine.split("=")[1]?.trim();
|
|
248
|
+
log(`\n🔑 Connect from local:\n wispy connect ${url} --token ${token}`);
|
|
249
|
+
}
|
|
250
|
+
} catch {}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Docker build + push ────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
async deployDocker(opts = {}) {
|
|
256
|
+
const { image = "wispy-cli", registry } = opts;
|
|
257
|
+
const tag = registry ? `${registry}/${image}:latest` : `${image}:latest`;
|
|
258
|
+
|
|
259
|
+
console.log(`\n🐳 Building Docker image: ${tag}\n`);
|
|
260
|
+
await exec(`docker build -t ${tag} .`);
|
|
261
|
+
console.log("✅ Image built");
|
|
262
|
+
|
|
263
|
+
if (registry) {
|
|
264
|
+
console.log(`\n📤 Pushing to ${registry}...`);
|
|
265
|
+
await exec(`docker push ${tag}`);
|
|
266
|
+
console.log("✅ Pushed");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Remote status check ────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
async checkRemote(url) {
|
|
273
|
+
const normalizedUrl = url.replace(/\/$/, "");
|
|
274
|
+
const start = Date.now();
|
|
275
|
+
try {
|
|
276
|
+
const resp = await fetch(`${normalizedUrl}/api/status`, {
|
|
277
|
+
signal: AbortSignal.timeout(5000),
|
|
278
|
+
});
|
|
279
|
+
const latency = Date.now() - start;
|
|
280
|
+
if (!resp.ok) return { alive: false, latency };
|
|
281
|
+
const data = await resp.json().catch(() => ({}));
|
|
282
|
+
return {
|
|
283
|
+
alive: true,
|
|
284
|
+
version: data.version,
|
|
285
|
+
uptime: data.uptime,
|
|
286
|
+
latency,
|
|
287
|
+
};
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return { alive: false, error: err.message };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|