create-tx5dr-plugin 2.0.0 → 2.5.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 CHANGED
@@ -14,7 +14,7 @@ npx create-tx5dr-plugin [name] [options]
14
14
  |------|-------------|---------|
15
15
  | `--type <utility\|strategy>` | Plugin type | `utility` |
16
16
  | `--lang <ts\|js>` | Language | `ts` |
17
- | `--template <basic\|ui-vanilla\|ui-react\|ui-vue>` | Project template | `basic` |
17
+ | `--template <basic\|ui-vanilla\|ui-react\|ui-vue\|ft8-contest>` | Project template | `basic` |
18
18
  | `--help, -h` | Show help | |
19
19
 
20
20
  ### Templates
@@ -25,6 +25,12 @@ npx create-tx5dr-plugin [name] [options]
25
25
  | `ui-vanilla` | Plugin with vanilla HTML/JS/CSS UI page |
26
26
  | `ui-react` | Plugin with React + Vite UI page |
27
27
  | `ui-vue` | Plugin with Vue + Vite UI page |
28
+ | `ft8-contest` | TypeScript strategy with composable FT8/FT4 contest rules and tests |
29
+
30
+ `ft8-contest` fixes the type to `strategy` and language to `ts`; conflicting
31
+ explicit `--type` or `--lang` values are rejected. Its build emits declarations
32
+ with TypeScript and a self-contained ESM `dist/index.mjs` with esbuild, so the
33
+ Host can load the linked plugin directory without the project `node_modules`.
28
34
 
29
35
  ### Examples
30
36
 
@@ -46,6 +52,9 @@ npx create-tx5dr-plugin my-plugin --template ui-vue
46
52
 
47
53
  # Vanilla UI (no build step for UI files)
48
54
  npx create-tx5dr-plugin my-plugin --template ui-vanilla
55
+
56
+ # FT8 contest strategy with rule modules and test kit
57
+ npx create-tx5dr-plugin my-contest --template ft8-contest
49
58
  ```
50
59
 
51
60
  ## Generated Structure
package/dist/index.d.ts CHANGED
@@ -7,5 +7,6 @@
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
9
  * npx create-tx5dr-plugin my-plugin --template ui-react
10
+ * npx create-tx5dr-plugin my-contest --template ft8-contest
10
11
  */
11
12
  export {};
package/dist/index.js CHANGED
@@ -7,11 +7,14 @@
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
9
  * npx create-tx5dr-plugin my-plugin --template ui-react
10
+ * npx create-tx5dr-plugin my-contest --template ft8-contest
10
11
  */
11
- import { mkdirSync, writeFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
13
+ import { createRequire } from 'node:module';
12
14
  import { join, resolve } from 'node:path';
13
15
  import { createInterface } from 'node:readline';
14
- const VALID_TEMPLATES = ['basic', 'ui-vanilla', 'ui-react', 'ui-vue'];
16
+ const require = createRequire(import.meta.url);
17
+ const VALID_TEMPLATES = ['basic', 'ui-vanilla', 'ui-react', 'ui-vue', 'ft8-contest'];
15
18
  // ===== CLI argument parsing =====
16
19
  function parseArgs() {
17
20
  const args = process.argv.slice(2);
@@ -58,7 +61,7 @@ function printUsage() {
58
61
  Options:
59
62
  --type <utility|strategy> Plugin type (default: utility)
60
63
  --lang <ts|js> Language (default: ts)
61
- --template <basic|ui-vanilla|ui-react|ui-vue> Template (default: basic)
64
+ --template <basic|ui-vanilla|ui-react|ui-vue|ft8-contest> Template (default: basic)
62
65
  --help, -h Show this help message
63
66
 
64
67
  Templates:
@@ -66,12 +69,14 @@ function printUsage() {
66
69
  ui-vanilla Plugin with vanilla HTML/JS/CSS UI page
67
70
  ui-react Plugin with React + Vite UI page
68
71
  ui-vue Plugin with Vue + Vite UI page
72
+ ft8-contest TypeScript strategy with composable FT8 contest rules
69
73
 
70
74
  Examples:
71
75
  npx create-tx5dr-plugin my-plugin
72
76
  npx create-tx5dr-plugin my-plugin --type strategy
73
77
  npx create-tx5dr-plugin my-plugin --template ui-react
74
78
  npx create-tx5dr-plugin my-plugin --template ui-vue --type utility
79
+ npx create-tx5dr-plugin my-contest --template ft8-contest
75
80
  `);
76
81
  }
77
82
  // ===== Interactive prompts =====
@@ -88,21 +93,29 @@ async function promptConfig(partial) {
88
93
  console.error('Plugin name is required.');
89
94
  process.exit(1);
90
95
  }
91
- let type = partial.type;
92
- if (!type) {
96
+ let template = partial.template;
97
+ if (!template) {
98
+ const answer = await prompt(rl, 'Template (basic/ui-vanilla/ui-react/ui-vue/ft8-contest) [basic]: ');
99
+ template = VALID_TEMPLATES.includes(answer) ? answer : 'basic';
100
+ }
101
+ if (template === 'ft8-contest' && partial.type && partial.type !== 'strategy') {
102
+ console.error('The ft8-contest template requires --type strategy.');
103
+ process.exit(1);
104
+ }
105
+ if (template === 'ft8-contest' && partial.lang && partial.lang !== 'ts') {
106
+ console.error('The ft8-contest template requires --lang ts.');
107
+ process.exit(1);
108
+ }
109
+ let type = template === 'ft8-contest' ? 'strategy' : partial.type ?? 'utility';
110
+ if (!partial.type && template !== 'ft8-contest') {
93
111
  const answer = await prompt(rl, 'Plugin type (utility/strategy) [utility]: ');
94
112
  type = answer === 'strategy' ? 'strategy' : 'utility';
95
113
  }
96
- let lang = partial.lang;
97
- if (!lang) {
114
+ let lang = template === 'ft8-contest' ? 'ts' : partial.lang ?? 'ts';
115
+ if (!partial.lang && template !== 'ft8-contest') {
98
116
  const answer = await prompt(rl, 'Language (ts/js) [ts]: ');
99
117
  lang = answer === 'js' ? 'js' : 'ts';
100
118
  }
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
119
  return { name, type, lang, template };
107
120
  }
108
121
  finally {
@@ -111,19 +124,42 @@ async function promptConfig(partial) {
111
124
  }
112
125
  // ===== Helpers =====
113
126
  function hasUI(config) {
114
- return config.template !== 'basic';
127
+ return config.template === 'ui-vanilla'
128
+ || config.template === 'ui-react'
129
+ || config.template === 'ui-vue';
115
130
  }
116
131
  function hasVite(config) {
117
132
  return config.template === 'ui-react' || config.template === 'ui-vue';
118
133
  }
134
+ function readContestLogbookUiAssets() {
135
+ const entry = require.resolve('@tx5dr/plugin-api/contest-logbook-ui/contest-log.html');
136
+ const root = resolve(entry, '..');
137
+ const files = new Map();
138
+ const visit = (directory, prefix) => {
139
+ for (const name of readdirSync(directory)) {
140
+ const source = join(directory, name);
141
+ const relative = join(prefix, name);
142
+ if (statSync(source).isDirectory())
143
+ visit(source, relative);
144
+ else
145
+ files.set(`ui/${relative}`, readFileSync(source, 'utf8'));
146
+ }
147
+ };
148
+ if (!existsSync(root))
149
+ throw new Error('contest-logbook-ui-assets-missing');
150
+ visit(root, '');
151
+ return files;
152
+ }
119
153
  // ===== Core template generation =====
120
154
  function generatePackageJson(config) {
121
155
  const devDeps = {
122
- '@tx5dr/plugin-api': 'latest',
156
+ '@tx5dr/plugin-api': '^2.5.0',
123
157
  };
124
158
  if (config.lang === 'ts') {
125
159
  devDeps['typescript'] = '^5.0.0';
126
160
  devDeps['vitest'] = '^1.0.0';
161
+ if (config.template === 'ft8-contest')
162
+ devDeps['esbuild'] = '^0.25.0';
127
163
  }
128
164
  if (hasVite(config)) {
129
165
  devDeps['vite'] = '^6.0.0';
@@ -141,7 +177,14 @@ function generatePackageJson(config) {
141
177
  }
142
178
  const scripts = {};
143
179
  if (config.lang === 'ts') {
144
- if (hasVite(config)) {
180
+ if (config.template === 'ft8-contest') {
181
+ scripts['build'] = 'npm run build:types && npm run build:bundle';
182
+ scripts['build:types'] = 'tsc --emitDeclarationOnly';
183
+ scripts['build:bundle'] = 'esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --legal-comments=none --outfile=dist/index.mjs';
184
+ scripts['typecheck'] = 'tsc --noEmit';
185
+ scripts['dev'] = 'esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --legal-comments=none --outfile=dist/index.mjs --watch';
186
+ }
187
+ else if (hasVite(config)) {
145
188
  scripts['build'] = 'tsc && npm run build:ui';
146
189
  scripts['build:ui'] = 'vite build --config ui/vite.config.ts';
147
190
  scripts['dev:server'] = 'tsc --watch';
@@ -159,11 +202,33 @@ function generatePackageJson(config) {
159
202
  version: '0.1.0',
160
203
  type: 'module',
161
204
  ...(config.lang === 'ts'
162
- ? { main: 'dist/index.js', types: 'dist/index.d.ts' }
205
+ ? {
206
+ main: config.template === 'ft8-contest' ? 'dist/index.mjs' : 'dist/index.js',
207
+ types: 'dist/index.d.ts',
208
+ }
163
209
  : { main: 'index.js' }),
164
210
  scripts,
165
211
  devDependencies: devDeps,
166
212
  };
213
+ if (config.template === 'ft8-contest') {
214
+ pkg.tx5drPlugin = {
215
+ pluginName: config.name,
216
+ title: config.name,
217
+ description: `${config.name} FT8/FT4 contest strategy`,
218
+ minPluginApiVersion: '2.5.0',
219
+ author: 'TX-5DR plugin author',
220
+ license: 'GPL-3.0-only',
221
+ categories: ['contest', 'ft8', 'ft4'],
222
+ keywords: ['amateur-radio', 'contest', config.name],
223
+ entry: 'dist/index.mjs',
224
+ include: [
225
+ { from: 'dist/index.mjs', to: 'index.mjs' },
226
+ { from: 'ui', to: 'ui' },
227
+ { from: 'src/locales', to: 'locales' },
228
+ { from: 'README.md', to: 'README.md' },
229
+ ],
230
+ };
231
+ }
167
232
  return JSON.stringify(pkg, null, 2) + '\n';
168
233
  }
169
234
  function generateTsConfig() {
@@ -191,6 +256,9 @@ dist/
191
256
  function generateLocaleZh(config) {
192
257
  return JSON.stringify({
193
258
  pluginDescription: `${config.name} plugin`,
259
+ contestLogTitle: '比赛日志',
260
+ contestNewCallsign: '本波段新台',
261
+ contestNewMultiplier: '新系数',
194
262
  enabled: '启用自动发射控制',
195
263
  enabledDesc: '允许此插件通过宿主发射协调器影响当前操作员的自动通联',
196
264
  }, null, 2) + '\n';
@@ -198,6 +266,9 @@ function generateLocaleZh(config) {
198
266
  function generateLocaleEn(config) {
199
267
  return JSON.stringify({
200
268
  pluginDescription: `${config.name} plugin`,
269
+ contestLogTitle: 'Contest log',
270
+ contestNewCallsign: 'New on band',
271
+ contestNewMultiplier: 'New multiplier',
201
272
  enabled: 'Enable automatic transmit control',
202
273
  enabledDesc: 'Allow this plugin to influence operator automation through the host coordinator',
203
274
  }, null, 2) + '\n';
@@ -371,6 +442,156 @@ export const locales: Record<string, Record<string, string>> = {
371
442
  en: enLocale,
372
443
  };
373
444
 
445
+ export default plugin;
446
+ `;
447
+ }
448
+ function generateTsFT8ContestPlugin(config) {
449
+ return `import {
450
+ type FrameMessage,
451
+ type ParsedFT8Message,
452
+ type SlotInfo,
453
+ type StrategyDecisionMetaV2,
454
+ type StrategyDecisionResult,
455
+ type StrategyPluginContext,
456
+ type StrategyRuntime,
457
+ type StrategyRuntimeCheckpoint,
458
+ type StrategyRuntimeContext,
459
+ type StrategyRuntimeSlot,
460
+ type StrategyRuntimeSlotContentUpdate,
461
+ type StrategyRuntimeSnapshot,
462
+ } from '@tx5dr/plugin-api';
463
+ import {
464
+ cabrilloSubmission,
465
+ CONTEST_LOGBOOK_PERMISSIONS,
466
+ composeFT8ContestPlugin,
467
+ createContestQsoEnvelopeAdapter,
468
+ defineFT8Contest,
469
+ distancePoints,
470
+ fixedWeekendEdition,
471
+ gridExchange,
472
+ requireExchangeAndFinalAck,
473
+ type FT8ContestQso,
474
+ standardFT8ContestLogbook,
475
+ type GridExchange,
476
+ } from '@tx5dr/plugin-api/contest';
477
+ import zhLocale from './locales/zh.json' with { type: 'json' };
478
+ import enLocale from './locales/en.json' with { type: 'json' };
479
+ import jaLocale from './locales/ja.json' with { type: 'json' };
480
+
481
+ export type ContestQso = FT8ContestQso<GridExchange> & {
482
+ frequencyKhz: number;
483
+ cabrilloDateTime: string;
484
+ };
485
+
486
+ export const contest = defineFT8Contest<GridExchange, ContestQso>({
487
+ id: '${config.name}',
488
+ rulesetVersion: '2026.1',
489
+ edition: fixedWeekendEdition({
490
+ id: '2026',
491
+ // Replace these example boundaries and record the official rule source.
492
+ startAt: '2026-01-03T00:00:00Z',
493
+ endAt: '2026-01-04T00:00:00Z',
494
+ }),
495
+ bands: ['80M', '40M', '20M', '15M', '10M'],
496
+ exchange: gridExchange(),
497
+ // Completion is explicit because it belongs to the RF fail-closed boundary.
498
+ completion: requireExchangeAndFinalAck(),
499
+ scoring: distancePoints<ContestQso>({ stepKm: 3000 }),
500
+ submission: cabrilloSubmission<ContestQso>({
501
+ headers: () => [['CONTEST', '${config.name.toUpperCase()}']],
502
+ qsoLine: (qso) =>
503
+ \`QSO: \${qso.frequencyKhz} DG \${qso.cabrilloDateTime} \${qso.callsign}\`,
504
+ }),
505
+ });
506
+
507
+ export const contestEnvelope = createContestQsoEnvelopeAdapter(contest);
508
+
509
+ export const logbook = standardFT8ContestLogbook({ contest });
510
+
511
+ class ContestRuntime implements StrategyRuntime {
512
+ private state: StrategyRuntimeSlot = 'TX6';
513
+ private slots: Partial<Record<StrategyRuntimeSlot, string>> = {};
514
+ private context: StrategyRuntimeContext = {};
515
+
516
+ constructor(private readonly ctx: StrategyPluginContext) {}
517
+
518
+ checkpoint(): StrategyRuntimeCheckpoint {
519
+ return structuredClone({ state: this.state, slots: this.slots, context: this.context });
520
+ }
521
+
522
+ restore(checkpoint: StrategyRuntimeCheckpoint): void {
523
+ const saved = checkpoint as {
524
+ state: StrategyRuntimeSlot;
525
+ slots: Partial<Record<StrategyRuntimeSlot, string>>;
526
+ context: StrategyRuntimeContext;
527
+ };
528
+ this.state = saved.state;
529
+ this.slots = { ...saved.slots };
530
+ this.context = { ...saved.context };
531
+ }
532
+
533
+ decide(messages: ParsedFT8Message[], meta: StrategyDecisionMetaV2): StrategyDecisionResult {
534
+ if (meta.signal.aborted) throw meta.signal.reason ?? new Error('Strategy decision aborted');
535
+ // Apply the contest exchange/completion modules in your protocol reducer here.
536
+ return { transmission: this.getTransmitText(), snapshot: this.getSnapshot() };
537
+ }
538
+
539
+ getTransmitText(): string | null {
540
+ return this.slots[this.state] ?? null;
541
+ }
542
+
543
+ requestCall(callsign: string, lastMessage?: { message: FrameMessage; slotInfo: SlotInfo }): void {
544
+ this.context.targetCallsign = callsign;
545
+ this.state = 'TX1';
546
+ this.ctx.log.info('Contest call requested', { callsign });
547
+ }
548
+
549
+ getSnapshot(): StrategyRuntimeSnapshot {
550
+ return {
551
+ currentState: this.state,
552
+ slots: { ...this.slots },
553
+ context: { ...this.context },
554
+ availableSlots: ['TX1', 'TX2', 'TX3', 'TX4', 'TX5', 'TX6'],
555
+ };
556
+ }
557
+
558
+ patchContext(patch: Partial<StrategyRuntimeContext>): void {
559
+ Object.assign(this.context, patch);
560
+ }
561
+
562
+ setState(state: StrategyRuntimeSlot): void {
563
+ this.state = state;
564
+ }
565
+
566
+ setSlotContent(update: StrategyRuntimeSlotContentUpdate): void {
567
+ this.slots[update.slot] = update.content;
568
+ }
569
+
570
+ reset(reason?: string): void {
571
+ this.state = 'TX6';
572
+ this.slots = {};
573
+ this.context = {};
574
+ this.ctx.log.info('Contest strategy reset', { reason });
575
+ }
576
+ }
577
+
578
+ export const plugin = composeFT8ContestPlugin({
579
+ name: '${config.name}',
580
+ version: '0.1.0',
581
+ minPluginApiVersion: '2.5.0',
582
+ description: 'pluginDescription',
583
+ permissions: CONTEST_LOGBOOK_PERMISSIONS,
584
+ contest,
585
+ logbook,
586
+ runtime: (_contest, context) => new ContestRuntime(context),
587
+ });
588
+
589
+ export const locales: Record<string, Record<string, string>> = {
590
+ zh: zhLocale,
591
+ en: enLocale,
592
+ ja: jaLocale,
593
+ };
594
+
374
595
  export default plugin;
375
596
  `;
376
597
  }
@@ -542,6 +763,48 @@ export default plugin;
542
763
  }
543
764
  // ===== Test templates =====
544
765
  function generateTsTest(config) {
766
+ if (config.template === 'ft8-contest') {
767
+ return `import { describe, it, expect } from 'vitest';
768
+ import { createFT8ContestTestKit } from '@tx5dr/plugin-api/contest';
769
+ import { contest, contestEnvelope, plugin } from '../index.js';
770
+
771
+ describe('${config.name}', () => {
772
+ const kit = createFT8ContestTestKit(contest);
773
+
774
+ it('exposes an FT8 strategy plugin', () => {
775
+ expect(plugin.type).toBe('strategy');
776
+ expect(plugin.minPluginApiVersion).toBe('2.5.0');
777
+ expect(contest.operating).toMatchObject({
778
+ humanInitiation: 'required',
779
+ maxConcurrentQsos: 1,
780
+ maxSimultaneousSignals: 1,
781
+ });
782
+ });
783
+
784
+ it('round-trips the contest exchange', () => {
785
+ kit.exchange({ grid: 'FN31' }, { grid: 'FN31' });
786
+ kit.invalidExchange({ grid: 'ZZ99' }, 'invalid_grid');
787
+ });
788
+
789
+ it('requires exchange and a final acknowledgement', () => {
790
+ kit.completion({
791
+ sentExchange: { grid: 'PL04' },
792
+ receivedExchange: { grid: 'FN31' },
793
+ receivedFinalAck: true,
794
+ }, true);
795
+ });
796
+
797
+ it('creates an identity-bound contest QSO envelope', () => {
798
+ const envelope = contestEnvelope.create({
799
+ sent: { grid: 'PL04' },
800
+ received: { grid: 'FN31' },
801
+ });
802
+ expect(contestEnvelope.validate(envelope).ok).toBe(true);
803
+ expect(envelope.rulesetVersion).toBe(contest.rulesetVersion);
804
+ });
805
+ });
806
+ `;
807
+ }
545
808
  if (config.type === 'strategy') {
546
809
  return `import { describe, it, expect } from 'vitest';
547
810
  import { createMockContext, createMockSlotInfo, createMockParsedMessage } from '@tx5dr/plugin-api/testing';
@@ -1106,7 +1369,13 @@ function generateFiles(config) {
1106
1369
  files.set('.gitignore', generateGitignore());
1107
1370
  if (config.lang === 'ts') {
1108
1371
  files.set('tsconfig.json', generateTsConfig());
1109
- if (config.type === 'strategy') {
1372
+ if (config.template === 'ft8-contest') {
1373
+ files.set('src/index.ts', generateTsFT8ContestPlugin(config));
1374
+ for (const [relativePath, content] of readContestLogbookUiAssets())
1375
+ files.set(relativePath, content);
1376
+ files.set('README.md', `# ${config.name}\n\nFT8/FT4 contest strategy plugin for TX-5DR.\n`);
1377
+ }
1378
+ else if (config.type === 'strategy') {
1110
1379
  files.set('src/index.ts', generateTsStrategyPlugin(config));
1111
1380
  }
1112
1381
  else if (hasUI(config)) {
@@ -1117,6 +1386,8 @@ function generateFiles(config) {
1117
1386
  }
1118
1387
  files.set('src/locales/zh.json', generateLocaleZh(config));
1119
1388
  files.set('src/locales/en.json', generateLocaleEn(config));
1389
+ if (config.template === 'ft8-contest')
1390
+ files.set('src/locales/ja.json', JSON.stringify({ pluginDescription: `${config.name} plugin`, contestLogTitle: 'コンテストログ', contestNewCallsign: 'このバンドで未交信', contestNewMultiplier: '新マルチ' }, null, 2) + '\n');
1120
1391
  files.set('src/__tests__/plugin.test.ts', generateTsTest(config));
1121
1392
  }
1122
1393
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tx5dr-plugin",
3
- "version": "2.0.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold a new TX-5DR plugin project",
6
6
  "license": "MIT",
@@ -22,7 +22,11 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "build": "tsc",
25
- "dev": "tsc --watch"
25
+ "dev": "tsc --watch",
26
+ "smoke:ft8-contest": "node scripts/ft8-contest-smoke.mjs"
27
+ },
28
+ "dependencies": {
29
+ "@tx5dr/plugin-api": "^2.5.0"
26
30
  },
27
31
  "devDependencies": {
28
32
  "typescript": "^5.0.0"