wispy-cli 1.1.2 → 1.2.1

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 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(
@@ -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
+ }