claude-garage 0.2.0 → 0.3.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.
@@ -0,0 +1,200 @@
1
+ import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { getHookToken } from "./registry.js";
4
+ import { tokenMatches } from "./token-auth.js";
5
+ import { resolveSessionIds } from "./session-resolve.js";
6
+ import { readSettingsOrRefuse, writeSettingsAtomic } from "./settings-install.js";
7
+ import { claudeHome } from "./claude-home.js";
8
+
9
+ const PORT = Number(process.env.GARAGE_PORT ?? 4747);
10
+ const SETTINGS_PATH = join(claudeHome(), "settings.json");
11
+ const WRAPPER_PATH = join(claudeHome(), "garage-statusline.sh");
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // In-memory store — mirrors status.js's shape (a Map, no persistence, wiped
15
+ // per the id it's keyed on). Two slots: per-session context percentage, and
16
+ // ONE account-wide rate-limits record (statusline posts carry the whole
17
+ // account's rate limits on every post, not a per-session figure).
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const contextStore = new Map(); // garage session id -> { usedPercentage }
21
+ let rateLimits = { fiveHour: null, sevenDay: null };
22
+
23
+ // Exported so the pure clamp logic is directly unit-testable (mirrors
24
+ // hooks.js's isIdleReminder being exported for the same reason).
25
+ export function clampPercentage(value) {
26
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
27
+ return Math.min(100, Math.max(0, value));
28
+ }
29
+
30
+ export function setContext(id, usedPercentage) {
31
+ contextStore.set(id, { usedPercentage });
32
+ }
33
+
34
+ // Read shape matches what sessions.js puts on the listing entry directly:
35
+ // {usedPercentage, source: "statusline"} | null.
36
+ export function getStatuslineContext(id) {
37
+ const entry = contextStore.get(id);
38
+ return entry ? { usedPercentage: entry.usedPercentage, source: "statusline" } : null;
39
+ }
40
+
41
+ // Called (like status.js's dropSession) when a session disappears from tmux,
42
+ // so a future reuse of the same garage session id starts clean — see
43
+ // poller.js. Also usable directly by tests.
44
+ export function dropSession(id) {
45
+ contextStore.delete(id);
46
+ }
47
+
48
+ function normalizeLimit(limit) {
49
+ if (!limit || typeof limit !== "object") return null;
50
+ const usedPercentage = clampPercentage(limit.used_percentage);
51
+ if (usedPercentage === null) return null;
52
+ return { usedPercentage, resetsAt: limit.resets_at ?? null };
53
+ }
54
+
55
+ // Partial-update semantics: a post carrying only one of the two buckets
56
+ // updates that bucket and leaves the other exactly as it was — a statusline
57
+ // post's rate_limits object isn't guaranteed to always carry both.
58
+ export function setRateLimits(raw) {
59
+ if (!raw || typeof raw !== "object") return;
60
+ const fiveHour = normalizeLimit(raw.five_hour);
61
+ const sevenDay = normalizeLimit(raw.seven_day);
62
+ if (fiveHour) rateLimits = { ...rateLimits, fiveHour };
63
+ if (sevenDay) rateLimits = { ...rateLimits, sevenDay };
64
+ }
65
+
66
+ export function getRateLimits() {
67
+ return { fiveHour: rateLimits.fiveHour, sevenDay: rateLimits.sevenDay };
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Chaining wrapper — see design.md D-wrapper. The wrapper is a tiny POSIX sh
72
+ // script (not an inline settings.json command string) so chaining an
73
+ // arbitrary pre-existing command never fights JSON/shell quoting inside
74
+ // settings.json itself; settings.json only ever gets a fixed `sh <path>`
75
+ // command pointing at it.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ // Single-quote a string for safe embedding in a POSIX sh script: close the
79
+ // quote, emit an escaped literal quote, reopen it.
80
+ function shSingleQuote(str) {
81
+ return `'${String(str).replace(/'/g, `'\\''`)}'`;
82
+ }
83
+
84
+ // `originalCommand` is the pre-existing statusLine command to chain (its
85
+ // stdout must ride through unchanged), or null when there was none.
86
+ export function wrapperScript({ token, port = PORT, originalCommand = null }) {
87
+ const url = `http://127.0.0.1:${port}/api/statusline/claude?token=${token}`;
88
+ const chain = originalCommand
89
+ ? `printf '%s' "$INPUT" | sh -c ${shSingleQuote(originalCommand)}\n`
90
+ : ""; // no pre-existing statusline — post and print nothing, per spec
91
+ return (
92
+ `#!/bin/sh\n` +
93
+ `# claude-garage statusline wrapper — auto-generated, do not hand-edit.\n` +
94
+ `# Reinstall (POST /api/statusline/install) regenerates this file.\n` +
95
+ `INPUT="$(cat)"\n` +
96
+ `printf '%s' "$INPUT" | curl -fsS --max-time 1 -X POST ${shSingleQuote(url)} ` +
97
+ `-H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 &\n` +
98
+ chain
99
+ );
100
+ }
101
+
102
+ function wrapperCommand() {
103
+ return `sh ${shSingleQuote(WRAPPER_PATH)}`;
104
+ }
105
+
106
+ async function writeWrapperScript(content) {
107
+ await mkdir(dirname(WRAPPER_PATH), { recursive: true });
108
+ const tmp = `${WRAPPER_PATH}.garage-tmp-${process.pid}`;
109
+ await writeFile(tmp, content, "utf8");
110
+ await chmod(tmp, 0o755);
111
+ await rename(tmp, WRAPPER_PATH);
112
+ }
113
+
114
+ export async function statuslineSnippet() {
115
+ const token = await getHookToken();
116
+ return {
117
+ statusLine: { type: "command", command: wrapperCommand() },
118
+ script: wrapperScript({ token, originalCommand: null }),
119
+ };
120
+ }
121
+
122
+ // Same safety order as hooks.js's installHooks: parse-or-refuse -> compute
123
+ // the merge -> backup + atomic write only if something actually changes.
124
+ // Idempotency: if settings.statusLine.command already IS our wrapper
125
+ // command, there's nothing new to capture or write (see design.md's note on
126
+ // the GARAGE_PORT-change edge case — out of scope here, matches hooks.js's
127
+ // own idempotency granularity).
128
+ async function installStatusline() {
129
+ const { settings, raw } = await readSettingsOrRefuse(SETTINGS_PATH);
130
+ const token = await getHookToken();
131
+ const command = wrapperCommand();
132
+
133
+ const existing = settings.statusLine ?? null;
134
+ if (existing && existing.command === command) {
135
+ return { ok: true, installed: true, alreadyInstalled: true, chained: null, backup: null };
136
+ }
137
+
138
+ // Only a `type: "command"` statusLine has a shell command we can chain;
139
+ // anything else (absent, or an unrecognized future type) is treated as
140
+ // "nothing to chain" rather than erroring — the wrapper simply posts and
141
+ // prints nothing, same as a fresh install.
142
+ const originalCommand =
143
+ existing && existing.type === "command" && typeof existing.command === "string"
144
+ ? existing.command
145
+ : null;
146
+
147
+ await writeWrapperScript(wrapperScript({ token, originalCommand }));
148
+
149
+ const merged = { ...settings, statusLine: { type: "command", command } };
150
+ const backup = await writeSettingsAtomic(SETTINGS_PATH, raw, merged);
151
+
152
+ return {
153
+ ok: true,
154
+ installed: true,
155
+ alreadyInstalled: false,
156
+ chained: originalCommand !== null,
157
+ backup,
158
+ };
159
+ }
160
+
161
+ export default async function statuslineRoutes(app) {
162
+ // Auth: same per-install token as hooks.js's /api/hooks/claude — the
163
+ // wrapper script is URL-only config (no way to send a header), so the
164
+ // token rides as a query param.
165
+ app.post("/api/statusline/claude", async (req, reply) => {
166
+ const expected = await getHookToken();
167
+ if (!tokenMatches(req.query?.token, expected)) {
168
+ return reply.code(401).send({ error: "missing or invalid hook token" });
169
+ }
170
+
171
+ const payload = req.body ?? {};
172
+ const ids = await resolveSessionIds(payload);
173
+ if (ids.length === 0) {
174
+ // Unresolvable session — no error, nothing recorded (context or
175
+ // account-wide rate limits): a post that can't be attributed to a
176
+ // known garage session tells us nothing trustworthy.
177
+ return reply.code(200).send({ ok: true, ignored: true });
178
+ }
179
+
180
+ const pct = clampPercentage(payload?.context_window?.used_percentage);
181
+ if (pct !== null) {
182
+ for (const id of ids) setContext(id, pct);
183
+ }
184
+ setRateLimits(payload?.rate_limits);
185
+
186
+ return reply.code(200).send({ ok: true, applied: ids });
187
+ });
188
+
189
+ app.get("/api/statusline/snippet", async () => statuslineSnippet());
190
+
191
+ app.post("/api/statusline/install", async (req, reply) => {
192
+ try {
193
+ return await installStatusline();
194
+ } catch (err) {
195
+ return reply
196
+ .code(err.statusCode ?? 500)
197
+ .send({ error: err.message ?? "statusline install failed" });
198
+ }
199
+ });
200
+ }
@@ -19,7 +19,10 @@ export async function listSessions() {
19
19
  ({ stdout } = await run("tmux", [
20
20
  "ls",
21
21
  "-F",
22
- "#{session_name}\t#{session_path}\t#{session_attached}",
22
+ // `session_created` (epoch SECONDS) backs the UI's elapsed timer for a
23
+ // session that has never had a status transition — without it a live
24
+ // session with no recorded `since` renders "—" instead of its age.
25
+ "#{session_name}\t#{session_path}\t#{session_attached}\t#{session_created}",
23
26
  ]));
24
27
  } catch {
25
28
  // tmux exits non-zero when no server is running — that means "no sessions".
@@ -29,20 +32,41 @@ export async function listSessions() {
29
32
  .split("\n")
30
33
  .filter((line) => line.startsWith(GARAGE_PREFIX))
31
34
  .map((line) => {
32
- const [name, dir, attached] = line.split("\t");
35
+ const [name, dir, attached, created] = line.split("\t");
33
36
  const [, workspace, label] = name.split("/");
34
- return { id: name, workspace, label, dir, attached: attached !== "0" };
37
+ const createdMs = Number(created) * 1000;
38
+ return {
39
+ id: name,
40
+ workspace,
41
+ label,
42
+ dir,
43
+ attached: attached !== "0",
44
+ createdAt: Number.isFinite(createdMs) && createdMs > 0 ? createdMs : null,
45
+ };
35
46
  });
36
47
  }
37
48
 
38
- // D-branch: live pane cwd per garage session, keyed by session name — NOT
39
- // `session_path` (that's the session's *starting* dir, frozen at creation;
40
- // a user who `cd`s into a worktree inside the pane changes pane_current_path
41
- // but not session_path). `-a` lists every pane on the server across every
42
- // session, so results are filtered to the garage prefix same as
43
- // listSessions(). A session can have multiple panes/windows if the user
44
- // split it manually — only the first pane encountered per session is kept,
45
- // there's no principled way to pick "the" cwd of a multi-pane session.
49
+ // p10: pane titles are free text — tmux doesn't forbid a literal tab in one
50
+ // (unlikely, but "typically" isn't "never"). `#{pane_title}` sits LAST in
51
+ // the format string and everything from the second tab onward is rejoined
52
+ // into the title field, so an embedded separator there can never shift
53
+ // session_name/pane_current_path out from under the earlier destructuring.
54
+ // Exported (pure, no tmux call) so the split logic is unit-testable without
55
+ // a live server.
56
+ export function parsePaneLine(line) {
57
+ const [name, panePath, ...rest] = line.split("\t");
58
+ return { name, panePath, title: rest.join("\t") };
59
+ }
60
+
61
+ // D-branch: live pane cwd + title per garage session, keyed by session name
62
+ // — NOT `session_path` (that's the session's *starting* dir, frozen at
63
+ // creation; a user who `cd`s into a worktree inside the pane changes
64
+ // pane_current_path but not session_path). `-a` lists every pane on the
65
+ // server across every session, so results are filtered to the garage prefix
66
+ // same as listSessions(). A session can have multiple panes/windows if the
67
+ // user split it manually — only the first pane encountered per session is
68
+ // kept, there's no principled way to pick "the" cwd/title of a multi-pane
69
+ // session.
46
70
  export async function listPanePaths() {
47
71
  let stdout;
48
72
  try {
@@ -50,19 +74,55 @@ export async function listPanePaths() {
50
74
  "list-panes",
51
75
  "-a",
52
76
  "-F",
53
- "#{session_name}\t#{pane_current_path}",
77
+ "#{session_name}\t#{pane_current_path}\t#{pane_title}",
54
78
  ]));
55
79
  } catch {
56
80
  // No tmux server running — no panes.
57
81
  return new Map();
58
82
  }
59
- const paths = new Map();
83
+ const panes = new Map();
60
84
  for (const line of stdout.split("\n")) {
61
85
  if (!line.startsWith(GARAGE_PREFIX)) continue;
62
- const [name, panePath] = line.split("\t");
63
- if (!paths.has(name)) paths.set(name, panePath);
86
+ const { name, panePath, title } = parsePaneLine(line);
87
+ if (!panes.has(name)) panes.set(name, { path: panePath, title });
64
88
  }
65
- return paths;
89
+ return panes;
90
+ }
91
+
92
+ // p10: tmux's own default pane titles carry no signal — empty, the machine
93
+ // hostname (tmux's out-of-the-box default), or the bare shell/login-shell
94
+ // name it sets before anything else runs. Filtering those down to null
95
+ // means the UI only ever sees a title something *set* (e.g. Claude Code's
96
+ // OSC title updates). Pure — hostname is passed in rather than read via
97
+ // os.hostname() internally so this stays trivially unit-testable; the one
98
+ // production caller (sessions.js) passes os.hostname().
99
+ const BARE_PROCESS_NAMES = new Set([
100
+ "sh",
101
+ "bash",
102
+ "zsh",
103
+ "fish",
104
+ "-sh",
105
+ "-bash",
106
+ "-zsh",
107
+ "-fish",
108
+ "tmux",
109
+ ]);
110
+
111
+ export function normalizeTitle(title, hostname) {
112
+ if (title == null) return null;
113
+ const trimmed = title.trim();
114
+ if (!trimmed) return null;
115
+
116
+ const lower = trimmed.toLowerCase();
117
+ if (BARE_PROCESS_NAMES.has(lower)) return null;
118
+
119
+ if (hostname) {
120
+ const hostLower = hostname.toLowerCase();
121
+ const shortHost = hostLower.split(".")[0];
122
+ if (lower === hostLower || lower === shortHost) return null;
123
+ }
124
+
125
+ return trimmed;
66
126
  }
67
127
 
68
128
  // D-branch: shared by sessions.js (per-session, keyed off live pane cwd)
@@ -0,0 +1,13 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ // Shared by hooks.js and statusline.js — both authenticate a URL query-param
4
+ // token (Claude Code's HTTP hooks and the statusline wrapper are both
5
+ // URL-only config) against the same per-install hook token from
6
+ // registry.js. Constant-time compare so response timing can't leak the
7
+ // expected token a byte at a time.
8
+ export function tokenMatches(given, expected) {
9
+ if (typeof given !== "string") return false;
10
+ const a = Buffer.from(given);
11
+ const b = Buffer.from(expected);
12
+ return a.length === b.length && timingSafeEqual(a, b);
13
+ }
@@ -0,0 +1,142 @@
1
+ import { open } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { claudeHome } from "./claude-home.js";
4
+
5
+ const TAIL_BYTES = 256 * 1024;
6
+ const TTL_MS = 15_000;
7
+ const DEFAULT_WINDOW = 200_000;
8
+
9
+ // Claude Code's own project-directory naming: the cwd with every `/`
10
+ // replaced by `-` (leading slash included). Exported — pure — for direct
11
+ // testing.
12
+ export function slugifyCwd(cwd) {
13
+ return cwd.replaceAll("/", "-");
14
+ }
15
+
16
+ export function transcriptPath(dir, claudeSessionId) {
17
+ return join(claudeHome(), "projects", slugifyCwd(dir), `${claudeSessionId}.jsonl`);
18
+ }
19
+
20
+ // Small map of known exceptions to the 200k default — "[1m]" marks the
21
+ // 1M-context beta variants of a model id (e.g.
22
+ // "claude-sonnet-4-5-20250929[1m]"). Anything else, including an unknown or
23
+ // missing model id, gets the default: a hint, not billing, per design.md.
24
+ export function windowForModel(model) {
25
+ if (typeof model !== "string") return DEFAULT_WINDOW;
26
+ if (model.includes("[1m]")) return 1_000_000;
27
+ return DEFAULT_WINDOW;
28
+ }
29
+
30
+ // Scans `text` (the tail-bounded transcript content) backward for the last
31
+ // assistant message carrying a `usage` block, skipping malformed JSON lines
32
+ // and any line that isn't an assistant/usage entry. Returns
33
+ // {tokens, model} | null. Pure — exported for direct unit tests.
34
+ export function parseLastAssistantUsage(text) {
35
+ const lines = text.split("\n");
36
+ for (let i = lines.length - 1; i >= 0; i--) {
37
+ const line = lines[i].trim();
38
+ if (!line) continue;
39
+
40
+ let entry;
41
+ try {
42
+ entry = JSON.parse(line);
43
+ } catch {
44
+ continue; // malformed line (or a partial line from the tail cut) — skip
45
+ }
46
+
47
+ if (entry?.type !== "assistant") continue;
48
+ const usage = entry?.message?.usage;
49
+ if (!usage || typeof usage !== "object") continue;
50
+
51
+ const input = Number(usage.input_tokens) || 0;
52
+ const cacheRead = Number(usage.cache_read_input_tokens) || 0;
53
+ const cacheCreation = Number(usage.cache_creation_input_tokens) || 0;
54
+ return { tokens: input + cacheRead + cacheCreation, model: entry.message.model ?? null };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ // Reads only the last <=256KB of the file (a multi-MB transcript must never
60
+ // cost a full read). Returns the tail text, or null if the file doesn't
61
+ // exist / can't be read. The first line of the tail may be a partial line
62
+ // cut mid-record — parseLastAssistantUsage tolerates that by skipping
63
+ // unparseable lines, which is exactly what a truncated leading line is.
64
+ async function readTail(path) {
65
+ let handle;
66
+ try {
67
+ handle = await open(path, "r");
68
+ const { size } = await handle.stat();
69
+ const start = Math.max(0, size - TAIL_BYTES);
70
+ const length = size - start;
71
+ const buffer = Buffer.alloc(length);
72
+ if (length > 0) {
73
+ await handle.read(buffer, 0, length, start);
74
+ }
75
+ return buffer.toString("utf8");
76
+ } catch {
77
+ return null;
78
+ } finally {
79
+ await handle?.close().catch(() => {});
80
+ }
81
+ }
82
+
83
+ // The percentage the fallback exposes: (input + cache_read + cache_creation)
84
+ // over the model's context window. Async, does real file IO — callers on
85
+ // the hot path must use the cache below (getCachedContext/refreshContext),
86
+ // never call this directly from a request handler.
87
+ export async function computeContext({ dir, claudeSessionId }) {
88
+ if (!dir || !claudeSessionId) return null;
89
+ const tail = await readTail(transcriptPath(dir, claudeSessionId));
90
+ if (tail === null) return null;
91
+
92
+ const usage = parseLastAssistantUsage(tail);
93
+ if (!usage) return null; // no assistant usage within the tail-bounded read
94
+
95
+ const window = windowForModel(usage.model);
96
+ const usedPercentage = Math.min(100, Math.max(0, Math.round((usage.tokens / window) * 100)));
97
+ return { usedPercentage, source: "transcript" };
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Per-session cache: {value, at, pending}. `value` is null until the first
102
+ // successful (or empty) read completes. The sessions route (sessions.js)
103
+ // must NEVER block on a transcript read — it calls getCachedContext for the
104
+ // synchronous, always-available answer, and refreshContext to kick a
105
+ // background read when the cache is stale. refreshContext returns
106
+ // immediately in every case; its promise is intentionally not returned so a
107
+ // caller can't accidentally await it into the request path.
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const cache = new Map(); // garage session id -> {value, at, pending}
111
+
112
+ export function getCachedContext(id) {
113
+ return cache.get(id)?.value ?? null;
114
+ }
115
+
116
+ // Exposes the full cache entry (including `at`) — mainly for tests to
117
+ // observe TTL behavior without waiting on wall-clock time.
118
+ export function getCacheEntry(id) {
119
+ return cache.get(id) ?? { value: null, at: null };
120
+ }
121
+
122
+ export function clearCache(id) {
123
+ cache.delete(id);
124
+ }
125
+
126
+ export function refreshContext(id, { dir, claudeSessionId }) {
127
+ const entry = cache.get(id);
128
+ const fresh = entry && Date.now() - entry.at < TTL_MS;
129
+ if (fresh || entry?.pending) return;
130
+
131
+ cache.set(id, { value: entry?.value ?? null, at: entry?.at ?? 0, pending: true });
132
+ computeContext({ dir, claudeSessionId })
133
+ .then((value) => {
134
+ cache.set(id, { value, at: Date.now(), pending: false });
135
+ })
136
+ .catch(() => {
137
+ // A read failure keeps whatever was already cached (stale-but-known
138
+ // beats null) but still stamps `at` so a persistently failing read
139
+ // doesn't retry every single request.
140
+ cache.set(id, { value: entry?.value ?? null, at: Date.now(), pending: false });
141
+ });
142
+ }
@@ -10,7 +10,11 @@ const run = promisify(execFile);
10
10
  // D-wt-location: outside any repo, so a worktree never pollutes `git status`
11
11
  // for the repo itself. Keyed by workspace so `~/.garage/worktrees/<ws>/` is
12
12
  // one place to `ls` per workspace.
13
- const WORKTREES_ROOT = path.join(homedir(), ".garage", "worktrees");
13
+ // Follows the registry's GARAGE_DIR override (testability — see registry.js).
14
+ const WORKTREES_ROOT = path.join(
15
+ process.env.GARAGE_DIR ?? path.join(homedir(), ".garage"),
16
+ "worktrees"
17
+ );
14
18
 
15
19
  // D-wt-branch: namespaced so a repo's real branches never collide with ours,
16
20
  // and so the discard/merge cleanup guard below can refuse to touch anything
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-garage",
3
- "version": "0.2.0",
4
- "description": "Multiple Claude agents driving you crazy? Park them all in one garage: every session on one live terminal wall, no window juggling. Needs-input triage, worktree isolation, diff review. tmux owns the sessions; close the app and nothing dies.",
3
+ "version": "0.3.0",
4
+ "description": "Multiple Claude agents driving you crazy? Park them all in one garage: a needs-input queue that follows you across projects, a full-screen terminal TUI, and a browser wall with diff review. tmux owns the sessions; close the app and nothing dies.",
5
5
  "license": "MIT",
6
6
  "author": "Saiful Islam <saifulislamadar@gmail.com>",
7
7
  "repository": {
@@ -14,6 +14,7 @@
14
14
  "claude",
15
15
  "claude-code",
16
16
  "tmux",
17
+ "tui",
17
18
  "terminal",
18
19
  "agents",
19
20
  "session-manager",
@@ -33,7 +34,8 @@
33
34
  "bin",
34
35
  "daemon/src",
35
36
  "daemon/package.json",
36
- "ui/dist"
37
+ "ui/dist",
38
+ "wall/dist"
37
39
  ],
38
40
  "workspaces": [
39
41
  "daemon",
@@ -42,6 +44,7 @@
42
44
  "scripts": {
43
45
  "dev": "concurrently -n daemon,ui -c blue,magenta \"npm:dev --workspace daemon\" \"npm:dev --workspace ui\"",
44
46
  "test": "node --test \"daemon/test/*.test.js\" \"ui/test/*.test.js\"",
47
+ "build:tui": "cd wall && cargo build --release && mkdir -p dist && cp target/release/garage-wall dist/garage-wall-$(node -p \"process.platform+'-'+process.arch\")",
45
48
  "postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true",
46
49
  "prepack": "npm run build --workspace ui"
47
50
  },