create-tx5dr-plugin 1.7.12 → 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,29 +256,42 @@ dist/
191
256
  function generateLocaleZh(config) {
192
257
  return JSON.stringify({
193
258
  pluginDescription: `${config.name} plugin`,
259
+ contestLogTitle: '比赛日志',
260
+ contestNewCallsign: '本波段新台',
261
+ contestNewMultiplier: '新系数',
262
+ enabled: '启用自动发射控制',
263
+ enabledDesc: '允许此插件通过宿主发射协调器影响当前操作员的自动通联',
194
264
  }, null, 2) + '\n';
195
265
  }
196
266
  function generateLocaleEn(config) {
197
267
  return JSON.stringify({
198
268
  pluginDescription: `${config.name} plugin`,
269
+ contestLogTitle: 'Contest log',
270
+ contestNewCallsign: 'New on band',
271
+ contestNewMultiplier: 'New multiplier',
272
+ enabled: 'Enable automatic transmit control',
273
+ enabledDesc: 'Allow this plugin to influence operator automation through the host coordinator',
199
274
  }, null, 2) + '\n';
200
275
  }
201
276
  // ===== Server-side plugin definition templates =====
202
277
  function generateTsUtilityPlugin(config) {
203
- return `import type {
204
- PluginDefinition,
205
- PluginContext,
206
- ParsedFT8Message,
207
- SlotInfo,
278
+ return `import {
279
+ definePlugin,
280
+ type ParsedFT8Message,
281
+ type SlotInfo,
208
282
  } from '@tx5dr/plugin-api';
209
283
  import zhLocale from './locales/zh.json' with { type: 'json' };
210
284
  import enLocale from './locales/en.json' with { type: 'json' };
211
285
 
212
- export const plugin: PluginDefinition = {
286
+ export const plugin = definePlugin({
287
+ apiVersion: 2,
213
288
  name: '${config.name}',
214
289
  version: '0.1.0',
215
290
  type: 'utility',
216
291
  description: 'pluginDescription',
292
+ // Add only the capabilities this plugin actually uses. The host omits all
293
+ // undeclared privileged APIs from both the TypeScript type and runtime context.
294
+ permissions: [],
217
295
 
218
296
  settings: {
219
297
  // Define your plugin settings here
@@ -227,18 +305,18 @@ export const plugin: PluginDefinition = {
227
305
  },
228
306
 
229
307
  hooks: {
230
- onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx: PluginContext): void {
308
+ onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx): void {
231
309
  ctx.log.debug('Slot started', { slotId: slotInfo.id, messageCount: messages.length });
232
310
  },
233
311
 
234
- onDecode(messages: ParsedFT8Message[], ctx: PluginContext): void {
312
+ onDecode(messages: ParsedFT8Message[], ctx): void {
235
313
  // Process decoded messages
236
314
  for (const msg of messages) {
237
315
  ctx.log.debug('Decoded message', { raw: msg.rawMessage, snr: msg.snr });
238
316
  }
239
317
  },
240
318
  },
241
- };
319
+ });
242
320
 
243
321
  export const locales: Record<string, Record<string, string>> = {
244
322
  zh: zhLocale,
@@ -249,19 +327,20 @@ export default plugin;
249
327
  `;
250
328
  }
251
329
  function generateTsStrategyPlugin(config) {
252
- return `import type {
253
- PluginDefinition,
254
- PluginContext,
255
- StrategyRuntime,
256
- StrategyRuntimeSnapshot,
257
- StrategyRuntimeSlot,
258
- StrategyRuntimeSlotContentUpdate,
259
- StrategyRuntimeContext,
260
- ParsedFT8Message,
261
- StrategyDecision,
262
- StrategyDecisionMeta,
263
- FrameMessage,
264
- SlotInfo,
330
+ return `import {
331
+ definePlugin,
332
+ type StrategyPluginContext,
333
+ type StrategyRuntime,
334
+ type StrategyRuntimeSnapshot,
335
+ type StrategyRuntimeSlot,
336
+ type StrategyRuntimeSlotContentUpdate,
337
+ type StrategyRuntimeContext,
338
+ type StrategyRuntimeCheckpoint,
339
+ type ParsedFT8Message,
340
+ type StrategyDecisionResult,
341
+ type StrategyDecisionMetaV2,
342
+ type FrameMessage,
343
+ type SlotInfo,
265
344
  } from '@tx5dr/plugin-api';
266
345
  import zhLocale from './locales/zh.json' with { type: 'json' };
267
346
  import enLocale from './locales/en.json' with { type: 'json' };
@@ -271,11 +350,32 @@ class PluginRuntime implements StrategyRuntime {
271
350
  private slots: Partial<Record<StrategyRuntimeSlot, string>> = {};
272
351
  private context: StrategyRuntimeContext = {};
273
352
 
274
- constructor(private ctx: PluginContext) {}
353
+ constructor(private ctx: StrategyPluginContext) {}
354
+
355
+ checkpoint(): StrategyRuntimeCheckpoint {
356
+ return structuredClone({ state: this.state, slots: this.slots, context: this.context });
357
+ }
358
+
359
+ restore(checkpoint: StrategyRuntimeCheckpoint): void {
360
+ const saved = checkpoint as {
361
+ state: StrategyRuntimeSlot;
362
+ slots: Partial<Record<StrategyRuntimeSlot, string>>;
363
+ context: StrategyRuntimeContext;
364
+ };
365
+ this.state = saved.state;
366
+ this.slots = { ...saved.slots };
367
+ this.context = { ...saved.context };
368
+ }
275
369
 
276
- decide(messages: ParsedFT8Message[], meta?: StrategyDecisionMeta): StrategyDecision {
370
+ decide(messages: ParsedFT8Message[], meta: StrategyDecisionMetaV2): StrategyDecisionResult {
371
+ if (meta.signal.aborted) {
372
+ throw meta.signal.reason ?? new Error('Strategy decision aborted');
373
+ }
277
374
  // Implement your QSO strategy logic here
278
- return {};
375
+ return {
376
+ transmission: this.getTransmitText(),
377
+ snapshot: this.getSnapshot(),
378
+ };
279
379
  }
280
380
 
281
381
  getTransmitText(): string | null {
@@ -317,15 +417,16 @@ class PluginRuntime implements StrategyRuntime {
317
417
  }
318
418
  }
319
419
 
320
- export const plugin: PluginDefinition = {
420
+ export const plugin = definePlugin({
421
+ apiVersion: 2,
321
422
  name: '${config.name}',
322
423
  version: '0.1.0',
323
424
  type: 'strategy',
324
425
  description: 'pluginDescription',
426
+ // Selecting a strategy is the explicit grant for declarative RF decisions.
427
+ // Add operator:transmit-control only to utility plugins that submit commands.
325
428
 
326
- settings: {},
327
-
328
- createStrategyRuntime(ctx: PluginContext): StrategyRuntime {
429
+ createStrategyRuntime(ctx: StrategyPluginContext): StrategyRuntime {
329
430
  return new PluginRuntime(ctx);
330
431
  },
331
432
 
@@ -334,31 +435,183 @@ export const plugin: PluginDefinition = {
334
435
  ctx.log.debug('Slot started', { slotId: slotInfo.id });
335
436
  },
336
437
  },
438
+ });
439
+
440
+ export const locales: Record<string, Record<string, string>> = {
441
+ zh: zhLocale,
442
+ en: enLocale,
443
+ };
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;
337
484
  };
338
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
+
339
589
  export const locales: Record<string, Record<string, string>> = {
340
590
  zh: zhLocale,
341
591
  en: enLocale,
592
+ ja: jaLocale,
342
593
  };
343
594
 
344
595
  export default plugin;
345
596
  `;
346
597
  }
347
598
  function generateTsUtilityPluginWithUI(config) {
348
- return `import type {
349
- PluginDefinition,
350
- PluginContext,
351
- ParsedFT8Message,
352
- SlotInfo,
599
+ return `import {
600
+ definePlugin,
601
+ type ParsedFT8Message,
602
+ type SlotInfo,
353
603
  } from '@tx5dr/plugin-api';
354
604
  import zhLocale from './locales/zh.json' with { type: 'json' };
355
605
  import enLocale from './locales/en.json' with { type: 'json' };
356
606
 
357
- export const plugin: PluginDefinition = {
607
+ export const plugin = definePlugin({
608
+ apiVersion: 2,
358
609
  name: '${config.name}',
359
610
  version: '0.1.0',
360
611
  type: 'utility',
361
612
  description: 'pluginDescription',
613
+ // Add only the capabilities this plugin actually uses.
614
+ permissions: [],
362
615
 
363
616
  settings: {
364
617
  // Define your plugin settings here
@@ -372,11 +625,12 @@ export const plugin: PluginDefinition = {
372
625
  entry: 'settings.html',
373
626
  title: 'settingsPage',
374
627
  accessScope: 'admin',
628
+ resourceBinding: 'none',
375
629
  },
376
630
  ],
377
631
  },
378
632
 
379
- onLoad(ctx: PluginContext): void {
633
+ onLoad(ctx): void {
380
634
  ctx.ui.registerPageHandler({
381
635
  async onMessage(pageId, action, data) {
382
636
  if (action === 'getSettings') {
@@ -396,17 +650,17 @@ export const plugin: PluginDefinition = {
396
650
  },
397
651
 
398
652
  hooks: {
399
- onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx: PluginContext): void {
653
+ onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx): void {
400
654
  ctx.log.debug('Slot started', { slotId: slotInfo.id, messageCount: messages.length });
401
655
  },
402
656
 
403
- onDecode(messages: ParsedFT8Message[], ctx: PluginContext): void {
657
+ onDecode(messages: ParsedFT8Message[], ctx): void {
404
658
  for (const msg of messages) {
405
659
  ctx.log.debug('Decoded message', { raw: msg.rawMessage, snr: msg.snr });
406
660
  }
407
661
  },
408
662
  },
409
- };
663
+ });
410
664
 
411
665
  export const locales: Record<string, Record<string, string>> = {
412
666
  zh: zhLocale,
@@ -417,12 +671,15 @@ export default plugin;
417
671
  `;
418
672
  }
419
673
  function generateJsUtilityPlugin(config) {
420
- return `/** @type {import('@tx5dr/plugin-api').PluginDefinition} */
421
- export const plugin = {
674
+ return `import { definePlugin } from '@tx5dr/plugin-api';
675
+
676
+ export const plugin = definePlugin({
677
+ apiVersion: 2,
422
678
  name: '${config.name}',
423
679
  version: '0.1.0',
424
680
  type: 'utility',
425
681
  description: 'pluginDescription',
682
+ permissions: [],
426
683
 
427
684
  settings: {
428
685
  // Define your plugin settings here
@@ -439,18 +696,21 @@ export const plugin = {
439
696
  }
440
697
  },
441
698
  },
442
- };
699
+ });
443
700
 
444
701
  export default plugin;
445
702
  `;
446
703
  }
447
704
  function generateJsUtilityPluginWithUI(config) {
448
- return `/** @type {import('@tx5dr/plugin-api').PluginDefinition} */
449
- export const plugin = {
705
+ return `import { definePlugin } from '@tx5dr/plugin-api';
706
+
707
+ export const plugin = definePlugin({
708
+ apiVersion: 2,
450
709
  name: '${config.name}',
451
710
  version: '0.1.0',
452
711
  type: 'utility',
453
712
  description: 'pluginDescription',
713
+ permissions: [],
454
714
 
455
715
  settings: {},
456
716
 
@@ -462,6 +722,7 @@ export const plugin = {
462
722
  entry: 'settings.html',
463
723
  title: 'settingsPage',
464
724
  accessScope: 'admin',
725
+ resourceBinding: 'none',
465
726
  },
466
727
  ],
467
728
  },
@@ -495,13 +756,55 @@ export const plugin = {
495
756
  }
496
757
  },
497
758
  },
498
- };
759
+ });
499
760
 
500
761
  export default plugin;
501
762
  `;
502
763
  }
503
764
  // ===== Test templates =====
504
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
+ }
505
808
  if (config.type === 'strategy') {
506
809
  return `import { describe, it, expect } from 'vitest';
507
810
  import { createMockContext, createMockSlotInfo, createMockParsedMessage } from '@tx5dr/plugin-api/testing';
@@ -1066,7 +1369,13 @@ function generateFiles(config) {
1066
1369
  files.set('.gitignore', generateGitignore());
1067
1370
  if (config.lang === 'ts') {
1068
1371
  files.set('tsconfig.json', generateTsConfig());
1069
- 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') {
1070
1379
  files.set('src/index.ts', generateTsStrategyPlugin(config));
1071
1380
  }
1072
1381
  else if (hasUI(config)) {
@@ -1077,6 +1386,8 @@ function generateFiles(config) {
1077
1386
  }
1078
1387
  files.set('src/locales/zh.json', generateLocaleZh(config));
1079
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');
1080
1391
  files.set('src/__tests__/plugin.test.ts', generateTsTest(config));
1081
1392
  }
1082
1393
  else {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "create-tx5dr-plugin",
3
- "version": "1.7.12",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold a new TX-5DR plugin project",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/boybook/tx-5dr.git",
9
+ "url": "https://github.com/boybook/tx-5dr",
10
10
  "directory": "packages/create-tx5dr-plugin"
11
11
  },
12
12
  "keywords": [
@@ -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"