@jhytabest/plashboard 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -56,6 +56,9 @@ For real model runs, switch `fill_provider` to `command` and provide `fill_comma
56
56
  ## Runtime Command
57
57
 
58
58
  ```text
59
+ /plashboard setup [mock|command <fill_command>]
60
+ /plashboard expose-guide [local_url] [https_port]
61
+ /plashboard expose-check [local_url] [https_port]
59
62
  /plashboard init
60
63
  /plashboard status
61
64
  /plashboard list
@@ -66,6 +69,20 @@ For real model runs, switch `fill_provider` to `command` and provide `fill_comma
66
69
  /plashboard set-display <width> <height> <safe_top> <safe_bottom>
67
70
  ```
68
71
 
72
+ Recommended first run:
73
+
74
+ ```text
75
+ /plashboard setup mock
76
+ /plashboard init
77
+ ```
78
+
79
+ Tailscale helper flow:
80
+
81
+ ```text
82
+ /plashboard expose-guide
83
+ /plashboard expose-check
84
+ ```
85
+
69
86
  ## Notes
70
87
 
71
88
  - The plugin includes an admin skill (`plashboard-admin`) for tool-guided management.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhytabest/plashboard",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "description": "Plashboard OpenClaw plugin runtime",
6
6
  "license": "MIT",
@@ -17,6 +17,9 @@ Use this skill for plashboard runtime administration.
17
17
 
18
18
  ## Required Tooling
19
19
  Always use plugin tools:
20
+ - `plashboard_setup`
21
+ - `plashboard_exposure_guide`
22
+ - `plashboard_exposure_check`
20
23
  - `plashboard_init`
21
24
  - `plashboard_template_create`
22
25
  - `plashboard_template_update`
@@ -36,6 +39,9 @@ Always use plugin tools:
36
39
  - Never ask the model to generate full dashboard structure when filling values.
37
40
 
38
41
  ## Command Shortcuts
42
+ - `/plashboard setup [mock|command <fill_command>]`
43
+ - `/plashboard expose-guide [local_url] [https_port]`
44
+ - `/plashboard expose-check [local_url] [https_port]`
39
45
  - `/plashboard init`
40
46
  - `/plashboard status`
41
47
  - `/plashboard list`
package/src/plugin.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { access, stat } from 'node:fs/promises';
1
4
  import type { DisplayProfile, ToolResponse } from './types.js';
2
5
  import { resolveConfig } from './config.js';
3
6
  import { PlashboardRuntime } from './runtime.js';
@@ -11,6 +14,12 @@ type UnknownApi = {
11
14
  warn?: (...args: unknown[]) => void;
12
15
  error?: (...args: unknown[]) => void;
13
16
  };
17
+ runtime?: {
18
+ config?: {
19
+ loadConfig?: () => unknown;
20
+ writeConfigFile?: (nextConfig: unknown) => Promise<void>;
21
+ };
22
+ };
14
23
  config?: unknown;
15
24
  pluginConfig?: unknown;
16
25
  };
@@ -42,6 +51,361 @@ function asString(value: unknown): string {
42
51
  return typeof value === 'string' ? value : '';
43
52
  }
44
53
 
54
+ function asNumber(value: unknown): number | undefined {
55
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
56
+ if (typeof value === 'string' && value.trim()) {
57
+ const parsed = Number(value);
58
+ if (Number.isFinite(parsed)) return parsed;
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ function asErrorMessage(error: unknown): string {
64
+ if (error instanceof Error && error.message) return error.message;
65
+ if (typeof error === 'string' && error.trim()) return error;
66
+ return 'unknown error';
67
+ }
68
+
69
+ type SetupParams = {
70
+ fill_provider?: 'mock' | 'command';
71
+ fill_command?: string;
72
+ data_dir?: string;
73
+ scheduler_tick_seconds?: number;
74
+ session_timeout_seconds?: number;
75
+ width_px?: number;
76
+ height_px?: number;
77
+ safe_top_px?: number;
78
+ safe_bottom_px?: number;
79
+ safe_side_px?: number;
80
+ layout_safety_margin_px?: number;
81
+ };
82
+
83
+ type ExposureParams = {
84
+ local_url?: string;
85
+ tailscale_https_port?: number;
86
+ dashboard_output_path?: string;
87
+ };
88
+
89
+ type CommandExecResult = {
90
+ ok: boolean;
91
+ stdout: string;
92
+ stderr: string;
93
+ code: number | null;
94
+ error?: string;
95
+ };
96
+
97
+ function normalizeLocalUrl(raw: string | undefined): string {
98
+ const fallback = 'http://127.0.0.1:18888';
99
+ if (!raw || !raw.trim()) return fallback;
100
+ try {
101
+ const parsed = new URL(raw.trim());
102
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return fallback;
103
+ return parsed.toString();
104
+ } catch {
105
+ return fallback;
106
+ }
107
+ }
108
+
109
+ function normalizePort(raw: number | undefined, fallback: number): number {
110
+ const value = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : fallback;
111
+ return Math.max(1, Math.min(65535, value));
112
+ }
113
+
114
+ function runCommand(binary: string, args: string[], timeoutMs: number): Promise<CommandExecResult> {
115
+ return new Promise((resolve) => {
116
+ const child = spawn(binary, args, {
117
+ env: process.env
118
+ });
119
+
120
+ let stdout = '';
121
+ let stderr = '';
122
+ let settled = false;
123
+
124
+ const finish = (result: CommandExecResult) => {
125
+ if (settled) return;
126
+ settled = true;
127
+ resolve(result);
128
+ };
129
+
130
+ const timer = setTimeout(() => {
131
+ child.kill('SIGKILL');
132
+ finish({
133
+ ok: false,
134
+ stdout,
135
+ stderr,
136
+ code: null,
137
+ error: `timed out after ${Math.floor(timeoutMs / 1000)}s`
138
+ });
139
+ }, timeoutMs);
140
+
141
+ child.stdout.on('data', (chunk) => {
142
+ stdout += String(chunk);
143
+ });
144
+ child.stderr.on('data', (chunk) => {
145
+ stderr += String(chunk);
146
+ });
147
+
148
+ child.on('error', (error) => {
149
+ clearTimeout(timer);
150
+ finish({
151
+ ok: false,
152
+ stdout,
153
+ stderr,
154
+ code: null,
155
+ error: asString((error as { message?: unknown }).message) || 'spawn failed'
156
+ });
157
+ });
158
+
159
+ child.on('close', (code) => {
160
+ clearTimeout(timer);
161
+ finish({
162
+ ok: code === 0,
163
+ stdout: stdout.trim(),
164
+ stderr: stderr.trim(),
165
+ code
166
+ });
167
+ });
168
+ });
169
+ }
170
+
171
+ async function buildExposureGuide(resolvedConfig: ReturnType<typeof resolveConfig>, params: ExposureParams = {}) {
172
+ const localUrl = normalizeLocalUrl(params.local_url);
173
+ const httpsPort = normalizePort(asNumber(params.tailscale_https_port), 8444);
174
+ const dashboardPath = (params.dashboard_output_path || resolvedConfig.dashboard_output_path).trim();
175
+
176
+ return {
177
+ ok: true,
178
+ errors: [],
179
+ data: {
180
+ local_url: localUrl,
181
+ tailscale_https_port: httpsPort,
182
+ dashboard_output_path: dashboardPath,
183
+ commands: [
184
+ `tailscale serve status`,
185
+ `tailscale serve --https=${httpsPort} ${localUrl}`,
186
+ `tailscale serve status`,
187
+ `tailscale serve --https=${httpsPort} off`
188
+ ],
189
+ checks: [
190
+ `test -f ${dashboardPath}`,
191
+ `curl -I ${localUrl}`
192
+ ],
193
+ notes: [
194
+ 'plashboard only writes dashboard JSON; your local UI/server must serve it.',
195
+ 'the tailscale mapping reuses your existing tailnet identity.',
196
+ 'choose a port not already used by another tailscale serve mapping.'
197
+ ]
198
+ }
199
+ } satisfies ToolResponse<Record<string, unknown>>;
200
+ }
201
+
202
+ async function runExposureCheck(resolvedConfig: ReturnType<typeof resolveConfig>, params: ExposureParams = {}) {
203
+ const localUrl = normalizeLocalUrl(params.local_url);
204
+ const httpsPort = normalizePort(asNumber(params.tailscale_https_port), 8444);
205
+ const dashboardPath = (params.dashboard_output_path || resolvedConfig.dashboard_output_path).trim();
206
+ const errors: string[] = [];
207
+
208
+ let dashboardExists = false;
209
+ let dashboardSizeBytes: number | undefined;
210
+ let dashboardMtimeIso: string | undefined;
211
+
212
+ try {
213
+ await access(dashboardPath, fsConstants.R_OK);
214
+ const info = await stat(dashboardPath);
215
+ dashboardExists = true;
216
+ dashboardSizeBytes = info.size;
217
+ dashboardMtimeIso = info.mtime.toISOString();
218
+ } catch {
219
+ errors.push(`dashboard file is not readable: ${dashboardPath}`);
220
+ }
221
+
222
+ let localUrlOk = false;
223
+ let localStatusCode: number | undefined;
224
+ let localError: string | undefined;
225
+
226
+ try {
227
+ const controller = new AbortController();
228
+ const timer = setTimeout(() => controller.abort(), 5000);
229
+ const response = await fetch(localUrl, {
230
+ method: 'GET',
231
+ signal: controller.signal
232
+ });
233
+ clearTimeout(timer);
234
+ localStatusCode = response.status;
235
+ localUrlOk = response.status >= 200 && response.status < 500;
236
+ if (!localUrlOk) {
237
+ errors.push(`local dashboard URL returned status ${response.status}: ${localUrl}`);
238
+ }
239
+ } catch (error) {
240
+ localError = asErrorMessage(error);
241
+ errors.push(`local dashboard URL is not reachable: ${localUrl} (${localError})`);
242
+ }
243
+
244
+ const tailscale = await runCommand('tailscale', ['serve', 'status'], 8000);
245
+ const tailscaleOutput = `${tailscale.stdout}\n${tailscale.stderr}`.trim();
246
+ let tailscalePortConfigured = false;
247
+
248
+ if (!tailscale.ok) {
249
+ errors.push(`tailscale serve status failed: ${tailscale.error || tailscale.stderr || `exit ${tailscale.code}`}`);
250
+ } else {
251
+ tailscalePortConfigured = tailscaleOutput.includes(`:${httpsPort}`);
252
+ if (!tailscalePortConfigured) {
253
+ errors.push(`tailscale serve has no mapping for https port ${httpsPort}`);
254
+ }
255
+ }
256
+
257
+ return {
258
+ ok: errors.length === 0,
259
+ errors,
260
+ data: {
261
+ dashboard_output_path: dashboardPath,
262
+ dashboard_exists: dashboardExists,
263
+ dashboard_size_bytes: dashboardSizeBytes,
264
+ dashboard_mtime_utc: dashboardMtimeIso,
265
+ local_url: localUrl,
266
+ local_url_ok: localUrlOk,
267
+ local_status_code: localStatusCode,
268
+ local_error: localError,
269
+ tailscale_https_port: httpsPort,
270
+ tailscale_status_ok: tailscale.ok,
271
+ tailscale_port_configured: tailscalePortConfigured,
272
+ tailscale_status_excerpt: tailscaleOutput.slice(0, 1200)
273
+ }
274
+ } satisfies ToolResponse<Record<string, unknown>>;
275
+ }
276
+
277
+ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resolveConfig>, params: SetupParams = {}) {
278
+ const loadConfig = api.runtime?.config?.loadConfig;
279
+ const writeConfigFile = api.runtime?.config?.writeConfigFile;
280
+
281
+ if (!loadConfig || !writeConfigFile) {
282
+ return {
283
+ ok: false,
284
+ errors: ['setup is unavailable: runtime config API is not exposed by this OpenClaw build']
285
+ } satisfies ToolResponse<Record<string, unknown>>;
286
+ }
287
+
288
+ const rootConfig = asObject(loadConfig());
289
+ const plugins = asObject(rootConfig.plugins);
290
+ const entries = asObject(plugins.entries);
291
+ const currentEntry = asObject(entries.plashboard);
292
+ const currentPluginConfig = asObject(currentEntry.config);
293
+
294
+ const existingDisplay = asObject(currentPluginConfig.display_profile);
295
+ const displayProfile = {
296
+ width_px: Math.max(
297
+ 320,
298
+ Math.floor(asNumber(params.width_px) ?? asNumber(existingDisplay.width_px) ?? resolvedConfig.display_profile.width_px)
299
+ ),
300
+ height_px: Math.max(
301
+ 240,
302
+ Math.floor(asNumber(params.height_px) ?? asNumber(existingDisplay.height_px) ?? resolvedConfig.display_profile.height_px)
303
+ ),
304
+ safe_top_px: Math.max(
305
+ 0,
306
+ Math.floor(asNumber(params.safe_top_px) ?? asNumber(existingDisplay.safe_top_px) ?? resolvedConfig.display_profile.safe_top_px)
307
+ ),
308
+ safe_bottom_px: Math.max(
309
+ 0,
310
+ Math.floor(
311
+ asNumber(params.safe_bottom_px) ?? asNumber(existingDisplay.safe_bottom_px) ?? resolvedConfig.display_profile.safe_bottom_px
312
+ )
313
+ ),
314
+ safe_side_px: Math.max(
315
+ 0,
316
+ Math.floor(asNumber(params.safe_side_px) ?? asNumber(existingDisplay.safe_side_px) ?? resolvedConfig.display_profile.safe_side_px)
317
+ ),
318
+ layout_safety_margin_px: Math.max(
319
+ 0,
320
+ Math.floor(
321
+ asNumber(params.layout_safety_margin_px)
322
+ ?? asNumber(existingDisplay.layout_safety_margin_px)
323
+ ?? resolvedConfig.display_profile.layout_safety_margin_px
324
+ )
325
+ )
326
+ };
327
+
328
+ const selectedProvider =
329
+ params.fill_provider
330
+ || (currentPluginConfig.fill_provider === 'command' ? 'command' : currentPluginConfig.fill_provider === 'mock' ? 'mock' : resolvedConfig.fill_provider);
331
+ const selectedCommand = (
332
+ params.fill_command
333
+ || asString(currentPluginConfig.fill_command)
334
+ || asString(resolvedConfig.fill_command)
335
+ ).trim();
336
+
337
+ if (selectedProvider === 'command' && !selectedCommand) {
338
+ return {
339
+ ok: false,
340
+ errors: ['fill_provider=command requires fill_command']
341
+ } satisfies ToolResponse<Record<string, unknown>>;
342
+ }
343
+
344
+ const nextPluginConfig: Record<string, unknown> = {
345
+ ...currentPluginConfig,
346
+ data_dir: params.data_dir || asString(currentPluginConfig.data_dir) || resolvedConfig.data_dir,
347
+ scheduler_tick_seconds: Math.max(
348
+ 5,
349
+ Math.floor(
350
+ asNumber(params.scheduler_tick_seconds)
351
+ ?? asNumber(currentPluginConfig.scheduler_tick_seconds)
352
+ ?? resolvedConfig.scheduler_tick_seconds
353
+ )
354
+ ),
355
+ session_timeout_seconds: Math.max(
356
+ 10,
357
+ Math.floor(
358
+ asNumber(params.session_timeout_seconds)
359
+ ?? asNumber(currentPluginConfig.session_timeout_seconds)
360
+ ?? resolvedConfig.session_timeout_seconds
361
+ )
362
+ ),
363
+ fill_provider: selectedProvider,
364
+ display_profile: displayProfile
365
+ };
366
+
367
+ if (selectedCommand) {
368
+ nextPluginConfig.fill_command = selectedCommand;
369
+ }
370
+
371
+ const nextRootConfig = {
372
+ ...rootConfig,
373
+ plugins: {
374
+ ...plugins,
375
+ entries: {
376
+ ...entries,
377
+ plashboard: {
378
+ ...currentEntry,
379
+ enabled: true,
380
+ config: nextPluginConfig
381
+ }
382
+ }
383
+ }
384
+ };
385
+
386
+ await writeConfigFile(nextRootConfig);
387
+
388
+ return {
389
+ ok: true,
390
+ errors: [],
391
+ data: {
392
+ configured: true,
393
+ restart_required: true,
394
+ plugin_id: 'plashboard',
395
+ fill_provider: selectedProvider,
396
+ fill_command: selectedProvider === 'command' ? selectedCommand : undefined,
397
+ data_dir: nextPluginConfig.data_dir,
398
+ scheduler_tick_seconds: nextPluginConfig.scheduler_tick_seconds,
399
+ session_timeout_seconds: nextPluginConfig.session_timeout_seconds,
400
+ display_profile: displayProfile,
401
+ next_steps: [
402
+ 'restart OpenClaw gateway',
403
+ 'run /plashboard init'
404
+ ]
405
+ }
406
+ } satisfies ToolResponse<Record<string, unknown>>;
407
+ }
408
+
45
409
  export function registerPlashboardPlugin(api: UnknownApi): void {
46
410
  const config = resolveConfig(api);
47
411
  const runtime = new PlashboardRuntime(config, {
@@ -60,6 +424,65 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
60
424
  }
61
425
  });
62
426
 
427
+ api.registerTool?.({
428
+ name: 'plashboard_exposure_guide',
429
+ description: 'Return copy-paste commands to expose dashboard UI over existing Tailscale.',
430
+ optional: true,
431
+ parameters: {
432
+ type: 'object',
433
+ properties: {
434
+ local_url: { type: 'string' },
435
+ tailscale_https_port: { type: 'number' },
436
+ dashboard_output_path: { type: 'string' }
437
+ },
438
+ additionalProperties: false
439
+ },
440
+ execute: async (_toolCallId: unknown, params: ExposureParams = {}) =>
441
+ toToolResult(await buildExposureGuide(config, params))
442
+ });
443
+
444
+ api.registerTool?.({
445
+ name: 'plashboard_exposure_check',
446
+ description: 'Check dashboard file, local URL, and tailscale serve mapping health.',
447
+ optional: true,
448
+ parameters: {
449
+ type: 'object',
450
+ properties: {
451
+ local_url: { type: 'string' },
452
+ tailscale_https_port: { type: 'number' },
453
+ dashboard_output_path: { type: 'string' }
454
+ },
455
+ additionalProperties: false
456
+ },
457
+ execute: async (_toolCallId: unknown, params: ExposureParams = {}) =>
458
+ toToolResult(await runExposureCheck(config, params))
459
+ });
460
+
461
+ api.registerTool?.({
462
+ name: 'plashboard_setup',
463
+ description: 'Bootstrap or update plashboard plugin configuration in openclaw.json.',
464
+ optional: true,
465
+ parameters: {
466
+ type: 'object',
467
+ properties: {
468
+ fill_provider: { type: 'string', enum: ['mock', 'command'] },
469
+ fill_command: { type: 'string' },
470
+ data_dir: { type: 'string' },
471
+ scheduler_tick_seconds: { type: 'number' },
472
+ session_timeout_seconds: { type: 'number' },
473
+ width_px: { type: 'number' },
474
+ height_px: { type: 'number' },
475
+ safe_top_px: { type: 'number' },
476
+ safe_bottom_px: { type: 'number' },
477
+ safe_side_px: { type: 'number' },
478
+ layout_safety_margin_px: { type: 'number' }
479
+ },
480
+ additionalProperties: false
481
+ },
482
+ execute: async (_toolCallId: unknown, params: SetupParams = {}) =>
483
+ toToolResult(await runSetup(api, config, params))
484
+ });
485
+
63
486
  api.registerTool?.({
64
487
  name: 'plashboard_init',
65
488
  description: 'Initialize plashboard state directories and optional default template.',
@@ -240,6 +663,32 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
240
663
  const args = asString(ctx.args).split(/\s+/).filter(Boolean);
241
664
  const [cmd, ...rest] = args;
242
665
 
666
+ if (cmd === 'expose-guide') {
667
+ const localUrl = rest.find((token) => token.startsWith('http://') || token.startsWith('https://'));
668
+ const portToken = rest.find((token) => /^[0-9]+$/.test(token));
669
+ return toCommandResult(
670
+ await buildExposureGuide(config, {
671
+ local_url: localUrl,
672
+ tailscale_https_port: portToken ? Number(portToken) : undefined
673
+ })
674
+ );
675
+ }
676
+ if (cmd === 'expose-check') {
677
+ const localUrl = rest.find((token) => token.startsWith('http://') || token.startsWith('https://'));
678
+ const portToken = rest.find((token) => /^[0-9]+$/.test(token));
679
+ return toCommandResult(
680
+ await runExposureCheck(config, {
681
+ local_url: localUrl,
682
+ tailscale_https_port: portToken ? Number(portToken) : undefined
683
+ })
684
+ );
685
+ }
686
+ if (cmd === 'setup') {
687
+ const mode = asString(rest[0]).toLowerCase();
688
+ const fillProvider = mode === 'command' || mode === 'mock' ? mode : undefined;
689
+ const fillCommand = fillProvider === 'command' ? rest.slice(1).join(' ').trim() || undefined : undefined;
690
+ return toCommandResult(await runSetup(api, config, { fill_provider: fillProvider, fill_command: fillCommand }));
691
+ }
243
692
  if (cmd === 'init') return toCommandResult(await runtime.init());
244
693
  if (cmd === 'status') return toCommandResult(await runtime.status());
245
694
  if (cmd === 'list') return toCommandResult(await runtime.templateList());
@@ -264,7 +713,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
264
713
  return toCommandResult({
265
714
  ok: false,
266
715
  errors: [
267
- 'unknown command. supported: init, status, list, activate <id>, delete <id>, copy <src> <new-id> [new-name] [activate], run <id>, set-display <width> <height> <top> <bottom>'
716
+ 'unknown command. supported: setup [mock|command <fill_command>], expose-guide [local_url] [https_port], expose-check [local_url] [https_port], init, status, list, activate <id>, delete <id>, copy <src> <new-id> [new-name] [activate], run <id>, set-display <width> <height> <top> <bottom>'
268
717
  ]
269
718
  });
270
719
  }