@cryptiklemur/lattice 1.26.1 → 1.27.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.
@@ -16,6 +16,9 @@ jobs:
16
16
  name: Release
17
17
  runs-on: ubuntu-latest
18
18
  environment: npm
19
+ outputs:
20
+ new_release_published: ${{ steps.semantic.outputs.new_release_published }}
21
+ new_release_version: ${{ steps.semantic.outputs.new_release_version }}
19
22
  steps:
20
23
  - name: Checkout
21
24
  uses: actions/checkout@v5
@@ -39,6 +42,46 @@ jobs:
39
42
  run: bun run build
40
43
 
41
44
  - name: Release
45
+ id: semantic
42
46
  env:
43
47
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44
- run: npx semantic-release
48
+ run: |
49
+ npx semantic-release
50
+ TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
51
+ if [ -n "$TAG" ]; then
52
+ VERSION="${TAG#v}"
53
+ echo "new_release_published=true" >> $GITHUB_OUTPUT
54
+ echo "new_release_version=$VERSION" >> $GITHUB_OUTPUT
55
+ else
56
+ echo "new_release_published=false" >> $GITHUB_OUTPUT
57
+ fi
58
+
59
+ build-binaries:
60
+ name: Build ${{ matrix.target }}
61
+ needs: release
62
+ if: needs.release.outputs.new_release_published == 'true'
63
+ runs-on: ubuntu-latest
64
+ strategy:
65
+ matrix:
66
+ target: [linux-x64, linux-arm64, darwin-x64, darwin-arm64]
67
+ steps:
68
+ - name: Checkout
69
+ uses: actions/checkout@v5
70
+ with:
71
+ ref: v${{ needs.release.outputs.new_release_version }}
72
+
73
+ - name: Setup Bun
74
+ uses: oven-sh/setup-bun@v2
75
+ with:
76
+ bun-version: latest
77
+
78
+ - name: Install dependencies
79
+ run: bun install --frozen-lockfile
80
+
81
+ - name: Build binary
82
+ run: bun scripts/build-binary.ts --target=${{ matrix.target }} --version=${{ needs.release.outputs.new_release_version }}
83
+
84
+ - name: Upload to GitHub Release
85
+ env:
86
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
87
+ run: gh release upload "v${{ needs.release.outputs.new_release_version }}" "dist/lattice-${{ matrix.target }}" --clobber
@@ -1,11 +1,13 @@
1
- import { useState } from "react";
2
- import { Sun, Moon, Settings, Download } from "lucide-react";
1
+ import { useState, useEffect } from "react";
2
+ import { Sun, Moon, Settings, Download, ArrowUpCircle } from "lucide-react";
3
3
  import { useStore } from "@tanstack/react-store";
4
4
  import { useTheme } from "../../hooks/useTheme";
5
5
  import { useSidebar } from "../../hooks/useSidebar";
6
6
  import { useInstallPrompt } from "../../hooks/useInstallPrompt";
7
+ import { useWebSocket } from "../../hooks/useWebSocket";
7
8
  import { getSessionStore } from "../../stores/session";
8
9
  import pkg from "../../../package.json";
10
+ import type { ServerMessage } from "@lattice/shared";
9
11
 
10
12
  interface UserIslandProps {
11
13
  nodeName: string;
@@ -18,6 +20,20 @@ export function UserIsland(props: UserIslandProps) {
18
20
  var { canInstall, install } = useInstallPrompt();
19
21
  var budgetStatus = useStore(getSessionStore(), function (s) { return s.budgetStatus; });
20
22
  var [showTooltip, setShowTooltip] = useState(false);
23
+ var ws = useWebSocket();
24
+ var [updateAvailable, setUpdateAvailable] = useState(false);
25
+ var [latestVersion, setLatestVersion] = useState<string | null>(null);
26
+
27
+ useEffect(function () {
28
+ function handleUpdateStatus(msg: ServerMessage) {
29
+ if (msg.type !== "update:status") return;
30
+ var data = msg as { type: string; updateAvailable: boolean; latestVersion: string | null };
31
+ setUpdateAvailable(data.updateAvailable);
32
+ setLatestVersion(data.latestVersion);
33
+ }
34
+ ws.subscribe("update:status", handleUpdateStatus);
35
+ return function () { ws.unsubscribe("update:status", handleUpdateStatus); };
36
+ }, []);
21
37
 
22
38
  var initial = props.nodeName.charAt(0).toUpperCase();
23
39
 
@@ -72,8 +88,14 @@ export function UserIsland(props: UserIslandProps) {
72
88
  <div className="text-[13px] font-semibold text-base-content truncate">
73
89
  {props.nodeName}
74
90
  </div>
75
- <div className="text-[10px] text-base-content/30 font-mono">
76
- {"v" + pkg.version}
91
+ <div className="text-[10px] font-mono flex items-center gap-1">
92
+ <span className="text-base-content/30">{"v" + pkg.version}</span>
93
+ {updateAvailable && latestVersion && (
94
+ <span className="flex items-center gap-0.5 text-primary/70">
95
+ <ArrowUpCircle size={9} />
96
+ {latestVersion}
97
+ </span>
98
+ )}
77
99
  </div>
78
100
  </div>
79
101
  </button>
@@ -0,0 +1,110 @@
1
+ import { useState, useEffect } from "react";
2
+ import { ArrowUpCircle, X, Loader2, ExternalLink, RefreshCw } from "lucide-react";
3
+ import { useWebSocket } from "../../hooks/useWebSocket";
4
+ import type { ServerMessage } from "@lattice/shared";
5
+
6
+ interface UpdateState {
7
+ currentVersion: string;
8
+ latestVersion: string | null;
9
+ updateAvailable: boolean;
10
+ releaseUrl: string | null;
11
+ }
12
+
13
+ export function UpdateBanner() {
14
+ var { send, subscribe, unsubscribe } = useWebSocket();
15
+ var [update, setUpdate] = useState<UpdateState | null>(null);
16
+ var [dismissed, setDismissed] = useState(false);
17
+ var [applying, setApplying] = useState(false);
18
+ var [applyResult, setApplyResult] = useState<{ success: boolean; message: string } | null>(null);
19
+
20
+ useEffect(function () {
21
+ function handleStatus(msg: ServerMessage) {
22
+ if (msg.type !== "update:status") return;
23
+ var data = msg as UpdateState & { type: string };
24
+ setUpdate({ currentVersion: data.currentVersion, latestVersion: data.latestVersion, updateAvailable: data.updateAvailable, releaseUrl: data.releaseUrl });
25
+ if (data.updateAvailable) setDismissed(false);
26
+ }
27
+
28
+ function handleApplyResult(msg: ServerMessage) {
29
+ if (msg.type !== "update:apply_result") return;
30
+ var data = msg as { type: string; success: boolean; message?: string };
31
+ setApplying(false);
32
+ setApplyResult({ success: data.success, message: data.message ?? "" });
33
+ }
34
+
35
+ subscribe("update:status", handleStatus);
36
+ subscribe("update:apply_result", handleApplyResult);
37
+ send({ type: "update:check" } as any);
38
+
39
+ return function () {
40
+ unsubscribe("update:status", handleStatus);
41
+ unsubscribe("update:apply_result", handleApplyResult);
42
+ };
43
+ }, []);
44
+
45
+ if (!update || !update.updateAvailable || dismissed) return null;
46
+
47
+ if (applyResult) {
48
+ return (
49
+ <div className={
50
+ "flex items-center gap-2 px-4 py-2 text-[12px] border-b " +
51
+ (applyResult.success
52
+ ? "bg-success/10 border-success/20 text-success"
53
+ : "bg-error/10 border-error/20 text-error")
54
+ }>
55
+ <span className="flex-1">{applyResult.message}</span>
56
+ {applyResult.success && (
57
+ <button
58
+ onClick={function () { window.location.reload(); }}
59
+ className="flex items-center gap-1 text-[11px] font-mono font-bold hover:underline"
60
+ >
61
+ <RefreshCw size={11} />
62
+ Reload
63
+ </button>
64
+ )}
65
+ <button onClick={function () { setDismissed(true); }} className="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100">
66
+ <X size={12} />
67
+ </button>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ return (
73
+ <div className="flex items-center gap-2 px-4 py-2 bg-primary/8 border-b border-primary/15 text-[12px] text-base-content/70">
74
+ <ArrowUpCircle size={14} className="text-primary flex-shrink-0" />
75
+ <span className="flex-1">
76
+ <span className="font-semibold text-base-content">v{update.latestVersion}</span>
77
+ {" "}available
78
+ <span className="text-base-content/40 ml-1">(current: v{update.currentVersion})</span>
79
+ </span>
80
+ {update.releaseUrl && (
81
+ <a
82
+ href={update.releaseUrl}
83
+ target="_blank"
84
+ rel="noopener noreferrer"
85
+ className="flex items-center gap-0.5 text-[11px] text-primary/50 hover:text-primary transition-colors"
86
+ >
87
+ <ExternalLink size={10} />
88
+ Notes
89
+ </a>
90
+ )}
91
+ {applying ? (
92
+ <Loader2 size={12} className="text-primary animate-spin" />
93
+ ) : (
94
+ <button
95
+ onClick={function () { setApplying(true); send({ type: "update:apply" } as any); }}
96
+ className="btn btn-primary btn-xs"
97
+ >
98
+ Update
99
+ </button>
100
+ )}
101
+ <button
102
+ onClick={function () { setDismissed(true); }}
103
+ aria-label="Dismiss update"
104
+ className="btn btn-ghost btn-xs btn-square text-base-content/30 hover:text-base-content"
105
+ >
106
+ <X size={12} />
107
+ </button>
108
+ </div>
109
+ );
110
+ }
@@ -15,6 +15,7 @@ import { AddProjectModal } from "./components/sidebar/AddProjectModal";
15
15
  import { useSidebar } from "./hooks/useSidebar";
16
16
  import { useWorkspace } from "./hooks/useWorkspace";
17
17
  import { useWebSocket } from "./hooks/useWebSocket";
18
+ import { UpdateBanner } from "./components/ui/UpdateBanner";
18
19
  import { useSwipeDrawer } from "./hooks/useSwipeDrawer";
19
20
  import { exitSettings, getSidebarStore, handlePopState, closeDrawer, toggleDrawer } from "./stores/sidebar";
20
21
 
@@ -422,6 +423,7 @@ function RootLayout() {
422
423
  />
423
424
 
424
425
  <main id="main-content" className="drawer-content flex flex-col h-full min-w-0 overflow-hidden">
426
+ <UpdateBanner />
425
427
  <Outlet />
426
428
  </main>
427
429
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.26.1",
3
+ "version": "1.27.0",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",
@@ -29,7 +29,9 @@
29
29
  "dev": "bun run --filter '*' dev",
30
30
  "build": "bun run --filter '*' build",
31
31
  "lint": "bun run --filter '*' lint",
32
- "typecheck": "bunx tsc --noEmit -p tsconfig.json"
32
+ "typecheck": "bunx tsc --noEmit -p tsconfig.json",
33
+ "build:binary": "bun scripts/build-binary.ts",
34
+ "build:binary:all": "bun scripts/build-binary.ts --all"
33
35
  },
34
36
  "workspaces": [
35
37
  "shared",
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bun
2
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
3
+ import { join, extname, relative } from "node:path";
4
+ import { execSync } from "node:child_process";
5
+
6
+ var CONTENT_TYPES: Record<string, string> = {
7
+ ".html": "text/html; charset=utf-8",
8
+ ".js": "application/javascript",
9
+ ".css": "text/css",
10
+ ".json": "application/json",
11
+ ".svg": "image/svg+xml",
12
+ ".png": "image/png",
13
+ ".ico": "image/x-icon",
14
+ ".webmanifest": "application/manifest+json",
15
+ ".woff": "font/woff",
16
+ ".woff2": "font/woff2",
17
+ ".ttf": "font/ttf",
18
+ ".txt": "text/plain",
19
+ ".map": "application/json",
20
+ ".wasm": "application/wasm",
21
+ };
22
+
23
+ var VALID_TARGETS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"];
24
+
25
+ var ROOT = join(import.meta.dir, "..");
26
+
27
+ function parseArgs(): { targets: string[]; version: string } {
28
+ var args = process.argv.slice(2);
29
+ var targets: string[] = [];
30
+ var version = "";
31
+
32
+ for (var i = 0; i < args.length; i++) {
33
+ if (args[i] === "--target" && i + 1 < args.length) {
34
+ targets.push(args[i + 1]);
35
+ i++;
36
+ } else if (args[i].startsWith("--target=")) {
37
+ targets.push(args[i].split("=")[1]);
38
+ } else if (args[i] === "--version" && i + 1 < args.length) {
39
+ version = args[i + 1];
40
+ i++;
41
+ } else if (args[i].startsWith("--version=")) {
42
+ version = args[i].split("=")[1];
43
+ } else if (args[i] === "--all") {
44
+ targets = [...VALID_TARGETS];
45
+ }
46
+ }
47
+
48
+ if (targets.length === 0) {
49
+ var platform = process.platform === "darwin" ? "darwin" : "linux";
50
+ var arch = process.arch === "arm64" ? "arm64" : "x64";
51
+ targets = [platform + "-" + arch];
52
+ }
53
+
54
+ if (!version) {
55
+ var pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
56
+ version = pkg.version || "0.0.0-dev";
57
+ }
58
+
59
+ for (var t = 0; t < targets.length; t++) {
60
+ if (VALID_TARGETS.indexOf(targets[t]) === -1) {
61
+ console.error("Invalid target: " + targets[t]);
62
+ console.error("Valid targets: " + VALID_TARGETS.join(", "));
63
+ process.exit(1);
64
+ }
65
+ }
66
+
67
+ return { targets, version };
68
+ }
69
+
70
+ function walkDir(dir: string, base: string): Array<{ path: string; relativePath: string }> {
71
+ var results: Array<{ path: string; relativePath: string }> = [];
72
+ var entries = readdirSync(dir, { withFileTypes: true });
73
+ for (var i = 0; i < entries.length; i++) {
74
+ var fullPath = join(dir, entries[i].name);
75
+ if (entries[i].isDirectory()) {
76
+ results = results.concat(walkDir(fullPath, base));
77
+ } else {
78
+ results.push({ path: fullPath, relativePath: "/" + relative(base, fullPath) });
79
+ }
80
+ }
81
+ return results;
82
+ }
83
+
84
+ function generateEmbeddedAssets(clientDistDir: string): void {
85
+ console.log("[build] Generating embedded assets...");
86
+ var files = walkDir(clientDistDir, clientDistDir);
87
+ var genDir = join(ROOT, "server/src/_generated");
88
+ mkdirSync(genDir, { recursive: true });
89
+
90
+ var lines: string[] = [];
91
+ lines.push("var assets = new Map<string, { b64: string; type: string }>();");
92
+
93
+ var totalSize = 0;
94
+ for (var i = 0; i < files.length; i++) {
95
+ var file = files[i];
96
+ var ext = extname(file.path);
97
+ var contentType = CONTENT_TYPES[ext] || "application/octet-stream";
98
+ var data = readFileSync(file.path);
99
+ var b64 = data.toString("base64");
100
+ totalSize += data.length;
101
+ lines.push("assets.set(" + JSON.stringify(file.relativePath) + ", { b64: " + JSON.stringify(b64) + ", type: " + JSON.stringify(contentType) + " });");
102
+ }
103
+
104
+ lines.push("export { assets };");
105
+
106
+ writeFileSync(join(genDir, "embedded-assets.ts"), lines.join("\n"), "utf-8");
107
+ console.log("[build] Embedded " + files.length + " files (" + (totalSize / 1024 / 1024).toFixed(1) + " MB)");
108
+ }
109
+
110
+ function buildClient(): void {
111
+ console.log("[build] Building client...");
112
+ execSync("bun run --filter @lattice/client build", { cwd: ROOT, stdio: "inherit" });
113
+ }
114
+
115
+ function compileBinary(target: string, version: string): void {
116
+ var outDir = join(ROOT, "dist");
117
+ mkdirSync(outDir, { recursive: true });
118
+ var outFile = join(outDir, "lattice-" + target);
119
+
120
+ console.log("[build] Compiling for " + target + "...");
121
+ var cmd = [
122
+ "bun", "build", "--compile",
123
+ "--target=bun-" + target,
124
+ "--outfile=" + outFile,
125
+ "--define=process.env.LATTICE_VERSION=\"'" + version + "'\"",
126
+ join(ROOT, "server/src/index.ts"),
127
+ ].join(" ");
128
+
129
+ execSync(cmd, { cwd: ROOT, stdio: "inherit" });
130
+
131
+ var size = statSync(outFile).size;
132
+ console.log("[build] Output: " + outFile + " (" + (size / 1024 / 1024).toFixed(1) + " MB)");
133
+ }
134
+
135
+ function main(): void {
136
+ var { targets, version } = parseArgs();
137
+ console.log("[build] Version: " + version);
138
+ console.log("[build] Targets: " + targets.join(", "));
139
+
140
+ buildClient();
141
+
142
+ var clientDist = join(ROOT, "client/dist");
143
+ if (!existsSync(clientDist)) {
144
+ console.error("[build] client/dist not found after build");
145
+ process.exit(1);
146
+ }
147
+
148
+ generateEmbeddedAssets(clientDist);
149
+
150
+ for (var i = 0; i < targets.length; i++) {
151
+ compileBinary(targets[i], version);
152
+ }
153
+
154
+ console.log("[build] Done.");
155
+ }
156
+
157
+ main();
@@ -0,0 +1,69 @@
1
+ import { join } from "node:path";
2
+ import { IS_COMPILED } from "./runtime";
3
+
4
+ var CONTENT_TYPES: Record<string, string> = {
5
+ ".html": "text/html; charset=utf-8",
6
+ ".js": "application/javascript",
7
+ ".css": "text/css",
8
+ ".json": "application/json",
9
+ ".svg": "image/svg+xml",
10
+ ".png": "image/png",
11
+ ".ico": "image/x-icon",
12
+ ".webmanifest": "application/manifest+json",
13
+ ".woff": "font/woff",
14
+ ".woff2": "font/woff2",
15
+ ".ttf": "font/ttf",
16
+ ".txt": "text/plain",
17
+ ".map": "application/json",
18
+ ".wasm": "application/wasm",
19
+ };
20
+
21
+ interface EmbeddedAssetModule {
22
+ assets: Map<string, { b64: string; type: string }>;
23
+ }
24
+
25
+ var embeddedAssets: Map<string, { b64: string; type: string }> | null = null;
26
+ var assetCache = new Map<string, Uint8Array>();
27
+
28
+ export async function initAssets(): Promise<void> {
29
+ if (!IS_COMPILED) return;
30
+ try {
31
+ var mod = await import("./_generated/embedded-assets") as EmbeddedAssetModule;
32
+ embeddedAssets = mod.assets;
33
+ } catch {}
34
+ }
35
+
36
+ export function serveStaticAsset(pathname: string): Response | null {
37
+ if (!embeddedAssets) return null;
38
+
39
+ var entry = embeddedAssets.get(pathname);
40
+ if (!entry) return null;
41
+
42
+ var cached = assetCache.get(pathname);
43
+ if (!cached) {
44
+ cached = Buffer.from(entry.b64, "base64") as unknown as Uint8Array;
45
+ assetCache.set(pathname, cached);
46
+ }
47
+
48
+ return new Response(cached as unknown as BodyInit, {
49
+ headers: {
50
+ "Content-Type": entry.type,
51
+ "Cache-Control": pathname === "/index.html" || pathname === "/sw.js"
52
+ ? "no-cache"
53
+ : "public, max-age=31536000, immutable",
54
+ },
55
+ });
56
+ }
57
+
58
+ export function hasEmbeddedAssets(): boolean {
59
+ return embeddedAssets !== null;
60
+ }
61
+
62
+ export function getClientDir(): string {
63
+ return join(import.meta.dir, "../../client/dist");
64
+ }
65
+
66
+ export function guessContentType(path: string): string {
67
+ var ext = path.slice(path.lastIndexOf("."));
68
+ return CONTENT_TYPES[ext] || "application/octet-stream";
69
+ }
@@ -1,4 +1,5 @@
1
1
  import { join, resolve } from "node:path";
2
+ import { initAssets, serveStaticAsset, hasEmbeddedAssets, getClientDir } from "./assets";
2
3
  import { readFileSync, existsSync } from "node:fs";
3
4
  import type { ServerWebSocket } from "bun";
4
5
  import { getLatticeHome, loadConfig } from "./config";
@@ -35,8 +36,10 @@ import "./handlers/editor";
35
36
  import "./handlers/analytics";
36
37
  import "./handlers/bookmarks";
37
38
  import "./handlers/plugins";
39
+ import "./handlers/update";
38
40
  import { startScheduler } from "./features/scheduler";
39
41
  import { loadNotes } from "./features/sticky-notes";
42
+ import { startPeriodicUpdateCheck, getCachedUpdateInfo } from "./update-checker";
40
43
  import { loadBookmarks } from "./project/bookmarks";
41
44
  import { cleanupClientTerminals } from "./handlers/terminal";
42
45
  import { cleanupClient as cleanupClientAttachments } from "./handlers/attachment";
@@ -202,7 +205,8 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
202
205
  log.server("Node: %s (%s)", config.name, identity.id);
203
206
  log.server("Home: %s", getLatticeHome());
204
207
 
205
- var clientDir = join(import.meta.dir, "../../client/dist");
208
+ await initAssets();
209
+ var clientDir = getClientDir();
206
210
 
207
211
  var tlsOptions: { cert: Buffer; key: Buffer } | undefined;
208
212
  if (config.tls) {
@@ -286,6 +290,15 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
286
290
  }
287
291
 
288
292
  var staticPath = url.pathname === "/" ? "/index.html" : url.pathname;
293
+
294
+ if (hasEmbeddedAssets()) {
295
+ var embedded = serveStaticAsset(staticPath);
296
+ if (embedded) return embedded;
297
+ var embeddedIndex = serveStaticAsset("/index.html");
298
+ if (embeddedIndex) return embeddedIndex;
299
+ return new Response("Not found", { status: 404 });
300
+ }
301
+
289
302
  var file = Bun.file(join(clientDir, staticPath));
290
303
  if (await file.exists()) {
291
304
  return new Response(file);
@@ -355,6 +368,8 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
355
368
  loadNotes();
356
369
  loadBookmarks();
357
370
 
371
+ startPeriodicUpdateCheck();
372
+
358
373
  loadInterruptedSessions();
359
374
 
360
375
  onPeerConnected(function (nodeId: string) {
@@ -386,5 +401,16 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
386
401
  return { slug: p.slug, path: p.path, title: p.title, nodeId: currentIdentity.id, nodeName: currentConfig.name, isRemote: false, ideProjectName: detectIdeProjectName(p.path) };
387
402
  }),
388
403
  });
404
+ var updateInfo = getCachedUpdateInfo();
405
+ if (updateInfo && updateInfo.updateAvailable) {
406
+ broadcast({
407
+ type: "update:status",
408
+ currentVersion: updateInfo.currentVersion,
409
+ latestVersion: updateInfo.latestVersion,
410
+ updateAvailable: updateInfo.updateAvailable,
411
+ releaseUrl: updateInfo.releaseUrl,
412
+ installMode: updateInfo.installMode,
413
+ });
414
+ }
389
415
  }, 10000);
390
416
  }
@@ -0,0 +1,127 @@
1
+ import { chmodSync, renameSync, writeFileSync } from "node:fs";
2
+ import type { ClientMessage } from "@lattice/shared";
3
+ import { registerHandler } from "../ws/router";
4
+ import { sendTo, broadcast } from "../ws/broadcast";
5
+ import { checkForUpdate, getPackageName, getGitHubRepo, getInstallMode } from "../update-checker";
6
+ import { IS_COMPILED } from "../runtime";
7
+
8
+ function getAssetName(): string {
9
+ var platform = process.platform === "darwin" ? "darwin" : "linux";
10
+ var arch = process.arch === "arm64" ? "arm64" : "x64";
11
+ return "lattice-" + platform + "-" + arch;
12
+ }
13
+
14
+ async function downloadBinaryUpdate(): Promise<{ success: boolean; message: string }> {
15
+ var repo = getGitHubRepo();
16
+ var assetName = getAssetName();
17
+
18
+ try {
19
+ var releaseRes = await fetch("https://api.github.com/repos/" + repo + "/releases/latest", {
20
+ headers: { "Accept": "application/vnd.github.v3+json" },
21
+ signal: AbortSignal.timeout(30000),
22
+ });
23
+
24
+ if (!releaseRes.ok) {
25
+ return { success: false, message: "Failed to fetch release info (HTTP " + releaseRes.status + ")" };
26
+ }
27
+
28
+ var release = await releaseRes.json() as { assets?: Array<{ name: string; browser_download_url: string }> };
29
+ var assets = release.assets ?? [];
30
+ var asset = assets.find(function (a) { return a.name === assetName; });
31
+
32
+ if (!asset) {
33
+ return { success: false, message: "No binary found for " + assetName + " in latest release" };
34
+ }
35
+
36
+ var downloadRes = await fetch(asset.browser_download_url, {
37
+ signal: AbortSignal.timeout(120000),
38
+ });
39
+
40
+ if (!downloadRes.ok) {
41
+ return { success: false, message: "Failed to download binary (HTTP " + downloadRes.status + ")" };
42
+ }
43
+
44
+ var binary = new Uint8Array(await downloadRes.arrayBuffer());
45
+ var execPath = process.execPath;
46
+ var tmpPath = execPath + ".update";
47
+
48
+ writeFileSync(tmpPath, binary);
49
+ chmodSync(tmpPath, 0o755);
50
+ renameSync(tmpPath, execPath);
51
+
52
+ return { success: true, message: "Updated successfully. Restart the server to apply." };
53
+ } catch (err) {
54
+ return { success: false, message: "Update failed: " + (err instanceof Error ? err.message : String(err)) };
55
+ }
56
+ }
57
+
58
+ registerHandler("update", function (clientId: string, message: ClientMessage) {
59
+ if (message.type === "update:check") {
60
+ var checkMsg = message as { type: "update:check"; force?: boolean };
61
+ void checkForUpdate(checkMsg.force ?? false).then(function (info) {
62
+ sendTo(clientId, {
63
+ type: "update:status",
64
+ currentVersion: info.currentVersion,
65
+ latestVersion: info.latestVersion,
66
+ updateAvailable: info.updateAvailable,
67
+ releaseUrl: info.releaseUrl,
68
+ installMode: info.installMode,
69
+ });
70
+ });
71
+ return;
72
+ }
73
+
74
+ if (message.type === "update:apply") {
75
+ if (IS_COMPILED) {
76
+ void downloadBinaryUpdate().then(function (result) {
77
+ sendTo(clientId, { type: "update:apply_result", success: result.success, message: result.message });
78
+ if (result.success) {
79
+ void checkForUpdate(true).then(function (info) {
80
+ broadcast({
81
+ type: "update:status",
82
+ currentVersion: info.currentVersion,
83
+ latestVersion: info.latestVersion,
84
+ updateAvailable: info.updateAvailable,
85
+ releaseUrl: info.releaseUrl,
86
+ installMode: info.installMode,
87
+ });
88
+ });
89
+ }
90
+ });
91
+ } else {
92
+ var pkgName = getPackageName();
93
+ try {
94
+ var proc = Bun.spawn(["bun", "install", "-g", pkgName + "@latest"], {
95
+ stdout: "pipe",
96
+ stderr: "pipe",
97
+ });
98
+
99
+ var timeout = setTimeout(function () {
100
+ proc.kill();
101
+ }, 120000);
102
+
103
+ void proc.exited.then(function (code) {
104
+ clearTimeout(timeout);
105
+ if (code === 0) {
106
+ sendTo(clientId, { type: "update:apply_result", success: true, message: "Updated successfully. Restart the server to apply." });
107
+ void checkForUpdate(true).then(function (info) {
108
+ broadcast({
109
+ type: "update:status",
110
+ currentVersion: info.currentVersion,
111
+ latestVersion: info.latestVersion,
112
+ updateAvailable: info.updateAvailable,
113
+ releaseUrl: info.releaseUrl,
114
+ installMode: info.installMode,
115
+ });
116
+ });
117
+ } else {
118
+ sendTo(clientId, { type: "update:apply_result", success: false, message: "Update failed (exit code " + code + ")" });
119
+ }
120
+ });
121
+ } catch (err) {
122
+ sendTo(clientId, { type: "update:apply_result", success: false, message: "Failed to start update: " + String(err) });
123
+ }
124
+ }
125
+ return;
126
+ }
127
+ });
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { DAEMON_PID_FILE } from "@lattice/shared";
5
5
  import { getLatticeHome, loadConfig } from "./config";
6
+ import { IS_COMPILED } from "./runtime";
6
7
 
7
8
  var args = process.argv.slice(2);
8
9
  var command = "start";
@@ -74,9 +75,12 @@ switch (command) {
74
75
  case "status":
75
76
  runStatus();
76
77
  break;
78
+ case "update":
79
+ await runUpdate();
80
+ break;
77
81
  default:
78
82
  console.log("[lattice] Unknown command: " + command);
79
- console.log("[lattice] Usage: lattice [start|stop|status|daemon]");
83
+ console.log("[lattice] Usage: lattice [start|stop|status|update|daemon]");
80
84
  process.exit(1);
81
85
  }
82
86
 
@@ -125,10 +129,13 @@ async function runStart(): Promise<void> {
125
129
 
126
130
  removePid();
127
131
 
128
- var scriptPath = import.meta.path;
129
132
  var logPath = join(getLatticeHome(), "daemon.log");
130
133
 
131
- var child = Bun.spawn(["bun", scriptPath, "daemon"], {
134
+ var spawnArgs = IS_COMPILED
135
+ ? [process.execPath, "daemon"]
136
+ : ["bun", import.meta.path, "daemon"];
137
+
138
+ var child = Bun.spawn(spawnArgs, {
132
139
  detached: true,
133
140
  stdio: ["ignore", Bun.file(logPath), Bun.file(logPath)],
134
141
  });
@@ -194,6 +201,96 @@ function runStatus(): void {
194
201
  console.log("[lattice] URL: " + url);
195
202
  }
196
203
 
204
+ async function runUpdate(): Promise<void> {
205
+ var { checkForUpdate, getPackageName, getGitHubRepo } = await import("./update-checker");
206
+ console.log("[lattice] Checking for updates...");
207
+ var info = await checkForUpdate(true);
208
+
209
+ if (!info.latestVersion) {
210
+ console.log("[lattice] Could not check for updates. Try again later.");
211
+ process.exit(1);
212
+ }
213
+
214
+ if (!info.updateAvailable) {
215
+ console.log("[lattice] Already on latest version (%s)", info.currentVersion);
216
+ process.exit(0);
217
+ }
218
+
219
+ console.log("[lattice] Update available: %s -> %s (%s)", info.currentVersion, info.latestVersion, info.installMode);
220
+ console.log("[lattice] Installing...");
221
+
222
+ var code: number;
223
+
224
+ if (IS_COMPILED) {
225
+ var { chmodSync, renameSync, writeFileSync } = await import("node:fs");
226
+ var repo = getGitHubRepo();
227
+ var platform = process.platform === "darwin" ? "darwin" : "linux";
228
+ var arch = process.arch === "arm64" ? "arm64" : "x64";
229
+ var assetName = "lattice-" + platform + "-" + arch;
230
+
231
+ try {
232
+ var releaseRes = await fetch("https://api.github.com/repos/" + repo + "/releases/latest", {
233
+ headers: { "Accept": "application/vnd.github.v3+json" },
234
+ });
235
+ var release = await releaseRes.json() as { assets?: Array<{ name: string; browser_download_url: string }> };
236
+ var asset = (release.assets ?? []).find(function (a) { return a.name === assetName; });
237
+
238
+ if (!asset) {
239
+ console.error("[lattice] No binary found for " + assetName);
240
+ process.exit(1);
241
+ }
242
+
243
+ console.log("[lattice] Downloading " + assetName + "...");
244
+ var downloadRes = await fetch(asset.browser_download_url);
245
+ var binary = new Uint8Array(await downloadRes.arrayBuffer());
246
+ var tmpPath = process.execPath + ".update";
247
+ writeFileSync(tmpPath, binary);
248
+ chmodSync(tmpPath, 0o755);
249
+ renameSync(tmpPath, process.execPath);
250
+ code = 0;
251
+ } catch (err) {
252
+ console.error("[lattice] Download failed:", err instanceof Error ? err.message : String(err));
253
+ code = 1;
254
+ }
255
+ } else {
256
+ var pkgName = getPackageName();
257
+ var proc = Bun.spawn(["bun", "install", "-g", pkgName + "@latest"], {
258
+ stdout: "inherit",
259
+ stderr: "inherit",
260
+ });
261
+ code = await proc.exited;
262
+ }
263
+
264
+ if (code === 0) {
265
+ console.log("[lattice] Updated to %s", info.latestVersion);
266
+
267
+ var pid = readPid();
268
+ if (pid !== null && isDaemonRunning(pid)) {
269
+ console.log("[lattice] Restarting daemon...");
270
+ try {
271
+ process.kill(pid, "SIGTERM");
272
+ } catch {}
273
+ removePid();
274
+ await new Promise<void>(function (resolve) { setTimeout(resolve, 1000); });
275
+
276
+ var logPath = join(getLatticeHome(), "daemon.log");
277
+ var restartArgs = IS_COMPILED
278
+ ? [process.execPath, "daemon"]
279
+ : ["bun", import.meta.path, "daemon"];
280
+ var child = Bun.spawn(restartArgs, {
281
+ detached: true,
282
+ stdio: ["ignore", Bun.file(logPath), Bun.file(logPath)],
283
+ });
284
+ child.unref();
285
+ writePid(child.pid);
286
+ console.log("[lattice] Daemon restarted (PID %d)", child.pid);
287
+ }
288
+ } else {
289
+ console.error("[lattice] Update failed (exit code %d)", code);
290
+ process.exit(1);
291
+ }
292
+ }
293
+
197
294
  function openBrowser(url: string): void {
198
295
  var platform = process.platform;
199
296
  try {
@@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import type { ChildProcess } from "node:child_process";
4
4
  import { join } from "node:path";
5
+ import { existsSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { IS_COMPILED } from "../runtime";
5
8
 
6
9
  interface TerminalWorker {
7
10
  process: ChildProcess;
@@ -10,21 +13,33 @@ interface TerminalWorker {
10
13
 
11
14
  var terminals = new Map<string, TerminalWorker>();
12
15
 
13
- // node-pty doesn't work under Bun (child gets SIGHUP immediately).
14
- // We run it in a Node.js subprocess instead, communicating via JSON over stdio.
15
- var WORKER_PATH = join(import.meta.dir, "pty-worker.cjs");
16
+ function getWorkerPath(): string {
17
+ if (!IS_COMPILED) {
18
+ return join(import.meta.dir, "pty-worker.cjs");
19
+ }
20
+ var extractedPath = join(homedir(), ".lattice", "pty-worker.cjs");
21
+ if (!existsSync(extractedPath)) {
22
+ var srcPath = join(import.meta.dir, "pty-worker.cjs");
23
+ if (existsSync(srcPath)) {
24
+ mkdirSync(join(homedir(), ".lattice"), { recursive: true });
25
+ writeFileSync(extractedPath, readFileSync(srcPath));
26
+ }
27
+ }
28
+ return extractedPath;
29
+ }
30
+
31
+ var WORKER_PATH = getWorkerPath();
16
32
 
17
- // node-pty lives in Bun's module cache — resolve the path so Node can find it.
18
33
  var NODE_MODULES_PATH = (function () {
19
- // Bun can resolve the module — use it to find the actual path
20
34
  try {
21
35
  var resolved = require.resolve("node-pty");
22
- // resolved = .../node_modules/.bun/node-pty@X.Y.Z/node_modules/node-pty/lib/index.js
23
- // We need: .../node_modules/.bun/node-pty@X.Y.Z/node_modules
24
36
  var parts = resolved.split("/node_modules/");
25
- parts.pop(); // remove node-pty/lib/index.js
37
+ parts.pop();
26
38
  return parts.join("/node_modules/") + "/node_modules";
27
39
  } catch {
40
+ if (IS_COMPILED) {
41
+ return "";
42
+ }
28
43
  return join(import.meta.dir, "..", "..", "..", "node_modules");
29
44
  }
30
45
  })();
@@ -0,0 +1,4 @@
1
+ export var IS_COMPILED = typeof Bun !== "undefined"
2
+ && !process.argv[0].endsWith("/bun")
3
+ && !process.argv[0].endsWith("\\bun.exe")
4
+ && !process.argv[0].includes("node_modules");
@@ -0,0 +1,146 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { log } from "./logger";
4
+ import { IS_COMPILED } from "./runtime";
5
+
6
+ var PKG_NAME = "@cryptiklemur/lattice";
7
+ var GITHUB_REPO = "cryptiklemur/lattice";
8
+ var CHECK_INTERVAL_MS = 3600000;
9
+
10
+ export type InstallMode = "binary" | "npm";
11
+
12
+ export interface UpdateInfo {
13
+ currentVersion: string;
14
+ latestVersion: string | null;
15
+ updateAvailable: boolean;
16
+ lastCheckedAt: number;
17
+ releaseUrl: string | null;
18
+ installMode: InstallMode;
19
+ }
20
+
21
+ var cached: UpdateInfo | null = null;
22
+ var checking = false;
23
+
24
+ function getCurrentVersion(): string {
25
+ if (process.env.LATTICE_VERSION) return process.env.LATTICE_VERSION;
26
+ try {
27
+ var pkg = JSON.parse(readFileSync(join(import.meta.dir, "../../package.json"), "utf-8"));
28
+ return pkg.version || "0.0.0";
29
+ } catch {
30
+ return "0.0.0";
31
+ }
32
+ }
33
+
34
+ function compareVersions(a: string, b: string): number {
35
+ var pa = a.replace(/^v/, "").split(".").map(Number);
36
+ var pb = b.replace(/^v/, "").split(".").map(Number);
37
+ for (var i = 0; i < 3; i++) {
38
+ var va = pa[i] || 0;
39
+ var vb = pb[i] || 0;
40
+ if (va !== vb) return va - vb;
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ export function getInstallMode(): InstallMode {
46
+ return IS_COMPILED ? "binary" : "npm";
47
+ }
48
+
49
+ async function checkGitHub(currentVersion: string): Promise<UpdateInfo> {
50
+ var res = await fetch("https://api.github.com/repos/" + GITHUB_REPO + "/releases/latest", {
51
+ headers: { "Accept": "application/vnd.github.v3+json" },
52
+ signal: AbortSignal.timeout(10000),
53
+ });
54
+
55
+ if (!res.ok) {
56
+ log.server("GitHub update check failed: HTTP %d", res.status);
57
+ return { currentVersion, latestVersion: null, updateAvailable: false, lastCheckedAt: Date.now(), releaseUrl: null, installMode: "binary" };
58
+ }
59
+
60
+ var data = await res.json() as { tag_name?: string; html_url?: string };
61
+ var latestVersion = data.tag_name ? data.tag_name.replace(/^v/, "") : null;
62
+ var updateAvailable = latestVersion !== null && compareVersions(latestVersion, currentVersion) > 0;
63
+
64
+ return {
65
+ currentVersion,
66
+ latestVersion,
67
+ updateAvailable,
68
+ lastCheckedAt: Date.now(),
69
+ releaseUrl: updateAvailable ? (data.html_url ?? null) : null,
70
+ installMode: "binary",
71
+ };
72
+ }
73
+
74
+ async function checkNpm(currentVersion: string): Promise<UpdateInfo> {
75
+ var res = await fetch("https://registry.npmjs.org/" + PKG_NAME + "/latest", {
76
+ headers: { "Accept": "application/json" },
77
+ signal: AbortSignal.timeout(10000),
78
+ });
79
+
80
+ if (!res.ok) {
81
+ log.server("npm update check failed: HTTP %d", res.status);
82
+ return { currentVersion, latestVersion: null, updateAvailable: false, lastCheckedAt: Date.now(), releaseUrl: null, installMode: "npm" };
83
+ }
84
+
85
+ var data = await res.json() as { version?: string };
86
+ var latestVersion = data.version ?? null;
87
+ var updateAvailable = latestVersion !== null && compareVersions(latestVersion, currentVersion) > 0;
88
+
89
+ return {
90
+ currentVersion,
91
+ latestVersion,
92
+ updateAvailable,
93
+ lastCheckedAt: Date.now(),
94
+ releaseUrl: updateAvailable ? "https://github.com/" + GITHUB_REPO + "/releases/tag/v" + latestVersion : null,
95
+ installMode: "npm",
96
+ };
97
+ }
98
+
99
+ export async function checkForUpdate(force: boolean = false): Promise<UpdateInfo> {
100
+ var currentVersion = getCurrentVersion();
101
+
102
+ if (!force && cached && Date.now() - cached.lastCheckedAt < CHECK_INTERVAL_MS) {
103
+ return cached;
104
+ }
105
+
106
+ if (checking) {
107
+ return cached ?? { currentVersion, latestVersion: null, updateAvailable: false, lastCheckedAt: 0, releaseUrl: null, installMode: getInstallMode() };
108
+ }
109
+
110
+ checking = true;
111
+ try {
112
+ cached = IS_COMPILED
113
+ ? await checkGitHub(currentVersion)
114
+ : await checkNpm(currentVersion);
115
+
116
+ if (cached.updateAvailable) {
117
+ log.server("Update available: %s -> %s (%s)", currentVersion, cached.latestVersion, cached.installMode);
118
+ }
119
+ return cached;
120
+ } catch (err) {
121
+ log.server("Update check error: %s", err instanceof Error ? err.message : String(err));
122
+ cached = { currentVersion, latestVersion: null, updateAvailable: false, lastCheckedAt: Date.now(), releaseUrl: null, installMode: getInstallMode() };
123
+ return cached;
124
+ } finally {
125
+ checking = false;
126
+ }
127
+ }
128
+
129
+ export function getCachedUpdateInfo(): UpdateInfo | null {
130
+ return cached;
131
+ }
132
+
133
+ export function getPackageName(): string {
134
+ return PKG_NAME;
135
+ }
136
+
137
+ export function getGitHubRepo(): string {
138
+ return GITHUB_REPO;
139
+ }
140
+
141
+ export function startPeriodicUpdateCheck(): void {
142
+ void checkForUpdate();
143
+ setInterval(function () {
144
+ void checkForUpdate();
145
+ }, CHECK_INTERVAL_MS);
146
+ }
@@ -505,6 +505,15 @@ export interface PluginErrorsMessage {
505
505
  type: "plugin:errors";
506
506
  }
507
507
 
508
+ export interface UpdateCheckMessage {
509
+ type: "update:check";
510
+ force?: boolean;
511
+ }
512
+
513
+ export interface UpdateApplyMessage {
514
+ type: "update:apply";
515
+ }
516
+
508
517
  export type ClientMessage =
509
518
  | SessionCreateMessage
510
519
  | SessionActivateMessage
@@ -576,7 +585,9 @@ export type ClientMessage =
576
585
  | PluginUpdateMessage
577
586
  | PluginDetailsMessage
578
587
  | PluginDiscoverMessage
579
- | PluginErrorsMessage;
588
+ | PluginErrorsMessage
589
+ | UpdateCheckMessage
590
+ | UpdateApplyMessage;
580
591
 
581
592
  export interface SessionListMessage {
582
593
  type: "session:list";
@@ -971,6 +982,21 @@ export interface PluginErrorsResultMessage {
971
982
  errors: PluginError[];
972
983
  }
973
984
 
985
+ export interface UpdateStatusMessage {
986
+ type: "update:status";
987
+ currentVersion: string;
988
+ latestVersion: string | null;
989
+ updateAvailable: boolean;
990
+ releaseUrl: string | null;
991
+ installMode: "binary" | "npm";
992
+ }
993
+
994
+ export interface UpdateApplyResultMessage {
995
+ type: "update:apply_result";
996
+ success: boolean;
997
+ message?: string;
998
+ }
999
+
974
1000
  export type ServerMessage =
975
1001
  | SessionListMessage
976
1002
  | SessionCreatedMessage
@@ -1047,7 +1073,9 @@ export type ServerMessage =
1047
1073
  | PluginUpdateResultMessage
1048
1074
  | PluginDetailsResultMessage
1049
1075
  | PluginDiscoverResultMessage
1050
- | PluginErrorsResultMessage;
1076
+ | PluginErrorsResultMessage
1077
+ | UpdateStatusMessage
1078
+ | UpdateApplyResultMessage;
1051
1079
 
1052
1080
  export interface BudgetStatusMessage {
1053
1081
  type: "budget:status";