@polderlabs/bizar-dash 3.0.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.
- package/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/state.mjs
|
|
3
|
+
*
|
|
4
|
+
* v3.0.0 — Server-side state aggregation.
|
|
5
|
+
*
|
|
6
|
+
* Holds the read-only legacy endpoints (overview, plans, chat-via-legacy,
|
|
7
|
+
* etc.) used by both the dashboard and the TUI. New endpoints (per-project
|
|
8
|
+
* tasks, schedules, mods, projects) live directly in api.mjs and use the
|
|
9
|
+
* dedicated stores.
|
|
10
|
+
*
|
|
11
|
+
* Data sources:
|
|
12
|
+
* - getOverview: counts + .bizar/activity.log tail
|
|
13
|
+
* - getChat: per-project sessions/<id>.jsonl (preferred) — falls
|
|
14
|
+
* back to legacy .bizar/sessions if no project is active
|
|
15
|
+
* - getAgents: ~/.config/opencode/agents/*.md (frontmatter parse)
|
|
16
|
+
* - getPlans: scans plans/ (worktree) and ~/.config/opencode/plans/
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
statSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { join, basename, dirname } from 'node:path';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { projectsStore } from './projects-store.mjs';
|
|
29
|
+
|
|
30
|
+
const HOME = homedir();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {string} opts.projectRoot
|
|
35
|
+
* @param {string} opts.opencodeConfigDir
|
|
36
|
+
* @param {string} opts.bizarRoot
|
|
37
|
+
*/
|
|
38
|
+
export function createState({ projectRoot, opencodeConfigDir, bizarRoot }) {
|
|
39
|
+
const paths = {
|
|
40
|
+
projectRoot,
|
|
41
|
+
opencodeConfigDir,
|
|
42
|
+
bizarRoot,
|
|
43
|
+
opencodeJson: join(opencodeConfigDir, 'opencode.json'),
|
|
44
|
+
agentsDir: join(opencodeConfigDir, 'agents'),
|
|
45
|
+
commandsDir: join(opencodeConfigDir, 'commands-bizar'),
|
|
46
|
+
bizarDir: join(projectRoot, '.bizar'),
|
|
47
|
+
sessionsDir: join(projectRoot, '.bizar', 'sessions'),
|
|
48
|
+
activityLog: join(projectRoot, '.bizar', 'activity.log'),
|
|
49
|
+
plansDir: join(projectRoot, 'plans'),
|
|
50
|
+
globalPlansDir: join(opencodeConfigDir, 'plans'),
|
|
51
|
+
settingsFile: join(HOME, '.config', 'bizar', 'settings.json'),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function safeReadJSON(file, fallback = null) {
|
|
55
|
+
try {
|
|
56
|
+
if (!existsSync(file)) return fallback;
|
|
57
|
+
const text = readFileSync(file, 'utf8');
|
|
58
|
+
if (!text.trim()) return fallback;
|
|
59
|
+
return JSON.parse(text);
|
|
60
|
+
} catch {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function safeReadText(file, fallback = '') {
|
|
66
|
+
try {
|
|
67
|
+
if (!existsSync(file)) return fallback;
|
|
68
|
+
return readFileSync(file, 'utf8');
|
|
69
|
+
} catch {
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeStat(p) {
|
|
75
|
+
try {
|
|
76
|
+
return statSync(p);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseFrontmatter(raw) {
|
|
83
|
+
if (!raw.startsWith('---')) return { frontmatter: {}, body: raw };
|
|
84
|
+
const end = raw.indexOf('\n---', 3);
|
|
85
|
+
if (end === -1) return { frontmatter: {}, body: raw };
|
|
86
|
+
const fmBlock = raw.slice(3, end).trim();
|
|
87
|
+
const body = raw.slice(end + 4).replace(/^\s+/, '');
|
|
88
|
+
const frontmatter = {};
|
|
89
|
+
for (const line of fmBlock.split(/\r?\n/)) {
|
|
90
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
91
|
+
if (!m) continue;
|
|
92
|
+
const key = m[1];
|
|
93
|
+
let val = m[2].trim();
|
|
94
|
+
if (
|
|
95
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
96
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
97
|
+
) {
|
|
98
|
+
val = val.slice(1, -1);
|
|
99
|
+
}
|
|
100
|
+
frontmatter[key] = val;
|
|
101
|
+
}
|
|
102
|
+
return { frontmatter, body };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getOverview() {
|
|
106
|
+
// Use the new store for project count to keep the v3 view consistent.
|
|
107
|
+
const projectsList = projectsStore.list();
|
|
108
|
+
const agents = readAgents();
|
|
109
|
+
const plans = readPlans();
|
|
110
|
+
const active = projectsStore.active();
|
|
111
|
+
|
|
112
|
+
let sessionCount = 0;
|
|
113
|
+
if (active) {
|
|
114
|
+
const dir = join(projectsStore.projectDir(active.id), 'sessions');
|
|
115
|
+
if (existsSync(dir)) {
|
|
116
|
+
try {
|
|
117
|
+
sessionCount = readdirSync(dir).filter((f) => f.endsWith('.jsonl')).length;
|
|
118
|
+
} catch {
|
|
119
|
+
sessionCount = 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} else if (existsSync(paths.sessionsDir)) {
|
|
123
|
+
try {
|
|
124
|
+
sessionCount = readdirSync(paths.sessionsDir).filter((f) => f.endsWith('.jsonl')).length;
|
|
125
|
+
} catch {
|
|
126
|
+
sessionCount = 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const recentActivity = [];
|
|
131
|
+
try {
|
|
132
|
+
const logFile = active
|
|
133
|
+
? join(projectsStore.projectDir(active.id), 'activity.log')
|
|
134
|
+
: paths.activityLog;
|
|
135
|
+
if (existsSync(logFile)) {
|
|
136
|
+
const lines = readFileSync(logFile, 'utf8').split(/\r?\n/);
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (!line.trim()) continue;
|
|
139
|
+
try {
|
|
140
|
+
recentActivity.push(JSON.parse(line));
|
|
141
|
+
} catch {
|
|
142
|
+
// skip
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
recentActivity.reverse();
|
|
150
|
+
const trimmedActivity = recentActivity.slice(0, 30);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
counts: {
|
|
154
|
+
agents: agents.length,
|
|
155
|
+
plans: plans.length,
|
|
156
|
+
projects: projectsList.projects.length,
|
|
157
|
+
sessions: sessionCount,
|
|
158
|
+
activeProject: active?.id || null,
|
|
159
|
+
},
|
|
160
|
+
recentActivity: trimmedActivity,
|
|
161
|
+
versions: {
|
|
162
|
+
node: process.version,
|
|
163
|
+
platform: process.platform,
|
|
164
|
+
bizarRoot,
|
|
165
|
+
projectRoot,
|
|
166
|
+
},
|
|
167
|
+
generatedAt: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getChat({ sessionId = null, limit = 200 } = {}) {
|
|
172
|
+
const active = projectsStore.active();
|
|
173
|
+
const sessionsDir = active
|
|
174
|
+
? join(projectsStore.projectDir(active.id), 'sessions')
|
|
175
|
+
: paths.sessionsDir;
|
|
176
|
+
if (!existsSync(sessionsDir)) return { messages: [], sessions: [] };
|
|
177
|
+
const allFiles = readdirSync(sessionsDir).filter((f) => f.endsWith('.jsonl'));
|
|
178
|
+
const sessions = allFiles.map((f) => {
|
|
179
|
+
const st = safeStat(join(sessionsDir, f));
|
|
180
|
+
return {
|
|
181
|
+
id: f.replace(/\.jsonl$/, ''),
|
|
182
|
+
file: f,
|
|
183
|
+
mtime: st ? st.mtimeMs : 0,
|
|
184
|
+
size: st ? st.size : 0,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
188
|
+
|
|
189
|
+
const target = sessionId
|
|
190
|
+
? allFiles.filter((f) => f === `${sessionId}.jsonl`)
|
|
191
|
+
: allFiles;
|
|
192
|
+
|
|
193
|
+
const messages = [];
|
|
194
|
+
for (const file of target) {
|
|
195
|
+
const full = join(sessionsDir, file);
|
|
196
|
+
try {
|
|
197
|
+
const lines = readFileSync(full, 'utf8').split(/\r?\n/);
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
if (!line.trim()) continue;
|
|
200
|
+
try {
|
|
201
|
+
messages.push(JSON.parse(line));
|
|
202
|
+
} catch {
|
|
203
|
+
/* skip */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
/* skip */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
messages.reverse();
|
|
212
|
+
return { messages: messages.slice(0, limit), sessions };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readAgents() {
|
|
216
|
+
const dir = paths.agentsDir;
|
|
217
|
+
if (!existsSync(dir)) return [];
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const file of readdirSync(dir)) {
|
|
220
|
+
if (!file.endsWith('.md')) continue;
|
|
221
|
+
const full = join(dir, file);
|
|
222
|
+
const raw = safeReadText(full);
|
|
223
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
224
|
+
const st = safeStat(full);
|
|
225
|
+
out.push({
|
|
226
|
+
name:
|
|
227
|
+
frontmatter.name ||
|
|
228
|
+
basename(file, '.md') ||
|
|
229
|
+
file.replace(/\.md$/, ''),
|
|
230
|
+
description: frontmatter.description || '',
|
|
231
|
+
model: frontmatter.model || '',
|
|
232
|
+
mode: frontmatter.mode || '',
|
|
233
|
+
file,
|
|
234
|
+
path: full,
|
|
235
|
+
mtime: st ? st.mtimeMs : 0,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getAgents() {
|
|
243
|
+
return readAgents();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function readPlansFromDir(dir) {
|
|
247
|
+
if (!existsSync(dir)) return [];
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const entry of readdirSync(dir)) {
|
|
250
|
+
const full = join(dir, entry);
|
|
251
|
+
const st = safeStat(full);
|
|
252
|
+
if (!st || !st.isDirectory()) continue;
|
|
253
|
+
const meta = safeReadJSON(join(full, 'meta.json'), null);
|
|
254
|
+
const planPath = join(full, 'plan.mdx');
|
|
255
|
+
const planExists = existsSync(planPath);
|
|
256
|
+
out.push({
|
|
257
|
+
slug: entry,
|
|
258
|
+
title: meta?.title || entry,
|
|
259
|
+
status: meta?.status || 'draft',
|
|
260
|
+
source: dir === paths.plansDir ? 'worktree' : 'global',
|
|
261
|
+
elementCount: meta?.elementCount ?? null,
|
|
262
|
+
commentCount: meta?.commentCount ?? null,
|
|
263
|
+
mtime: st.mtimeMs,
|
|
264
|
+
planUrl: planExists ? `/${entry}/` : null,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function readPlans() {
|
|
271
|
+
const a = readPlansFromDir(paths.plansDir);
|
|
272
|
+
const b = readPlansFromDir(paths.globalPlansDir);
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const p of [...a, ...b]) {
|
|
276
|
+
if (seen.has(p.slug)) continue;
|
|
277
|
+
seen.add(p.slug);
|
|
278
|
+
out.push(p);
|
|
279
|
+
}
|
|
280
|
+
out.sort((a, b) => b.mtime - a.mtime);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getPlans() {
|
|
285
|
+
return readPlans();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function getProjects() {
|
|
289
|
+
// Legacy v2.x shape — list of {name, path, ...} for the Dashboard
|
|
290
|
+
// UI. The new v3 API lives in api.mjs and returns the richer registry.
|
|
291
|
+
const out = [];
|
|
292
|
+
let dir = projectRoot;
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
while (dir && dir !== dirname(dir) && !seen.has(dir)) {
|
|
295
|
+
seen.add(dir);
|
|
296
|
+
const marker = join(dir, '.bizar', 'PROJECT.md');
|
|
297
|
+
if (existsSync(marker)) {
|
|
298
|
+
const st = safeStat(marker);
|
|
299
|
+
out.push({
|
|
300
|
+
name: basename(dir),
|
|
301
|
+
path: dir,
|
|
302
|
+
projectMdSize: st ? st.size : 0,
|
|
303
|
+
mtime: st ? st.mtimeMs : 0,
|
|
304
|
+
active: dir === projectRoot,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
dir = dirname(dir);
|
|
308
|
+
}
|
|
309
|
+
const projectsRoot = join(HOME, 'Projects');
|
|
310
|
+
if (existsSync(projectsRoot)) {
|
|
311
|
+
try {
|
|
312
|
+
for (const entry of readdirSync(projectsRoot)) {
|
|
313
|
+
const full = join(projectsRoot, entry);
|
|
314
|
+
const st = safeStat(full);
|
|
315
|
+
if (!st || !st.isDirectory()) continue;
|
|
316
|
+
if (out.some((p) => p.path === full)) continue;
|
|
317
|
+
out.push({
|
|
318
|
+
name: entry,
|
|
319
|
+
path: full,
|
|
320
|
+
projectMdSize: 0,
|
|
321
|
+
mtime: st.mtimeMs,
|
|
322
|
+
active: false,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
/* ignore */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
out.sort((a, b) => (b.active ? 1 : 0) - (a.active ? 1 : 0));
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function appendActivity(event) {
|
|
334
|
+
try {
|
|
335
|
+
const active = projectsStore.active();
|
|
336
|
+
const targetDir = active
|
|
337
|
+
? projectsStore.ensureProjectDir(active.id)
|
|
338
|
+
: paths.bizarDir;
|
|
339
|
+
const logFile = join(targetDir, 'activity.log');
|
|
340
|
+
mkdirSync(targetDir, { recursive: true });
|
|
341
|
+
const record = { ts: new Date().toISOString(), ...event };
|
|
342
|
+
writeFileSync(logFile, JSON.stringify(record) + '\n', { flag: 'a', encoding: 'utf8' });
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.error('[dashboard state] appendActivity failed:', err);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
paths,
|
|
350
|
+
getOverview,
|
|
351
|
+
getChat,
|
|
352
|
+
getAgents,
|
|
353
|
+
getPlans,
|
|
354
|
+
getProjects,
|
|
355
|
+
appendActivity,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/tailscale-store.mjs
|
|
3
|
+
*
|
|
4
|
+
* v3.0.0 — Tailscale serve control.
|
|
5
|
+
*
|
|
6
|
+
* v3 only does the surface plumbing. The actual `tailscale serve` CLI
|
|
7
|
+
* invocation is wrapped in try/catch with helpful errors.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homedir, hostname as getOsHostname } from 'node:os';
|
|
14
|
+
|
|
15
|
+
const execFileP = promisify(execFile);
|
|
16
|
+
const HOME = homedir();
|
|
17
|
+
const SETTINGS_FILE = join(HOME, '.config', 'bizar', 'tailscale.json');
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { dirname } from 'node:path';
|
|
21
|
+
|
|
22
|
+
function loadSettings() {
|
|
23
|
+
try {
|
|
24
|
+
if (!existsSync(SETTINGS_FILE)) return { enabled: false, port: 4321, https: true, hostname: '' };
|
|
25
|
+
return JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return { enabled: false, port: 4321, https: true, hostname: '' };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function saveSettings(s) {
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(dirname(SETTINGS_FILE), { recursive: true });
|
|
34
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2) + '\n', 'utf8');
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function tailscaleVersion() {
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await execFileP('tailscale', ['version'], { timeout: 5000 });
|
|
43
|
+
return stdout.trim().split('\n')[0] || '';
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function tailscaleStatus() {
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await execFileP('tailscale', ['status', '--json'], { timeout: 5000 });
|
|
52
|
+
const data = JSON.parse(stdout);
|
|
53
|
+
return {
|
|
54
|
+
authenticated: !!data.AuthURL || !!data.Self?.Online,
|
|
55
|
+
backend: data.BackendState || 'unknown',
|
|
56
|
+
hostname: data.Self?.HostName || '',
|
|
57
|
+
};
|
|
58
|
+
} catch {
|
|
59
|
+
return { authenticated: false, backend: 'unknown' };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const tailscaleStore = {
|
|
64
|
+
SETTINGS_FILE,
|
|
65
|
+
|
|
66
|
+
async status() {
|
|
67
|
+
const version = await tailscaleVersion();
|
|
68
|
+
const installed = !!version;
|
|
69
|
+
let ts = null;
|
|
70
|
+
if (installed) ts = await tailscaleStatus();
|
|
71
|
+
const cfg = loadSettings();
|
|
72
|
+
return {
|
|
73
|
+
installed,
|
|
74
|
+
version,
|
|
75
|
+
...ts,
|
|
76
|
+
settings: cfg,
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async enable({ port = 4321, https = true, hostname = '' } = {}) {
|
|
81
|
+
if (!existsSync('/usr/bin/tailscale') && !existsSync('/usr/local/bin/tailscale')) {
|
|
82
|
+
// best-effort detection — `tailscale` may be in $PATH elsewhere
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
// Resolve hostname: use provided value, or fall back to current machine hostname
|
|
86
|
+
const resolvedHostname = hostname || getOsHostname();
|
|
87
|
+
const args = ['serve', '--bg'];
|
|
88
|
+
// Pass hostname via --host flag (Tailscale serve uses this for HTTPS certificate)
|
|
89
|
+
if (resolvedHostname) {
|
|
90
|
+
args.push('--host', resolvedHostname);
|
|
91
|
+
}
|
|
92
|
+
args.push(https ? 'https' : 'http', `localhost:${port}`);
|
|
93
|
+
await execFileP('tailscale', args, { timeout: 10000 });
|
|
94
|
+
const cfg = { enabled: true, port, https, hostname: resolvedHostname };
|
|
95
|
+
saveSettings(cfg);
|
|
96
|
+
return { ok: true, settings: cfg };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return { ok: false, error: err.message };
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async disable() {
|
|
103
|
+
try {
|
|
104
|
+
await execFileP('tailscale', ['serve', 'reset'], { timeout: 10000 });
|
|
105
|
+
const cfg = loadSettings();
|
|
106
|
+
cfg.enabled = false;
|
|
107
|
+
saveSettings(cfg);
|
|
108
|
+
return { ok: true, settings: cfg };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { ok: false, error: err.message };
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|