create-tx5dr-plugin 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # create-tx5dr-plugin
2
+
3
+ Scaffold a new [TX-5DR](https://github.com/boybook/tx-5dr) plugin project.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx create-tx5dr-plugin [name] [options]
9
+ ```
10
+
11
+ ### Options
12
+
13
+ | Flag | Description | Default |
14
+ |------|-------------|---------|
15
+ | `--type <utility\|strategy>` | Plugin type | `utility` |
16
+ | `--lang <ts\|js>` | Language | `ts` |
17
+ | `--template <basic\|ui-vanilla\|ui-react\|ui-vue>` | Project template | `basic` |
18
+ | `--help, -h` | Show help | |
19
+
20
+ ### Templates
21
+
22
+ | Template | Description |
23
+ |----------|-------------|
24
+ | `basic` | Server-side plugin only (no UI) |
25
+ | `ui-vanilla` | Plugin with vanilla HTML/JS/CSS UI page |
26
+ | `ui-react` | Plugin with React + Vite UI page |
27
+ | `ui-vue` | Plugin with Vue + Vite UI page |
28
+
29
+ ### Examples
30
+
31
+ ```bash
32
+ # Interactive mode (prompts for all options)
33
+ npx create-tx5dr-plugin
34
+
35
+ # Basic utility plugin
36
+ npx create-tx5dr-plugin my-plugin
37
+
38
+ # Strategy plugin
39
+ npx create-tx5dr-plugin my-strategy --type strategy
40
+
41
+ # Plugin with React UI
42
+ npx create-tx5dr-plugin my-plugin --template ui-react
43
+
44
+ # Plugin with Vue UI
45
+ npx create-tx5dr-plugin my-plugin --template ui-vue
46
+
47
+ # Vanilla UI (no build step for UI files)
48
+ npx create-tx5dr-plugin my-plugin --template ui-vanilla
49
+ ```
50
+
51
+ ## Generated Structure
52
+
53
+ ### Basic (no UI)
54
+
55
+ ```
56
+ my-plugin/
57
+ ├── package.json
58
+ ├── tsconfig.json
59
+ ├── .gitignore
60
+ ├── scripts/
61
+ │ └── link.mjs # Symlink to TX-5DR plugins dir
62
+ └── src/
63
+ ├── index.ts
64
+ ├── locales/
65
+ └── __tests__/
66
+ ```
67
+
68
+ ### React / Vue UI
69
+
70
+ ```
71
+ my-plugin/
72
+ ├── package.json # Includes vite + framework deps
73
+ ├── tsconfig.json # Server-side TS config
74
+ ├── scripts/
75
+ │ └── link.mjs
76
+ ├── src/
77
+ │ ├── index.ts # Plugin definition with UI pages
78
+ │ ├── locales/
79
+ │ └── __tests__/
80
+ ├── ui/ # Vite project
81
+ │ ├── vite.config.ts # MPA build config
82
+ │ ├── tsconfig.json # Frontend TS (includes bridge types)
83
+ │ ├── settings.html # HTML entry
84
+ │ └── src/
85
+ │ ├── main.tsx / main.ts # Framework entry
86
+ │ ├── App.tsx / App.vue # Main component
87
+ │ └── App.css # Uses --tx5dr-* CSS tokens
88
+ └── dist/ # Build output
89
+ ├── index.js # Server-side plugin
90
+ └── ui/
91
+ └── settings.html # Built iframe page
92
+ ```
93
+
94
+ ## Development Workflow
95
+
96
+ ```bash
97
+ cd my-plugin
98
+ npm install
99
+ npm run build # Build everything (server + UI)
100
+ npm run link # Symlink dist/ to TX-5DR plugins dir
101
+
102
+ # Development (two terminals):
103
+ npm run dev:server # Watch server-side TypeScript
104
+ npm run dev:ui # Watch UI with Vite (React/Vue only)
105
+ ```
106
+
107
+ ### How `npm run link` works
108
+
109
+ The `scripts/link.mjs` script:
110
+ 1. Detects the TX-5DR data directory (platform-specific, or `TX5DR_DATA_DIR` env)
111
+ 2. Creates a symlink from `dist/` to `{dataDir}/plugins/{plugin-name}`
112
+ 3. Creates a `.hotreload` marker so TX-5DR dev server auto-reloads on changes
113
+
114
+ ```bash
115
+ npm run link # Create symlink
116
+ npm run link -- --unlink # Remove symlink
117
+ ```
118
+
119
+ ### Hot Reload
120
+
121
+ In development mode (`NODE_ENV !== 'production'`), the TX-5DR server watches plugin directories that contain a `.hotreload` marker file. When files change, the plugin is automatically reloaded after a short debounce.
122
+
123
+ ## Documentation
124
+
125
+ - [Plugin System Guide](https://github.com/boybook/tx-5dr/blob/main/docs/plugin-system.md)
126
+ - [@tx5dr/plugin-api](https://www.npmjs.com/package/@tx5dr/plugin-api) — TypeScript types and testing utilities
127
+
128
+ ## License
129
+
130
+ MIT
package/dist/index.d.ts CHANGED
@@ -6,6 +6,6 @@
6
6
  * npx create-tx5dr-plugin # Interactive
7
7
  * npx create-tx5dr-plugin my-plugin # Name only, prompts for rest
8
8
  * npx create-tx5dr-plugin my-plugin --type utility # Non-interactive
9
- * npx create-tx5dr-plugin my-plugin --type strategy --lang js
9
+ * npx create-tx5dr-plugin my-plugin --template ui-react
10
10
  */
11
11
  export {};
package/dist/index.js CHANGED
@@ -6,11 +6,12 @@
6
6
  * npx create-tx5dr-plugin # Interactive
7
7
  * npx create-tx5dr-plugin my-plugin # Name only, prompts for rest
8
8
  * npx create-tx5dr-plugin my-plugin --type utility # Non-interactive
9
- * npx create-tx5dr-plugin my-plugin --type strategy --lang js
9
+ * npx create-tx5dr-plugin my-plugin --template ui-react
10
10
  */
11
11
  import { mkdirSync, writeFileSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { createInterface } from 'node:readline';
14
+ const VALID_TEMPLATES = ['basic', 'ui-vanilla', 'ui-react', 'ui-vue'];
14
15
  // ===== CLI argument parsing =====
15
16
  function parseArgs() {
16
17
  const args = process.argv.slice(2);
@@ -30,6 +31,12 @@ function parseArgs() {
30
31
  config.lang = value;
31
32
  }
32
33
  }
34
+ else if (arg === '--template' && i + 1 < args.length) {
35
+ const value = args[++i];
36
+ if (VALID_TEMPLATES.includes(value)) {
37
+ config.template = value;
38
+ }
39
+ }
33
40
  else if (arg === '--help' || arg === '-h') {
34
41
  printUsage();
35
42
  process.exit(0);
@@ -49,14 +56,22 @@ function printUsage() {
49
56
  npx create-tx5dr-plugin [name] [options]
50
57
 
51
58
  Options:
52
- --type <utility|strategy> Plugin type (default: utility)
53
- --lang <ts|js> Language (default: ts)
54
- --help, -h Show this help message
59
+ --type <utility|strategy> Plugin type (default: utility)
60
+ --lang <ts|js> Language (default: ts)
61
+ --template <basic|ui-vanilla|ui-react|ui-vue> Template (default: basic)
62
+ --help, -h Show this help message
63
+
64
+ Templates:
65
+ basic Server-side plugin only (no UI)
66
+ ui-vanilla Plugin with vanilla HTML/JS/CSS UI page
67
+ ui-react Plugin with React + Vite UI page
68
+ ui-vue Plugin with Vue + Vite UI page
55
69
 
56
70
  Examples:
57
- npx create-tx5dr-plugin my-cool-plugin
58
- npx create-tx5dr-plugin my-cool-plugin --type strategy
59
- npx create-tx5dr-plugin my-cool-plugin --type utility --lang js
71
+ npx create-tx5dr-plugin my-plugin
72
+ npx create-tx5dr-plugin my-plugin --type strategy
73
+ npx create-tx5dr-plugin my-plugin --template ui-react
74
+ npx create-tx5dr-plugin my-plugin --template ui-vue --type utility
60
75
  `);
61
76
  }
62
77
  // ===== Interactive prompts =====
@@ -83,28 +98,61 @@ async function promptConfig(partial) {
83
98
  const answer = await prompt(rl, 'Language (ts/js) [ts]: ');
84
99
  lang = answer === 'js' ? 'js' : 'ts';
85
100
  }
86
- return { name, type, lang };
101
+ let template = partial.template;
102
+ if (!template) {
103
+ const answer = await prompt(rl, 'Template (basic/ui-vanilla/ui-react/ui-vue) [basic]: ');
104
+ template = VALID_TEMPLATES.includes(answer) ? answer : 'basic';
105
+ }
106
+ return { name, type, lang, template };
87
107
  }
88
108
  finally {
89
109
  rl.close();
90
110
  }
91
111
  }
92
- // ===== Template generation =====
112
+ // ===== Helpers =====
113
+ function hasUI(config) {
114
+ return config.template !== 'basic';
115
+ }
116
+ function hasVite(config) {
117
+ return config.template === 'ui-react' || config.template === 'ui-vue';
118
+ }
119
+ // ===== Core template generation =====
93
120
  function generatePackageJson(config) {
94
- const deps = {};
95
121
  const devDeps = {
96
122
  '@tx5dr/plugin-api': 'latest',
97
123
  };
98
124
  if (config.lang === 'ts') {
99
125
  devDeps['typescript'] = '^5.0.0';
100
126
  devDeps['vitest'] = '^1.0.0';
101
- devDeps['@tx5dr/plugin-api'] = 'latest';
127
+ }
128
+ if (hasVite(config)) {
129
+ devDeps['vite'] = '^6.0.0';
130
+ if (config.template === 'ui-react') {
131
+ devDeps['react'] = '^19.0.0';
132
+ devDeps['react-dom'] = '^19.0.0';
133
+ devDeps['@vitejs/plugin-react'] = '^4.0.0';
134
+ devDeps['@types/react'] = '^19.0.0';
135
+ devDeps['@types/react-dom'] = '^19.0.0';
136
+ }
137
+ else if (config.template === 'ui-vue') {
138
+ devDeps['vue'] = '^3.5.0';
139
+ devDeps['@vitejs/plugin-vue'] = '^5.0.0';
140
+ }
102
141
  }
103
142
  const scripts = {};
104
143
  if (config.lang === 'ts') {
105
- scripts['build'] = 'tsc';
106
- scripts['dev'] = 'tsc --watch';
144
+ if (hasVite(config)) {
145
+ scripts['build'] = 'tsc && npm run build:ui';
146
+ scripts['build:ui'] = 'vite build --config ui/vite.config.ts';
147
+ scripts['dev:server'] = 'tsc --watch';
148
+ scripts['dev:ui'] = 'vite build --watch --config ui/vite.config.ts';
149
+ }
150
+ else {
151
+ scripts['build'] = 'tsc';
152
+ scripts['dev'] = 'tsc --watch';
153
+ }
107
154
  scripts['test'] = 'vitest run';
155
+ scripts['link'] = 'node scripts/link.mjs';
108
156
  }
109
157
  const pkg = {
110
158
  name: config.name,
@@ -116,9 +164,6 @@ function generatePackageJson(config) {
116
164
  scripts,
117
165
  devDependencies: devDeps,
118
166
  };
119
- if (Object.keys(deps).length > 0) {
120
- pkg.dependencies = deps;
121
- }
122
167
  return JSON.stringify(pkg, null, 2) + '\n';
123
168
  }
124
169
  function generateTsConfig() {
@@ -153,6 +198,7 @@ function generateLocaleEn(config) {
153
198
  pluginDescription: `${config.name} plugin`,
154
199
  }, null, 2) + '\n';
155
200
  }
201
+ // ===== Server-side plugin definition templates =====
156
202
  function generateTsUtilityPlugin(config) {
157
203
  return `import type {
158
204
  PluginDefinition,
@@ -295,6 +341,78 @@ export const locales: Record<string, Record<string, string>> = {
295
341
  en: enLocale,
296
342
  };
297
343
 
344
+ export default plugin;
345
+ `;
346
+ }
347
+ function generateTsUtilityPluginWithUI(config) {
348
+ return `import type {
349
+ PluginDefinition,
350
+ PluginContext,
351
+ ParsedFT8Message,
352
+ SlotInfo,
353
+ } from '@tx5dr/plugin-api';
354
+ import zhLocale from './locales/zh.json' with { type: 'json' };
355
+ import enLocale from './locales/en.json' with { type: 'json' };
356
+
357
+ export const plugin: PluginDefinition = {
358
+ name: '${config.name}',
359
+ version: '0.1.0',
360
+ type: 'utility',
361
+ description: 'pluginDescription',
362
+
363
+ settings: {
364
+ // Define your plugin settings here
365
+ },
366
+
367
+ ui: {
368
+ dir: 'ui',
369
+ pages: [
370
+ {
371
+ id: 'settings',
372
+ entry: 'settings.html',
373
+ title: 'settingsPage',
374
+ accessScope: 'admin',
375
+ },
376
+ ],
377
+ },
378
+
379
+ onLoad(ctx: PluginContext): void {
380
+ ctx.ui.registerPageHandler({
381
+ async onMessage(pageId, action, data) {
382
+ if (action === 'getSettings') {
383
+ return {
384
+ message: ctx.store.global.get<string>('message', ''),
385
+ };
386
+ }
387
+ if (action === 'saveSettings') {
388
+ const { message } = data as { message: string };
389
+ ctx.store.global.set('message', message);
390
+ ctx.log.info('Settings saved', { message });
391
+ return { ok: true };
392
+ }
393
+ return null;
394
+ },
395
+ });
396
+ },
397
+
398
+ hooks: {
399
+ onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx: PluginContext): void {
400
+ ctx.log.debug('Slot started', { slotId: slotInfo.id, messageCount: messages.length });
401
+ },
402
+
403
+ onDecode(messages: ParsedFT8Message[], ctx: PluginContext): void {
404
+ for (const msg of messages) {
405
+ ctx.log.debug('Decoded message', { raw: msg.rawMessage, snr: msg.snr });
406
+ }
407
+ },
408
+ },
409
+ };
410
+
411
+ export const locales: Record<string, Record<string, string>> = {
412
+ zh: zhLocale,
413
+ en: enLocale,
414
+ };
415
+
298
416
  export default plugin;
299
417
  `;
300
418
  }
@@ -326,6 +444,63 @@ export const plugin = {
326
444
  export default plugin;
327
445
  `;
328
446
  }
447
+ function generateJsUtilityPluginWithUI(config) {
448
+ return `/** @type {import('@tx5dr/plugin-api').PluginDefinition} */
449
+ export const plugin = {
450
+ name: '${config.name}',
451
+ version: '0.1.0',
452
+ type: 'utility',
453
+ description: 'pluginDescription',
454
+
455
+ settings: {},
456
+
457
+ ui: {
458
+ dir: 'ui',
459
+ pages: [
460
+ {
461
+ id: 'settings',
462
+ entry: 'settings.html',
463
+ title: 'settingsPage',
464
+ accessScope: 'admin',
465
+ },
466
+ ],
467
+ },
468
+
469
+ onLoad(ctx) {
470
+ ctx.ui.registerPageHandler({
471
+ async onMessage(pageId, action, data) {
472
+ if (action === 'getSettings') {
473
+ return {
474
+ message: ctx.store.global.get('message', ''),
475
+ };
476
+ }
477
+ if (action === 'saveSettings') {
478
+ ctx.store.global.set('message', data.message);
479
+ ctx.log.info('Settings saved', { message: data.message });
480
+ return { ok: true };
481
+ }
482
+ return null;
483
+ },
484
+ });
485
+ },
486
+
487
+ hooks: {
488
+ onSlotStart(slotInfo, messages, ctx) {
489
+ ctx.log.debug('Slot started', { slotId: slotInfo.id, messageCount: messages.length });
490
+ },
491
+
492
+ onDecode(messages, ctx) {
493
+ for (const msg of messages) {
494
+ ctx.log.debug('Decoded message', { raw: msg.rawMessage, snr: msg.snr });
495
+ }
496
+ },
497
+ },
498
+ };
499
+
500
+ export default plugin;
501
+ `;
502
+ }
503
+ // ===== Test templates =====
329
504
  function generateTsTest(config) {
330
505
  if (config.type === 'strategy') {
331
506
  return `import { describe, it, expect } from 'vitest';
@@ -400,6 +575,490 @@ describe('${config.name}', () => {
400
575
  });
401
576
  `;
402
577
  }
578
+ // ===== Vanilla UI templates =====
579
+ function generateVanillaUIHtml(config) {
580
+ return `<!DOCTYPE html>
581
+ <html>
582
+ <head>
583
+ <meta charset="UTF-8">
584
+ <link rel="stylesheet" href="settings.css">
585
+ </head>
586
+ <body>
587
+ <div class="container">
588
+ <h2 class="title">${config.name} Settings</h2>
589
+
590
+ <div class="section">
591
+ <label class="label">Message</label>
592
+ <input type="text" class="input" id="messageInput" placeholder="Enter a message">
593
+ </div>
594
+
595
+ <div class="section">
596
+ <button class="btn btn-primary" id="saveBtn">Save</button>
597
+ <span class="status" id="status"></span>
598
+ </div>
599
+
600
+ <div class="section">
601
+ <p class="text-secondary" id="currentValue"></p>
602
+ </div>
603
+ </div>
604
+ <script src="settings.js"></script>
605
+ </body>
606
+ </html>
607
+ `;
608
+ }
609
+ function generateVanillaUIJs() {
610
+ return `/// <reference types="@tx5dr/plugin-api/bridge" />
611
+ (function () {
612
+ 'use strict';
613
+
614
+ var bridge = window.tx5dr;
615
+ var messageInput = document.getElementById('messageInput');
616
+ var saveBtn = document.getElementById('saveBtn');
617
+ var status = document.getElementById('status');
618
+ var currentValue = document.getElementById('currentValue');
619
+
620
+ // Load saved value on page open
621
+ bridge.invoke('getSettings').then(function (settings) {
622
+ if (settings && settings.message) {
623
+ messageInput.value = settings.message;
624
+ currentValue.textContent = 'Current: ' + settings.message;
625
+ }
626
+ }).catch(function () {
627
+ // Use defaults
628
+ });
629
+
630
+ // Save button handler
631
+ saveBtn.addEventListener('click', function () {
632
+ var message = messageInput.value.trim();
633
+ if (!message) return;
634
+
635
+ saveBtn.disabled = true;
636
+ status.textContent = 'Saving...';
637
+
638
+ bridge.invoke('saveSettings', { message: message }).then(function () {
639
+ status.textContent = 'Saved!';
640
+ currentValue.textContent = 'Current: ' + message;
641
+ setTimeout(function () { status.textContent = ''; }, 2000);
642
+ }).catch(function (err) {
643
+ status.textContent = 'Error: ' + err.message;
644
+ }).finally(function () {
645
+ saveBtn.disabled = false;
646
+ });
647
+ });
648
+
649
+ // Listen for server push updates
650
+ bridge.onPush('settingsUpdated', function (data) {
651
+ if (data && data.message) {
652
+ messageInput.value = data.message;
653
+ currentValue.textContent = 'Current: ' + data.message;
654
+ }
655
+ });
656
+
657
+ // Auto-resize iframe
658
+ var resizeObserver = new ResizeObserver(function () {
659
+ var h = document.body.scrollHeight;
660
+ if (h > 0) bridge.resize(h);
661
+ });
662
+ resizeObserver.observe(document.body);
663
+ bridge.resize(document.body.scrollHeight);
664
+ })();
665
+ `;
666
+ }
667
+ function generateUICss() {
668
+ return `/* Plugin UI styles — using TX-5DR Design Tokens */
669
+ .container {
670
+ padding: var(--tx5dr-spacing-lg);
671
+ font-family: var(--tx5dr-font);
672
+ color: var(--tx5dr-text);
673
+ }
674
+
675
+ .title {
676
+ font-size: var(--tx5dr-font-size-lg);
677
+ font-weight: 600;
678
+ margin-bottom: var(--tx5dr-spacing-lg);
679
+ }
680
+
681
+ .section {
682
+ margin-bottom: var(--tx5dr-spacing-md);
683
+ display: flex;
684
+ align-items: center;
685
+ gap: var(--tx5dr-spacing-sm);
686
+ }
687
+
688
+ .label {
689
+ font-size: var(--tx5dr-font-size-sm);
690
+ color: var(--tx5dr-text-secondary);
691
+ min-width: 70px;
692
+ }
693
+
694
+ .input {
695
+ flex: 1;
696
+ padding: var(--tx5dr-spacing-sm) var(--tx5dr-spacing-md);
697
+ background: var(--tx5dr-bg-content);
698
+ border: 1px solid var(--tx5dr-border);
699
+ border-radius: var(--tx5dr-radius-sm);
700
+ color: var(--tx5dr-text);
701
+ font-size: var(--tx5dr-font-size-md);
702
+ font-family: var(--tx5dr-font);
703
+ outline: none;
704
+ transition: border-color 0.2s;
705
+ }
706
+
707
+ .input:focus {
708
+ border-color: var(--tx5dr-primary);
709
+ box-shadow: 0 0 0 2px var(--tx5dr-focus-ring);
710
+ }
711
+
712
+ .btn {
713
+ padding: var(--tx5dr-spacing-sm) var(--tx5dr-spacing-lg);
714
+ border: 1px solid var(--tx5dr-border);
715
+ border-radius: var(--tx5dr-radius-sm);
716
+ background: var(--tx5dr-bg-content);
717
+ color: var(--tx5dr-text);
718
+ font-size: var(--tx5dr-font-size-sm);
719
+ font-family: var(--tx5dr-font);
720
+ cursor: pointer;
721
+ transition: background 0.15s;
722
+ }
723
+
724
+ .btn:hover {
725
+ background: var(--tx5dr-bg-hover);
726
+ }
727
+
728
+ .btn:disabled {
729
+ opacity: 0.5;
730
+ cursor: not-allowed;
731
+ }
732
+
733
+ .btn-primary {
734
+ background: var(--tx5dr-primary);
735
+ border-color: var(--tx5dr-primary);
736
+ color: #fff;
737
+ }
738
+
739
+ .btn-primary:hover {
740
+ background: var(--tx5dr-primary-hover);
741
+ }
742
+
743
+ .status {
744
+ font-size: var(--tx5dr-font-size-sm);
745
+ color: var(--tx5dr-text-secondary);
746
+ }
747
+
748
+ .text-secondary {
749
+ font-size: var(--tx5dr-font-size-sm);
750
+ color: var(--tx5dr-text-secondary);
751
+ }
752
+ `;
753
+ }
754
+ function generateVanillaJsConfig() {
755
+ return JSON.stringify({
756
+ compilerOptions: {
757
+ types: ['@tx5dr/plugin-api/bridge'],
758
+ },
759
+ }, null, 2) + '\n';
760
+ }
761
+ // ===== Vite-based UI templates (React / Vue) =====
762
+ function generateViteConfig(config) {
763
+ const pluginImport = config.template === 'ui-react'
764
+ ? "import react from '@vitejs/plugin-react';"
765
+ : "import vue from '@vitejs/plugin-vue';";
766
+ const pluginUsage = config.template === 'ui-react' ? 'react()' : 'vue()';
767
+ return `import { defineConfig } from 'vite';
768
+ ${pluginImport}
769
+ import { resolve } from 'path';
770
+
771
+ export default defineConfig({
772
+ plugins: [${pluginUsage}],
773
+ root: import.meta.dirname,
774
+ build: {
775
+ outDir: '../dist/ui',
776
+ emptyOutDir: true,
777
+ rollupOptions: {
778
+ input: {
779
+ settings: resolve(import.meta.dirname, 'settings.html'),
780
+ // Add more entries here for multi-page plugins:
781
+ // dashboard: resolve(import.meta.dirname, 'dashboard.html'),
782
+ },
783
+ },
784
+ },
785
+ });
786
+ `;
787
+ }
788
+ function generateUITsConfig(config) {
789
+ const jsx = config.template === 'ui-react' ? { jsx: 'react-jsx' } : {};
790
+ return JSON.stringify({
791
+ compilerOptions: {
792
+ target: 'ES2021',
793
+ module: 'ESNext',
794
+ moduleResolution: 'bundler',
795
+ strict: true,
796
+ esModuleInterop: true,
797
+ skipLibCheck: true,
798
+ types: ['@tx5dr/plugin-api/bridge'],
799
+ ...jsx,
800
+ },
801
+ include: ['src'],
802
+ }, null, 2) + '\n';
803
+ }
804
+ function generateFrameworkEntryHtml(config) {
805
+ const ext = config.template === 'ui-react' ? 'tsx' : 'ts';
806
+ return `<!DOCTYPE html>
807
+ <html>
808
+ <head>
809
+ <meta charset="UTF-8">
810
+ </head>
811
+ <body>
812
+ <div id="app"></div>
813
+ <script type="module" src="./src/main.${ext}"></script>
814
+ </body>
815
+ </html>
816
+ `;
817
+ }
818
+ function generateReactMain() {
819
+ return `import React from 'react';
820
+ import { createRoot } from 'react-dom/client';
821
+ import { App } from './App';
822
+
823
+ const root = createRoot(document.getElementById('app')!);
824
+ root.render(<App />);
825
+
826
+ // Auto-resize iframe to fit content
827
+ const observer = new ResizeObserver(() => {
828
+ const h = document.body.scrollHeight;
829
+ if (h > 0) tx5dr.resize(h);
830
+ });
831
+ observer.observe(document.body);
832
+ `;
833
+ }
834
+ function generateReactApp(config) {
835
+ return `import React, { useCallback, useEffect, useState } from 'react';
836
+ import './App.css';
837
+
838
+ export function App() {
839
+ const [message, setMessage] = useState('');
840
+ const [saved, setSaved] = useState('');
841
+ const [status, setStatus] = useState('');
842
+
843
+ useEffect(() => {
844
+ tx5dr.invoke('getSettings').then((settings: any) => {
845
+ if (settings?.message) {
846
+ setMessage(settings.message);
847
+ setSaved(settings.message);
848
+ }
849
+ });
850
+
851
+ // Listen for push updates from the server
852
+ const handleUpdate = (data: any) => {
853
+ if (data?.message) {
854
+ setMessage(data.message);
855
+ setSaved(data.message);
856
+ }
857
+ };
858
+ tx5dr.onPush('settingsUpdated', handleUpdate);
859
+ return () => tx5dr.offPush('settingsUpdated', handleUpdate);
860
+ }, []);
861
+
862
+ const handleSave = useCallback(async () => {
863
+ if (!message.trim()) return;
864
+ setStatus('Saving...');
865
+ try {
866
+ await tx5dr.invoke('saveSettings', { message });
867
+ setSaved(message);
868
+ setStatus('Saved!');
869
+ setTimeout(() => setStatus(''), 2000);
870
+ } catch (err: any) {
871
+ setStatus('Error: ' + err.message);
872
+ }
873
+ }, [message]);
874
+
875
+ return (
876
+ <div className="container">
877
+ <h2 className="title">${config.name} Settings</h2>
878
+
879
+ <div className="section">
880
+ <label className="label">Message</label>
881
+ <input
882
+ className="input"
883
+ value={message}
884
+ onChange={(e) => setMessage(e.target.value)}
885
+ placeholder="Enter a message"
886
+ onKeyDown={(e) => e.key === 'Enter' && handleSave()}
887
+ />
888
+ </div>
889
+
890
+ <div className="section">
891
+ <button className="btn btn-primary" onClick={handleSave}>
892
+ Save
893
+ </button>
894
+ {status && <span className="status">{status}</span>}
895
+ </div>
896
+
897
+ {saved && (
898
+ <div className="section">
899
+ <p className="text-secondary">Current: {saved}</p>
900
+ </div>
901
+ )}
902
+ </div>
903
+ );
904
+ }
905
+ `;
906
+ }
907
+ function generateVueMain() {
908
+ return `import { createApp } from 'vue';
909
+ import App from './App.vue';
910
+
911
+ createApp(App).mount('#app');
912
+
913
+ // Auto-resize iframe to fit content
914
+ const observer = new ResizeObserver(() => {
915
+ const h = document.body.scrollHeight;
916
+ if (h > 0) tx5dr.resize(h);
917
+ });
918
+ observer.observe(document.body);
919
+ `;
920
+ }
921
+ function generateVueApp(config) {
922
+ return `<script setup lang="ts">
923
+ import { ref, onMounted, onUnmounted } from 'vue';
924
+ import './App.css';
925
+
926
+ const message = ref('');
927
+ const saved = ref('');
928
+ const status = ref('');
929
+
930
+ onMounted(async () => {
931
+ const settings = await tx5dr.invoke('getSettings') as any;
932
+ if (settings?.message) {
933
+ message.value = settings.message;
934
+ saved.value = settings.message;
935
+ }
936
+
937
+ tx5dr.onPush('settingsUpdated', handleUpdate);
938
+ });
939
+
940
+ onUnmounted(() => {
941
+ tx5dr.offPush('settingsUpdated', handleUpdate);
942
+ });
943
+
944
+ function handleUpdate(data: any) {
945
+ if (data?.message) {
946
+ message.value = data.message;
947
+ saved.value = data.message;
948
+ }
949
+ }
950
+
951
+ async function handleSave() {
952
+ if (!message.value.trim()) return;
953
+ status.value = 'Saving...';
954
+ try {
955
+ await tx5dr.invoke('saveSettings', { message: message.value });
956
+ saved.value = message.value;
957
+ status.value = 'Saved!';
958
+ setTimeout(() => { status.value = ''; }, 2000);
959
+ } catch (err: any) {
960
+ status.value = 'Error: ' + err.message;
961
+ }
962
+ }
963
+ </script>
964
+
965
+ <template>
966
+ <div class="container">
967
+ <h2 class="title">${config.name} Settings</h2>
968
+
969
+ <div class="section">
970
+ <label class="label">Message</label>
971
+ <input
972
+ class="input"
973
+ v-model="message"
974
+ placeholder="Enter a message"
975
+ @keydown.enter="handleSave"
976
+ />
977
+ </div>
978
+
979
+ <div class="section">
980
+ <button class="btn btn-primary" @click="handleSave">Save</button>
981
+ <span v-if="status" class="status">{{ status }}</span>
982
+ </div>
983
+
984
+ <div v-if="saved" class="section">
985
+ <p class="text-secondary">Current: {{ saved }}</p>
986
+ </div>
987
+ </div>
988
+ </template>
989
+ `;
990
+ }
991
+ // ===== Link script =====
992
+ function generateLinkScript(pluginName) {
993
+ return `#!/usr/bin/env node
994
+ // Symlink this plugin's dist/ to the TX-5DR plugins directory.
995
+ // Usage: node scripts/link.mjs (create link)
996
+ // node scripts/link.mjs --unlink (remove link)
997
+
998
+ import { symlinkSync, unlinkSync, existsSync, mkdirSync, writeFileSync, lstatSync } from 'node:fs';
999
+ import { join, resolve } from 'node:path';
1000
+ import { homedir, platform } from 'node:os';
1001
+
1002
+ const PLUGIN_NAME = '${pluginName}';
1003
+
1004
+ function getDataDir() {
1005
+ if (process.env.TX5DR_DATA_DIR) return process.env.TX5DR_DATA_DIR;
1006
+ const home = homedir();
1007
+ switch (platform()) {
1008
+ case 'darwin':
1009
+ return join(home, 'Library', 'Application Support', 'TX-5DR');
1010
+ case 'win32':
1011
+ return join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'TX-5DR');
1012
+ default:
1013
+ return join(process.env.XDG_DATA_HOME || join(home, '.local', 'share'), 'TX-5DR');
1014
+ }
1015
+ }
1016
+
1017
+ const unlink = process.argv.includes('--unlink');
1018
+ const pluginsDir = join(getDataDir(), 'plugins');
1019
+ const target = resolve('dist');
1020
+ const linkPath = join(pluginsDir, PLUGIN_NAME);
1021
+
1022
+ if (unlink) {
1023
+ if (existsSync(linkPath)) {
1024
+ unlinkSync(linkPath);
1025
+ console.log('Unlinked: ' + linkPath);
1026
+ } else {
1027
+ console.log('No link found at: ' + linkPath);
1028
+ }
1029
+ process.exit(0);
1030
+ }
1031
+
1032
+ if (!existsSync(target)) {
1033
+ console.error('Error: dist/ not found. Run "npm run build" first.');
1034
+ process.exit(1);
1035
+ }
1036
+
1037
+ if (existsSync(linkPath)) {
1038
+ try {
1039
+ const stat = lstatSync(linkPath);
1040
+ if (stat.isSymbolicLink()) {
1041
+ console.log('Already linked: ' + linkPath + ' -> ' + target);
1042
+ process.exit(0);
1043
+ }
1044
+ } catch { /* continue */ }
1045
+ console.error('Error: ' + linkPath + ' already exists and is not a symlink.');
1046
+ process.exit(1);
1047
+ }
1048
+
1049
+ mkdirSync(pluginsDir, { recursive: true });
1050
+ symlinkSync(target, linkPath, 'junction');
1051
+
1052
+ // Create .hotreload marker so the TX-5DR dev server auto-reloads on changes.
1053
+ const hotreloadPath = join(target, '.hotreload');
1054
+ if (!existsSync(hotreloadPath)) {
1055
+ writeFileSync(hotreloadPath, '', 'utf-8');
1056
+ }
1057
+
1058
+ console.log('Linked: ' + linkPath + ' -> ' + target);
1059
+ console.log('Created .hotreload marker for dev auto-reload.');
1060
+ `;
1061
+ }
403
1062
  // ===== File generation =====
404
1063
  function generateFiles(config) {
405
1064
  const files = new Map();
@@ -410,6 +1069,9 @@ function generateFiles(config) {
410
1069
  if (config.type === 'strategy') {
411
1070
  files.set('src/index.ts', generateTsStrategyPlugin(config));
412
1071
  }
1072
+ else if (hasUI(config)) {
1073
+ files.set('src/index.ts', generateTsUtilityPluginWithUI(config));
1074
+ }
413
1075
  else {
414
1076
  files.set('src/index.ts', generateTsUtilityPlugin(config));
415
1077
  }
@@ -418,7 +1080,41 @@ function generateFiles(config) {
418
1080
  files.set('src/__tests__/plugin.test.ts', generateTsTest(config));
419
1081
  }
420
1082
  else {
421
- files.set('index.js', generateJsUtilityPlugin(config));
1083
+ if (hasUI(config)) {
1084
+ files.set('index.js', generateJsUtilityPluginWithUI(config));
1085
+ }
1086
+ else {
1087
+ files.set('index.js', generateJsUtilityPlugin(config));
1088
+ }
1089
+ }
1090
+ // Vanilla UI template
1091
+ if (config.template === 'ui-vanilla') {
1092
+ files.set('ui/jsconfig.json', generateVanillaJsConfig());
1093
+ files.set('ui/settings.html', generateVanillaUIHtml(config));
1094
+ files.set('ui/settings.css', generateUICss());
1095
+ files.set('ui/settings.js', generateVanillaUIJs());
1096
+ }
1097
+ // React UI template
1098
+ if (config.template === 'ui-react') {
1099
+ files.set('ui/vite.config.ts', generateViteConfig(config));
1100
+ files.set('ui/tsconfig.json', generateUITsConfig(config));
1101
+ files.set('ui/settings.html', generateFrameworkEntryHtml(config));
1102
+ files.set('ui/src/main.tsx', generateReactMain());
1103
+ files.set('ui/src/App.tsx', generateReactApp(config));
1104
+ files.set('ui/src/App.css', generateUICss());
1105
+ }
1106
+ // Vue UI template
1107
+ if (config.template === 'ui-vue') {
1108
+ files.set('ui/vite.config.ts', generateViteConfig(config));
1109
+ files.set('ui/tsconfig.json', generateUITsConfig(config));
1110
+ files.set('ui/settings.html', generateFrameworkEntryHtml(config));
1111
+ files.set('ui/src/main.ts', generateVueMain());
1112
+ files.set('ui/src/App.vue', generateVueApp(config));
1113
+ files.set('ui/src/App.css', generateUICss());
1114
+ }
1115
+ // Link script for all templates with TS
1116
+ if (config.lang === 'ts') {
1117
+ files.set('scripts/link.mjs', generateLinkScript(config.name));
422
1118
  }
423
1119
  return files;
424
1120
  }
@@ -439,6 +1135,7 @@ async function main() {
439
1135
  console.log(`Creating TX-5DR plugin: ${config.name}`);
440
1136
  console.log(` Type: ${config.type}`);
441
1137
  console.log(` Language: ${config.lang === 'ts' ? 'TypeScript' : 'JavaScript'}`);
1138
+ console.log(` Template: ${config.template}`);
442
1139
  console.log(` Directory: ${targetDir}`);
443
1140
  console.log();
444
1141
  const files = generateFiles(config);
@@ -457,7 +1154,24 @@ async function main() {
457
1154
  console.log(' npm test');
458
1155
  }
459
1156
  console.log();
460
- console.log('Then copy the built plugin into your TX-5DR data/plugins/ directory.');
1157
+ if (config.lang === 'ts') {
1158
+ console.log('Link to TX-5DR for development:');
1159
+ console.log(' npm run link');
1160
+ console.log();
1161
+ }
1162
+ if (hasVite(config)) {
1163
+ console.log('UI development (run in two terminals):');
1164
+ console.log(' npm run dev:server # Watch server-side TypeScript');
1165
+ console.log(' npm run dev:ui # Watch UI with Vite');
1166
+ }
1167
+ else if (hasUI(config)) {
1168
+ console.log('UI files are in the ui/ directory.');
1169
+ console.log('Edit HTML/JS/CSS directly — the Bridge SDK is auto-injected by the host.');
1170
+ }
1171
+ if (hasUI(config) || hasVite(config)) {
1172
+ console.log();
1173
+ console.log('Docs: https://github.com/boybook/tx-5dr/blob/main/docs/plugin-system.md');
1174
+ }
461
1175
  }
462
1176
  main().catch((err) => {
463
1177
  console.error(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tx5dr-plugin",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold a new TX-5DR plugin project",
6
6
  "license": "MIT",