@unlikeotherai/unloved 0.2.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.
Files changed (41) hide show
  1. package/dist/index.js +820 -0
  2. package/package.json +32 -0
  3. package/public/assets/index-BBCy7fgT.js +152 -0
  4. package/public/assets/index-u3hNJUdm.css +1 -0
  5. package/public/assets/inter-latin-100-normal-Cg8nSI4P.woff2 +0 -0
  6. package/public/assets/inter-latin-100-normal-J9XNenR1.woff +0 -0
  7. package/public/assets/inter-latin-200-normal-CGIQ4gbF.woff2 +0 -0
  8. package/public/assets/inter-latin-200-normal-fAycq8N-.woff +0 -0
  9. package/public/assets/inter-latin-300-normal-BVlfKGgI.woff2 +0 -0
  10. package/public/assets/inter-latin-300-normal-i8F0SvXL.woff +0 -0
  11. package/public/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  12. package/public/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  13. package/public/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  14. package/public/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  15. package/public/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  16. package/public/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  17. package/public/assets/inter-latin-700-normal-Drs_5D37.woff2 +0 -0
  18. package/public/assets/inter-latin-700-normal-KTwiWvO9.woff +0 -0
  19. package/public/assets/inter-latin-800-normal-BYj_oED-.woff2 +0 -0
  20. package/public/assets/inter-latin-800-normal-D1mf63XC.woff +0 -0
  21. package/public/assets/inter-latin-900-normal-D4nM5aha.woff2 +0 -0
  22. package/public/assets/inter-latin-900-normal-EUCDUbiG.woff +0 -0
  23. package/public/assets/jetbrains-mono-latin-100-normal-DlYB2XW3.woff2 +0 -0
  24. package/public/assets/jetbrains-mono-latin-100-normal-DzH8uRxw.woff +0 -0
  25. package/public/assets/jetbrains-mono-latin-200-normal-CHn02WOn.woff +0 -0
  26. package/public/assets/jetbrains-mono-latin-200-normal-DvzYDkvL.woff2 +0 -0
  27. package/public/assets/jetbrains-mono-latin-300-normal-BYcAiAh2.woff +0 -0
  28. package/public/assets/jetbrains-mono-latin-300-normal-DuMDZskh.woff2 +0 -0
  29. package/public/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  30. package/public/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  31. package/public/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  32. package/public/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  33. package/public/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  34. package/public/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  35. package/public/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  36. package/public/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  37. package/public/assets/jetbrains-mono-latin-800-normal-D2mQHRMK.woff2 +0 -0
  38. package/public/assets/jetbrains-mono-latin-800-normal-Dj9qwObk.woff +0 -0
  39. package/public/favicon.png +0 -0
  40. package/public/index.html +14 -0
  41. package/public/logo.png +0 -0
package/dist/index.js ADDED
@@ -0,0 +1,820 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/parse-flags.ts
4
+ function parseArgs(argv) {
5
+ const args2 = argv.slice(2);
6
+ const command = args2[0] ?? "start";
7
+ const positional = [];
8
+ const flags = {};
9
+ let i = 1;
10
+ while (i < args2.length) {
11
+ const arg = args2[i];
12
+ if (arg.startsWith("--")) {
13
+ const key = arg.slice(2);
14
+ const next = args2[i + 1];
15
+ if (next && !next.startsWith("--")) {
16
+ flags[key] = next;
17
+ i += 2;
18
+ } else {
19
+ flags[key] = true;
20
+ i += 1;
21
+ }
22
+ } else {
23
+ positional.push(arg);
24
+ i += 1;
25
+ }
26
+ }
27
+ return { command, positional, flags };
28
+ }
29
+
30
+ // src/version.ts
31
+ import { readFile } from "fs/promises";
32
+ import { resolve, dirname } from "path";
33
+ import { fileURLToPath } from "url";
34
+ async function getVersion() {
35
+ try {
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ const pkgPath = resolve(__dirname, "..", "package.json");
38
+ const content = await readFile(pkgPath, "utf-8");
39
+ const pkg = JSON.parse(content);
40
+ return pkg.version;
41
+ } catch {
42
+ return "0.0.0";
43
+ }
44
+ }
45
+
46
+ // src/commands/start.ts
47
+ import { createConnection } from "net";
48
+ import { resolve as resolve6, dirname as dirname2 } from "path";
49
+ import { fileURLToPath as fileURLToPath2 } from "url";
50
+
51
+ // ../server/dist/index.js
52
+ import { createServer as createHttpServer } from "http";
53
+ import { resolve as resolve3 } from "path";
54
+ import cors from "cors";
55
+ import express from "express";
56
+ import { readFile as readFile2, writeFile } from "fs/promises";
57
+ import { resolve as resolve2 } from "path";
58
+ import { Router } from "express";
59
+
60
+ // ../shared/src/config.ts
61
+ var DEFAULT_CONFIG = {
62
+ theme: "light"
63
+ };
64
+
65
+ // ../server/dist/index.js
66
+ import { z } from "zod";
67
+ import { Router as Router2 } from "express";
68
+ import { execFile } from "child_process";
69
+ import { mkdir, readFile as readFile22, writeFile as writeFile2 } from "fs/promises";
70
+ import { resolve as resolve22 } from "path";
71
+ import { promisify } from "util";
72
+ import { Router as Router3 } from "express";
73
+ import { z as z2 } from "zod";
74
+ import { execFile as execFile2 } from "child_process";
75
+ import { promisify as promisify2 } from "util";
76
+ import { Router as Router4 } from "express";
77
+ import { WebSocketServer } from "ws";
78
+ import { execFileSync } from "child_process";
79
+ import { spawn } from "node-pty";
80
+ var configRouter = Router();
81
+ var getConfigPath = (homeDir) => resolve2(homeDir, "config.json");
82
+ var patchSchema = z.object({
83
+ theme: z.enum(["light", "dark"]).optional()
84
+ });
85
+ var readConfig = async (homeDir) => {
86
+ try {
87
+ const content = await readFile2(getConfigPath(homeDir), "utf-8");
88
+ return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
89
+ } catch (error) {
90
+ const fileError = error;
91
+ if (fileError.code === "ENOENT") {
92
+ return DEFAULT_CONFIG;
93
+ }
94
+ throw error;
95
+ }
96
+ };
97
+ configRouter.get("/", async (request, response) => {
98
+ try {
99
+ const config = await readConfig(request.app.locals.homeDir);
100
+ response.json(config);
101
+ } catch {
102
+ response.status(500).json({ error: "Failed to read config" });
103
+ }
104
+ });
105
+ configRouter.patch("/", async (request, response) => {
106
+ const parsedBody = patchSchema.safeParse(request.body);
107
+ if (!parsedBody.success) {
108
+ response.status(400).json({ error: "Invalid config payload" });
109
+ return;
110
+ }
111
+ try {
112
+ const homeDir = request.app.locals.homeDir;
113
+ const config = await readConfig(homeDir);
114
+ const updatedConfig = { ...config, ...parsedBody.data };
115
+ await writeFile(getConfigPath(homeDir), `${JSON.stringify(updatedConfig, null, 2)}
116
+ `, "utf-8");
117
+ response.json(updatedConfig);
118
+ } catch {
119
+ response.status(500).json({ error: "Failed to write config" });
120
+ }
121
+ });
122
+ var config_default = configRouter;
123
+ var healthRouter = Router2();
124
+ healthRouter.get("/", (_request, response) => {
125
+ response.json({ status: "ok" });
126
+ });
127
+ var health_default = healthRouter;
128
+ var execFileAsync = promisify(execFile);
129
+ var sessionMetaRouter = Router3();
130
+ var metaSchema = z2.object({
131
+ previewUrl: z2.string().optional(),
132
+ restartCommand: z2.string().optional(),
133
+ projectDir: z2.string().optional(),
134
+ cliTool: z2.string().optional(),
135
+ createdAt: z2.string().optional()
136
+ });
137
+ var nameSchema = z2.string().min(1).regex(/^[\w.-]+$/);
138
+ function getMetaPath(homeDir, name) {
139
+ return resolve22(homeDir, "sessions", name, "meta.json");
140
+ }
141
+ async function readMeta(homeDir, name) {
142
+ try {
143
+ const content = await readFile22(getMetaPath(homeDir, name), "utf-8");
144
+ return JSON.parse(content);
145
+ } catch (error) {
146
+ const fileError = error;
147
+ if (fileError.code === "ENOENT") {
148
+ return {};
149
+ }
150
+ throw error;
151
+ }
152
+ }
153
+ async function writeMeta(homeDir, name, meta) {
154
+ const metaPath = getMetaPath(homeDir, name);
155
+ await mkdir(resolve22(homeDir, "sessions", name), { recursive: true });
156
+ await writeFile2(metaPath, `${JSON.stringify(meta, null, 2)}
157
+ `, "utf-8");
158
+ }
159
+ sessionMetaRouter.get("/:name/meta", async (request, response) => {
160
+ const parsed = nameSchema.safeParse(request.params.name);
161
+ if (!parsed.success) {
162
+ response.status(400).json({ error: "Invalid session name" });
163
+ return;
164
+ }
165
+ try {
166
+ const meta = await readMeta(request.app.locals.homeDir, parsed.data);
167
+ response.json(meta);
168
+ } catch {
169
+ response.status(500).json({ error: "Failed to read session metadata" });
170
+ }
171
+ });
172
+ sessionMetaRouter.put("/:name/meta", async (request, response) => {
173
+ const parsedName = nameSchema.safeParse(request.params.name);
174
+ if (!parsedName.success) {
175
+ response.status(400).json({ error: "Invalid session name" });
176
+ return;
177
+ }
178
+ const parsedBody = metaSchema.safeParse(request.body);
179
+ if (!parsedBody.success) {
180
+ response.status(400).json({ error: "Invalid metadata payload" });
181
+ return;
182
+ }
183
+ try {
184
+ const homeDir = request.app.locals.homeDir;
185
+ const existing = await readMeta(homeDir, parsedName.data);
186
+ const merged = { ...existing, ...parsedBody.data };
187
+ await writeMeta(homeDir, parsedName.data, merged);
188
+ response.json(merged);
189
+ } catch {
190
+ response.status(500).json({ error: "Failed to write session metadata" });
191
+ }
192
+ });
193
+ sessionMetaRouter.post("/:name/restart", async (request, response) => {
194
+ const parsedName = nameSchema.safeParse(request.params.name);
195
+ if (!parsedName.success) {
196
+ response.status(400).json({ error: "Invalid session name" });
197
+ return;
198
+ }
199
+ try {
200
+ await execFileAsync("tmux", [
201
+ "send-keys",
202
+ "-t",
203
+ parsedName.data,
204
+ "unloved restart",
205
+ "Enter"
206
+ ]);
207
+ response.json({ sent: true });
208
+ } catch (error) {
209
+ const tmuxError = error;
210
+ if (tmuxError.code === "ENOENT") {
211
+ response.status(400).json({ error: "tmux is not installed" });
212
+ return;
213
+ }
214
+ response.status(500).json({ error: "Failed to send restart command" });
215
+ }
216
+ });
217
+ var session_meta_default = sessionMetaRouter;
218
+ var execFileAsync2 = promisify2(execFile2);
219
+ var tmuxRouter = Router4();
220
+ tmuxRouter.get("/sessions", async (_request, response) => {
221
+ try {
222
+ const { stdout } = await execFileAsync2("tmux", [
223
+ "list-sessions",
224
+ "-F",
225
+ "#{session_name} #{session_windows} #{session_created} #{session_attached}"
226
+ ]);
227
+ const sessions = stdout.trim().split("\n").filter(Boolean).map((line) => {
228
+ const [name, windowsRaw, created, attachedRaw] = line.split(" ");
229
+ return {
230
+ name,
231
+ windows: Number.parseInt(windowsRaw, 10),
232
+ created,
233
+ attached: attachedRaw === "1"
234
+ };
235
+ });
236
+ response.json(sessions);
237
+ } catch (error) {
238
+ const tmuxError = error;
239
+ if (tmuxError.code === "ENOENT" || tmuxError.status === 1) {
240
+ response.json([]);
241
+ return;
242
+ }
243
+ response.status(500).json({ error: "Failed to list tmux sessions" });
244
+ }
245
+ });
246
+ tmuxRouter.post("/sessions", async (request, response) => {
247
+ const { name } = request.body;
248
+ if (!name || !name.trim()) {
249
+ response.status(400).json({ error: "Session name is required" });
250
+ return;
251
+ }
252
+ try {
253
+ await execFileAsync2("tmux", ["new-session", "-d", "-s", name.trim()]);
254
+ response.json({ name: name.trim(), created: true });
255
+ } catch (error) {
256
+ const tmuxError = error;
257
+ if (tmuxError.code === "ENOENT") {
258
+ response.status(400).json({ error: "tmux is not installed" });
259
+ return;
260
+ }
261
+ response.status(500).json({ error: "Failed to create tmux session" });
262
+ }
263
+ });
264
+ var tmux_default = tmuxRouter;
265
+ function createApp(options) {
266
+ const app = express();
267
+ app.locals.homeDir = options.homeDir;
268
+ app.use(
269
+ cors({
270
+ origin: (_origin, callback) => callback(null, true)
271
+ })
272
+ );
273
+ app.use(express.json());
274
+ app.use("/api/health", health_default);
275
+ app.use("/api/tmux", tmux_default);
276
+ app.use("/api/config", config_default);
277
+ app.use("/api/sessions", session_meta_default);
278
+ if (options.staticDir) {
279
+ app.use(express.static(options.staticDir));
280
+ app.get("/{*splat}", (_req, res) => {
281
+ res.sendFile(resolve3(options.staticDir, "index.html"));
282
+ });
283
+ }
284
+ return app;
285
+ }
286
+ var MAX_BYTES = 1024 * 1024;
287
+ var RingBuffer = class {
288
+ chunks = [];
289
+ totalBytes = 0;
290
+ push(data) {
291
+ const buf = typeof data === "string" ? Buffer.from(data) : data;
292
+ this.chunks.push(buf);
293
+ this.totalBytes += buf.length;
294
+ this.trim();
295
+ }
296
+ getAll() {
297
+ return Buffer.concat(this.chunks);
298
+ }
299
+ trim() {
300
+ while (this.totalBytes > MAX_BYTES && this.chunks.length > 1) {
301
+ const removed = this.chunks.shift();
302
+ this.totalBytes -= removed.length;
303
+ }
304
+ }
305
+ };
306
+ var tmuxBin = "tmux";
307
+ try {
308
+ tmuxBin = execFileSync("which", ["tmux"], { encoding: "utf-8" }).trim();
309
+ } catch {
310
+ }
311
+ var entries = /* @__PURE__ */ new Map();
312
+ var GRACE_MS = 6e4;
313
+ function sendJson(ws, msg) {
314
+ ws.send(JSON.stringify(msg));
315
+ }
316
+ function attach(sessionName, ws) {
317
+ let entry = entries.get(sessionName);
318
+ if (!entry) {
319
+ let pty;
320
+ try {
321
+ pty = spawn(tmuxBin, ["attach-session", "-t", sessionName], {
322
+ name: "xterm-256color",
323
+ cols: 120,
324
+ rows: 30,
325
+ cwd: process.env.HOME ?? "/",
326
+ env: { ...process.env }
327
+ });
328
+ } catch (err) {
329
+ const msg = err instanceof Error ? err.message : "Failed to spawn pty";
330
+ console.error(`[pty] spawn failed for ${sessionName}:`, msg);
331
+ sendJson(ws, { type: "error", message: msg });
332
+ ws.close();
333
+ return;
334
+ }
335
+ const buffer = new RingBuffer();
336
+ const clients = /* @__PURE__ */ new Set();
337
+ pty.onData((data) => {
338
+ buffer.push(data);
339
+ for (const c of clients) {
340
+ if (c.readyState === 1) c.send(data);
341
+ }
342
+ });
343
+ pty.onExit(() => {
344
+ for (const c of clients) {
345
+ sendJson(c, { type: "error", message: "tmux session ended" });
346
+ c.close();
347
+ }
348
+ entries.delete(sessionName);
349
+ });
350
+ entry = { pty, buffer, clients, graceTimer: null };
351
+ entries.set(sessionName, entry);
352
+ }
353
+ if (entry.graceTimer) {
354
+ clearTimeout(entry.graceTimer);
355
+ entry.graceTimer = null;
356
+ }
357
+ const replay = entry.buffer.getAll();
358
+ if (replay.length > 0) {
359
+ sendJson(ws, { type: "replay", data: replay.toString("utf-8") });
360
+ }
361
+ entry.clients.add(ws);
362
+ }
363
+ function detach(sessionName, ws) {
364
+ const entry = entries.get(sessionName);
365
+ if (!entry) return;
366
+ entry.clients.delete(ws);
367
+ if (entry.clients.size === 0) {
368
+ entry.graceTimer = setTimeout(() => {
369
+ entry.pty.kill();
370
+ entries.delete(sessionName);
371
+ }, GRACE_MS);
372
+ }
373
+ }
374
+ function write(sessionName, data) {
375
+ entries.get(sessionName)?.pty.write(data);
376
+ }
377
+ function resize(sessionName, cols, rows) {
378
+ entries.get(sessionName)?.pty.resize(cols, rows);
379
+ }
380
+ function createWsHandler(server) {
381
+ const wss = new WebSocketServer({ noServer: true });
382
+ server.on("upgrade", (req, socket, head) => {
383
+ const match = req.url?.match(/^\/ws\/terminal\/([^/?]+)/);
384
+ if (!match) {
385
+ socket.destroy();
386
+ return;
387
+ }
388
+ wss.handleUpgrade(req, socket, head, (ws) => {
389
+ wss.emit("connection", ws, req, match[1]);
390
+ });
391
+ });
392
+ wss.on("connection", (ws, _req, sessionName) => {
393
+ attach(sessionName, ws);
394
+ ws.on("message", (raw, isBinary) => {
395
+ if (isBinary) {
396
+ write(sessionName, raw.toString());
397
+ return;
398
+ }
399
+ try {
400
+ const msg = JSON.parse(raw.toString());
401
+ if (msg.type === "resize" && msg.cols > 0 && msg.rows > 0) {
402
+ resize(sessionName, msg.cols, msg.rows);
403
+ }
404
+ } catch {
405
+ write(sessionName, raw.toString());
406
+ }
407
+ });
408
+ ws.on("close", () => {
409
+ detach(sessionName, ws);
410
+ });
411
+ });
412
+ }
413
+ var DEFAULT_PORT = 6200;
414
+ function startServer(options) {
415
+ const { port = DEFAULT_PORT, ...appOptions } = options;
416
+ const app = createApp(appOptions);
417
+ const server = createHttpServer(app);
418
+ createWsHandler(server);
419
+ server.listen(port, "0.0.0.0", () => {
420
+ console.log(`@unloved/server listening on http://localhost:${port}`);
421
+ });
422
+ return server;
423
+ }
424
+
425
+ // src/home.ts
426
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
427
+ import { resolve as resolve4 } from "path";
428
+ var SESSION_NAME_RE = /^[\w.-]+$/;
429
+ function getHomeDir() {
430
+ return resolve4(process.env.UNLOVED_HOME ?? resolve4(process.env.HOME ?? "", ".unloved"));
431
+ }
432
+ async function ensureHome() {
433
+ const homeDir = getHomeDir();
434
+ await mkdir2(resolve4(homeDir, "sessions"), { recursive: true });
435
+ return homeDir;
436
+ }
437
+ function validateSessionName(session) {
438
+ if (!SESSION_NAME_RE.test(session)) {
439
+ console.error(`Error: Invalid session name "${session}". Use only letters, numbers, dots, hyphens, and underscores.`);
440
+ process.exit(1);
441
+ }
442
+ }
443
+ function getMetaPath2(homeDir, session) {
444
+ validateSessionName(session);
445
+ return resolve4(homeDir, "sessions", session, "meta.json");
446
+ }
447
+ async function readSessionMeta(homeDir, session) {
448
+ try {
449
+ const content = await readFile3(getMetaPath2(homeDir, session), "utf-8");
450
+ return JSON.parse(content);
451
+ } catch (error) {
452
+ const fileError = error;
453
+ if (fileError.code === "ENOENT") return {};
454
+ throw error;
455
+ }
456
+ }
457
+ async function writeSessionMeta(homeDir, session, meta) {
458
+ const dir = resolve4(homeDir, "sessions", session);
459
+ await mkdir2(dir, { recursive: true });
460
+ await writeFile3(resolve4(dir, "meta.json"), `${JSON.stringify(meta, null, 2)}
461
+ `, "utf-8");
462
+ }
463
+
464
+ // src/banner.ts
465
+ import { networkInterfaces } from "os";
466
+ function getLocalIP() {
467
+ const nets = networkInterfaces();
468
+ for (const name of Object.keys(nets)) {
469
+ for (const net of nets[name] ?? []) {
470
+ if (net.family === "IPv4" && !net.internal) {
471
+ return net.address;
472
+ }
473
+ }
474
+ }
475
+ return null;
476
+ }
477
+ function printBanner(port) {
478
+ const localIP = getLocalIP();
479
+ console.log();
480
+ console.log(" unloved is running");
481
+ console.log();
482
+ console.log(` Local: http://localhost:${port}`);
483
+ if (localIP) {
484
+ console.log(` Network: http://${localIP}:${port}`);
485
+ }
486
+ console.log();
487
+ }
488
+
489
+ // src/detect-session.ts
490
+ import { execFile as execFile3 } from "child_process";
491
+ import { promisify as promisify3 } from "util";
492
+ var execFileAsync3 = promisify3(execFile3);
493
+ async function detectTmuxSession() {
494
+ if (!process.env.TMUX) return null;
495
+ try {
496
+ const { stdout } = await execFileAsync3("tmux", ["display-message", "-p", "#{session_name}"]);
497
+ const name = stdout.trim();
498
+ return name || null;
499
+ } catch {
500
+ return null;
501
+ }
502
+ }
503
+
504
+ // src/sync.ts
505
+ import { existsSync } from "fs";
506
+ import { execFileSync as execFileSync2 } from "child_process";
507
+ import { resolve as resolve5 } from "path";
508
+ function syncRepo(homeDir) {
509
+ const repoDir = resolve5(homeDir, "repo");
510
+ const webDist = resolve5(repoDir, "packages", "web", "dist");
511
+ try {
512
+ if (!existsSync(repoDir)) {
513
+ console.log(" Cloning unloved repo...");
514
+ execFileSync2("git", ["clone", "https://github.com/UnlikeOtherAI/unloved.git", repoDir], {
515
+ stdio: "inherit"
516
+ });
517
+ } else {
518
+ console.log(" Pulling latest...");
519
+ execFileSync2("git", ["pull", "origin", "main"], {
520
+ cwd: repoDir,
521
+ stdio: "inherit"
522
+ });
523
+ }
524
+ console.log(" Installing dependencies...");
525
+ execFileSync2("pnpm", ["install", "--frozen-lockfile"], {
526
+ cwd: repoDir,
527
+ stdio: "inherit"
528
+ });
529
+ console.log(" Building...");
530
+ execFileSync2("pnpm", ["build"], {
531
+ cwd: repoDir,
532
+ stdio: "inherit"
533
+ });
534
+ if (existsSync(webDist)) {
535
+ return webDist;
536
+ }
537
+ console.warn(" Warning: build succeeded but web dist not found, using bundled assets");
538
+ return null;
539
+ } catch {
540
+ console.warn(" Warning: sync failed, falling back to bundled assets");
541
+ return null;
542
+ }
543
+ }
544
+
545
+ // src/open-browser.ts
546
+ import { execFile as execFile4 } from "child_process";
547
+ function openBrowser(url) {
548
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
549
+ const args2 = process.platform === "win32" ? ["", url] : [url];
550
+ const child = execFile4(cmd, args2, { stdio: "ignore" });
551
+ child.unref();
552
+ }
553
+
554
+ // src/commands/start.ts
555
+ function isPortInUse(port) {
556
+ return new Promise((resolve7) => {
557
+ const socket = createConnection({ port }, () => {
558
+ socket.destroy();
559
+ resolve7(true);
560
+ });
561
+ socket.on("error", () => {
562
+ resolve7(false);
563
+ });
564
+ });
565
+ }
566
+ async function startCommand(args2) {
567
+ const port = args2.flags.port ? Number(args2.flags.port) : 6200;
568
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
569
+ console.error("Error: --port must be a number between 1 and 65535");
570
+ process.exit(1);
571
+ }
572
+ const sessionName = args2.positional[0] ?? await detectTmuxSession() ?? void 0;
573
+ if (await isPortInUse(port)) {
574
+ console.log(`unloved is already running at http://localhost:${port}`);
575
+ process.exit(0);
576
+ }
577
+ const homeDir = await ensureHome();
578
+ const __dirname = dirname2(fileURLToPath2(import.meta.url));
579
+ const bundledDir = resolve6(__dirname, "..", "public");
580
+ let staticDir = bundledDir;
581
+ if (!args2.flags["no-sync"]) {
582
+ const syncedDir = syncRepo(homeDir);
583
+ if (syncedDir) {
584
+ staticDir = syncedDir;
585
+ }
586
+ }
587
+ startServer({ homeDir, staticDir, port });
588
+ printBanner(port);
589
+ if (!args2.flags["no-open"]) {
590
+ openBrowser(`http://localhost:${port}`);
591
+ }
592
+ if (sessionName) {
593
+ const meta = {
594
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
595
+ };
596
+ if (args2.flags.url && typeof args2.flags.url === "string") meta.previewUrl = args2.flags.url;
597
+ if (args2.flags.restart && typeof args2.flags.restart === "string")
598
+ meta.restartCommand = args2.flags.restart;
599
+ meta.projectDir = process.cwd();
600
+ await writeSessionMeta(homeDir, sessionName, meta);
601
+ console.log(` Session: ${sessionName}`);
602
+ }
603
+ }
604
+
605
+ // src/commands/restart.ts
606
+ import { execFile as execFile5 } from "child_process";
607
+ import { promisify as promisify4 } from "util";
608
+ var execFileAsync4 = promisify4(execFile5);
609
+ async function restartCommand(args2) {
610
+ const sessionName = args2.positional[0] ?? await detectTmuxSession();
611
+ if (!sessionName) {
612
+ console.error("Error: No session name provided and not in a tmux session");
613
+ process.exit(1);
614
+ }
615
+ const homeDir = getHomeDir();
616
+ const meta = await readSessionMeta(homeDir, sessionName);
617
+ if (!meta.restartCommand) {
618
+ console.error(`Error: No restart command configured for session "${sessionName}"`);
619
+ console.error('Set one with: unloved meta restartCommand="your command"');
620
+ process.exit(1);
621
+ }
622
+ console.log(`Restarting: ${meta.restartCommand}`);
623
+ try {
624
+ const { stdout, stderr } = await execFileAsync4("sh", ["-c", meta.restartCommand], {
625
+ cwd: meta.projectDir ?? process.cwd(),
626
+ env: process.env
627
+ });
628
+ if (stdout) process.stdout.write(stdout);
629
+ if (stderr) process.stderr.write(stderr);
630
+ } catch (error) {
631
+ const execError = error;
632
+ if (execError.stdout) process.stdout.write(execError.stdout);
633
+ if (execError.stderr) process.stderr.write(execError.stderr);
634
+ process.exit(1);
635
+ }
636
+ }
637
+
638
+ // src/commands/url.ts
639
+ async function urlCommand(args2) {
640
+ let sessionName;
641
+ let url;
642
+ if (args2.positional.length === 2) {
643
+ sessionName = args2.positional[0];
644
+ url = args2.positional[1];
645
+ } else if (args2.positional.length === 1) {
646
+ url = args2.positional[0];
647
+ sessionName = await detectTmuxSession();
648
+ }
649
+ if (!sessionName) {
650
+ console.error("Error: No session name provided and not in a tmux session");
651
+ process.exit(1);
652
+ }
653
+ if (!url) {
654
+ console.error("Usage: unloved url [session] <url>");
655
+ process.exit(1);
656
+ }
657
+ const homeDir = getHomeDir();
658
+ const meta = await readSessionMeta(homeDir, sessionName);
659
+ meta.previewUrl = url;
660
+ await writeSessionMeta(homeDir, sessionName, meta);
661
+ console.log(`Preview URL set to ${url} for session "${sessionName}"`);
662
+ }
663
+
664
+ // src/commands/meta.ts
665
+ var VALID_META_KEYS = /* @__PURE__ */ new Set(["previewUrl", "restartCommand", "projectDir", "cliTool", "createdAt"]);
666
+ async function metaCommand(args2) {
667
+ const assignments = [];
668
+ let sessionName;
669
+ for (const arg of args2.positional) {
670
+ if (arg.includes("=")) {
671
+ assignments.push(arg);
672
+ } else if (!sessionName) {
673
+ sessionName = arg;
674
+ }
675
+ }
676
+ if (!sessionName) {
677
+ sessionName = await detectTmuxSession();
678
+ }
679
+ if (!sessionName) {
680
+ console.error("Error: No session name provided and not in a tmux session");
681
+ process.exit(1);
682
+ }
683
+ const homeDir = getHomeDir();
684
+ const meta = await readSessionMeta(homeDir, sessionName);
685
+ if (assignments.length === 0) {
686
+ console.log(JSON.stringify(meta, null, 2));
687
+ return;
688
+ }
689
+ for (const assignment of assignments) {
690
+ const eqIndex = assignment.indexOf("=");
691
+ const key = assignment.slice(0, eqIndex);
692
+ const value = assignment.slice(eqIndex + 1);
693
+ if (!VALID_META_KEYS.has(key)) {
694
+ console.error(`Unknown meta key: ${key}`);
695
+ console.error(`Valid keys: ${[...VALID_META_KEYS].join(", ")}`);
696
+ process.exit(1);
697
+ }
698
+ ;
699
+ meta[key] = value;
700
+ }
701
+ await writeSessionMeta(homeDir, sessionName, meta);
702
+ console.log(JSON.stringify(meta, null, 2));
703
+ }
704
+
705
+ // src/commands/help.ts
706
+ var HUMAN_HELP = `
707
+ unloved \u2014 local multi-device AI coding cockpit
708
+
709
+ Usage:
710
+ unloved Auto-sync from GitHub, build, start server, open browser
711
+ unloved start [session] Start with optional session and flags
712
+ unloved restart [session] Run the session's restart command locally
713
+ unloved url [session] <url> Set the preview URL for a session
714
+ unloved meta [session] [key=val...]
715
+ unloved help [--llm]
716
+ unloved version
717
+
718
+ Flags:
719
+ --no-sync Skip pulling latest from GitHub (use bundled assets)
720
+ --no-open Skip opening the browser
721
+ --port <n> Server port (default: 6200)
722
+ --url <url> Set preview URL for the session
723
+ --restart <cmd> Set restart command for the session
724
+
725
+ Running \`unloved\` with no arguments syncs the latest code from GitHub,
726
+ installs dependencies, builds, then starts the server and opens your browser.
727
+ If sync fails, it falls back to the bundled assets from the installed package.
728
+
729
+ Session name is auto-detected from your current tmux session when omitted.
730
+ `.trim();
731
+ var LLM_HELP = `
732
+ UNLOVED SESSION PROTOCOL
733
+
734
+ You are running inside a tmux session managed by unloved, a local multi-device
735
+ AI coding cockpit. The user is monitoring your work from a web dashboard on
736
+ another device (phone, tablet, or second screen). The dashboard shows your
737
+ session status, a preview of the running app, and controls to restart your
738
+ dev server.
739
+
740
+ AVAILABLE COMMANDS
741
+
742
+ All commands auto-detect the session name from your current tmux session.
743
+ Run them directly in your shell \u2014 no HTTP calls needed.
744
+
745
+ unloved restart
746
+ Re-run the session's configured restart command (e.g. restart dev server).
747
+ Use this after making changes that require a server/build restart.
748
+
749
+ unloved url <url>
750
+ Update the preview URL shown in the dashboard.
751
+ Example: unloved url http://localhost:3000
752
+ The dashboard will load this URL in an iframe for the user to see.
753
+
754
+ unloved meta
755
+ Print current session metadata as JSON.
756
+ Shows: previewUrl, restartCommand, projectDir, cliTool, createdAt.
757
+
758
+ unloved meta <key>=<value> [key=value...]
759
+ Set session metadata fields.
760
+ Examples:
761
+ unloved meta previewUrl=http://localhost:3000
762
+ unloved meta restartCommand="pnpm dev"
763
+ unloved meta cliTool="claude-code"
764
+
765
+ WORKFLOW
766
+
767
+ 1. After making code changes that need a dev server restart:
768
+ $ unloved restart
769
+
770
+ 2. After starting a dev server on a new port:
771
+ $ unloved url http://localhost:<port>
772
+
773
+ 3. To let the user know which tool you are:
774
+ $ unloved meta cliTool="claude-code"
775
+
776
+ NOTES
777
+
778
+ - The session name is derived from your TMUX environment. Do not hardcode it.
779
+ - The restart command runs locally in your session's shell. It is whatever was
780
+ configured when the session was created (via --restart flag or meta command).
781
+ - The preview URL is displayed in an iframe on the dashboard. Make sure the
782
+ dev server binds to 0.0.0.0 (not just localhost) if the dashboard is on
783
+ another device.
784
+ - These commands write to ~/.unloved/sessions/<session>/meta.json. The web
785
+ dashboard polls this metadata to update the UI.
786
+ `.trim();
787
+ function helpCommand(args2) {
788
+ if (args2.flags.llm) {
789
+ console.log(LLM_HELP);
790
+ } else {
791
+ console.log(HUMAN_HELP);
792
+ }
793
+ }
794
+
795
+ // src/index.ts
796
+ var args = parseArgs(process.argv);
797
+ switch (args.command) {
798
+ case "start":
799
+ await startCommand(args);
800
+ break;
801
+ case "restart":
802
+ await restartCommand(args);
803
+ break;
804
+ case "url":
805
+ await urlCommand(args);
806
+ break;
807
+ case "meta":
808
+ await metaCommand(args);
809
+ break;
810
+ case "version":
811
+ console.log(await getVersion());
812
+ break;
813
+ case "help":
814
+ helpCommand(args);
815
+ break;
816
+ default:
817
+ console.error(`Unknown command: ${args.command}`);
818
+ helpCommand({ ...args, flags: {} });
819
+ process.exit(1);
820
+ }