@springfield/ham-radio-utils 4.16.0 → 4.17.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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Build one local beta driver zip (ADR 0010).
3
+ *
4
+ * Usage from a radio module repo: `yarn pack:beta <modelId> [n]`
5
+ * n — positive beta counter (default 1, no leading zero).
6
+ *
7
+ * Writes `dist-release/radio-module-<modelId>-<version>-beta.<n>.zip` without
8
+ * modifying the working tree, catalog entries, or other zips in `dist-release/`.
9
+ * Does not call GitHub and does not skip a model that already has a release asset.
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { collectRefClosure } from './ref-closure.js';
17
+ import { assertStrictSemver, BETA_COUNTER_PATTERN, packedBetaVersion, parseBetaCounter } from './driver-semver.js';
18
+ export { assertStrictSemver, BETA_COUNTER_PATTERN, BETA_VERSION_PATTERN, compareDriverVersions, compareToCatalogVersion, packedBetaVersion, parseBetaCounter, STRICT_SEMVER_PATTERN, } from './driver-semver.js';
19
+ export const MAX_BETA_ZIP_BYTES = 1024 * 1024;
20
+ export function expectedBetaZipName(modelId, baseVersion, n) {
21
+ return `radio-module-${modelId}-${baseVersion}-beta.${n}.zip`;
22
+ }
23
+ export function packBetaZip(moduleRoot, modelId, n = 1, options = {}) {
24
+ if (!Number.isSafeInteger(n) || !BETA_COUNTER_PATTERN.test(String(n))) {
25
+ throw new Error('beta counter n must be a positive integer with no leading zero');
26
+ }
27
+ const descriptor = loadDescriptor(moduleRoot, modelId);
28
+ validateBetaDescriptor(descriptor);
29
+ const pathsToZip = closurePathsForConfig(moduleRoot, descriptor);
30
+ const version = packedBetaVersion(descriptor.configVersion, n);
31
+ const zipName = expectedBetaZipName(descriptor.modelId, descriptor.configVersion, n);
32
+ const outputDirectory = options.outputDirectory ?? join(moduleRoot, 'dist-release');
33
+ const zipPath = join(outputDirectory, zipName);
34
+ mkdirSync(outputDirectory, { recursive: true });
35
+ const stagingRoot = mkdtempSync(join(tmpdir(), 'pack-beta-'));
36
+ try {
37
+ stageClosureWithBetaVersion(moduleRoot, stagingRoot, pathsToZip, descriptor.relativeConfig, version);
38
+ createZipFromStaging(stagingRoot, zipPath, pathsToZip);
39
+ }
40
+ finally {
41
+ rmSync(stagingRoot, { recursive: true, force: true });
42
+ }
43
+ const size = statSync(zipPath).size;
44
+ if (size > MAX_BETA_ZIP_BYTES) {
45
+ unlinkSync(zipPath);
46
+ throw new Error(`beta zip exceeds ${MAX_BETA_ZIP_BYTES} bytes (${size} bytes)`);
47
+ }
48
+ const sha256 = integrityFromFile(zipPath);
49
+ const gitSha = shortGitSha(moduleRoot);
50
+ return {
51
+ zipPath,
52
+ zipName,
53
+ modelId: descriptor.modelId,
54
+ baseVersion: descriptor.configVersion,
55
+ packedVersion: version,
56
+ size,
57
+ sha256,
58
+ ...(gitSha ? { gitSha } : {}),
59
+ };
60
+ }
61
+ export function validateBetaDescriptor(descriptor) {
62
+ const { relativeConfig, configVersion, minApiVersion } = descriptor;
63
+ if (typeof configVersion !== 'string' || configVersion.length === 0) {
64
+ throw new Error(`${relativeConfig} is missing version`);
65
+ }
66
+ if (configVersion.includes('-') || configVersion.includes('+')) {
67
+ throw new Error(`${relativeConfig} version must not include a prerelease or build metadata suffix`);
68
+ }
69
+ assertStrictSemver(configVersion, `${relativeConfig} version`);
70
+ if (typeof minApiVersion !== 'string' || minApiVersion.length === 0) {
71
+ throw new Error(`${relativeConfig} is missing minApiVersion`);
72
+ }
73
+ if (minApiVersion.includes('-') || minApiVersion.includes('+')) {
74
+ throw new Error(`${relativeConfig} minApiVersion must not include a prerelease or build metadata suffix`);
75
+ }
76
+ assertStrictSemver(minApiVersion, `${relativeConfig} minApiVersion`);
77
+ }
78
+ export function listConfigDescriptors(moduleRoot) {
79
+ const configDirectory = join(moduleRoot, 'configs');
80
+ if (!existsSync(configDirectory)) {
81
+ throw new Error('configs directory is missing');
82
+ }
83
+ const manufacturerDefault = readManufacturerDefault(moduleRoot);
84
+ return readdirSync(configDirectory)
85
+ .filter((fileName) => fileName.endsWith('.json'))
86
+ .sort()
87
+ .map((fileName) => parseConfigDescriptor(moduleRoot, fileName, manufacturerDefault));
88
+ }
89
+ export function parseConfigDescriptor(moduleRoot, fileName, manufacturerDefault) {
90
+ const relativeConfig = `configs/${fileName}`;
91
+ const absoluteConfig = join(moduleRoot, relativeConfig);
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(readFileSync(absoluteConfig, 'utf8'));
95
+ }
96
+ catch {
97
+ throw new Error(`${relativeConfig} is not valid JSON`);
98
+ }
99
+ const record = parsed && typeof parsed === 'object' ? parsed : {};
100
+ const id = record.id && typeof record.id === 'object' ? record.id : {};
101
+ const modelId = id.model;
102
+ const name = id.name;
103
+ const configVersion = record.version;
104
+ const minApiVersion = record.minApiVersion;
105
+ const manufacturer = typeof id.manufacturer === 'string' && id.manufacturer.length > 0 ? id.manufacturer : manufacturerDefault;
106
+ if (typeof modelId !== 'string' || modelId.length === 0) {
107
+ throw new Error(`${relativeConfig} is missing id.model`);
108
+ }
109
+ if (typeof name !== 'string' || name.length === 0) {
110
+ throw new Error(`${relativeConfig} is missing id.name`);
111
+ }
112
+ if (typeof configVersion !== 'string' || configVersion.length === 0) {
113
+ throw new Error(`${relativeConfig} is missing version`);
114
+ }
115
+ if (typeof minApiVersion !== 'string' || minApiVersion.length === 0) {
116
+ throw new Error(`${relativeConfig} is missing minApiVersion`);
117
+ }
118
+ if (typeof manufacturer !== 'string' || manufacturer.length === 0) {
119
+ throw new Error(`${relativeConfig} is missing id.manufacturer`);
120
+ }
121
+ const expectedFile = `configs/${modelId}.json`;
122
+ if (relativeConfig !== expectedFile) {
123
+ throw new Error(`${relativeConfig} must be named ${expectedFile} (id.model ${modelId})`);
124
+ }
125
+ return {
126
+ fileName,
127
+ relativeConfig,
128
+ modelId,
129
+ name,
130
+ configVersion,
131
+ minApiVersion,
132
+ manufacturer,
133
+ parsed,
134
+ };
135
+ }
136
+ export function closurePathsForConfig(moduleRoot, descriptor) {
137
+ const paths = collectRefClosure(moduleRoot, descriptor.relativeConfig);
138
+ for (const relativePath of paths) {
139
+ if (!existsSync(join(moduleRoot, relativePath))) {
140
+ throw new Error(`${descriptor.relativeConfig} $ref target is missing: ${relativePath}`);
141
+ }
142
+ }
143
+ return paths;
144
+ }
145
+ export function stageClosureWithBetaVersion(moduleRoot, stagingRoot, relativePaths, configRelative, version) {
146
+ for (const relativePath of relativePaths) {
147
+ const sourcePath = join(moduleRoot, relativePath);
148
+ const destPath = join(stagingRoot, relativePath);
149
+ mkdirSync(dirname(destPath), { recursive: true });
150
+ if (relativePath === configRelative) {
151
+ const parsed = JSON.parse(readFileSync(sourcePath, 'utf8'));
152
+ parsed.version = version;
153
+ writeFileSync(destPath, `${JSON.stringify(parsed, null, 2)}\n`);
154
+ }
155
+ else {
156
+ cpSync(sourcePath, destPath);
157
+ }
158
+ }
159
+ }
160
+ export function shortGitSha(moduleRoot) {
161
+ const result = spawnSync('git', ['rev-parse', '--short', 'HEAD'], {
162
+ cwd: moduleRoot,
163
+ encoding: 'utf8',
164
+ });
165
+ if (result.status !== 0) {
166
+ return undefined;
167
+ }
168
+ const sha = result.stdout.trim();
169
+ return sha.length > 0 ? sha : undefined;
170
+ }
171
+ export function formatBetaPackOutput(result) {
172
+ const lines = [result.zipPath, String(result.size), result.sha256];
173
+ if (result.gitSha) {
174
+ lines.push(result.gitSha);
175
+ }
176
+ return `${lines.join('\n')}\n`;
177
+ }
178
+ export function runPackBetaCli(args, moduleRoot, stdout = process.stdout, stderr = process.stderr) {
179
+ const [modelId, nArg, ...rest] = args;
180
+ if (!modelId || rest.length > 0) {
181
+ stderr.write('usage: yarn pack:beta <modelId> [n]\n');
182
+ return 1;
183
+ }
184
+ try {
185
+ const n = parseBetaCounter(nArg);
186
+ const result = packBetaZip(moduleRoot, modelId, n);
187
+ stdout.write(formatBetaPackOutput(result));
188
+ return 0;
189
+ }
190
+ catch (error) {
191
+ stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
192
+ return 1;
193
+ }
194
+ }
195
+ function loadDescriptor(moduleRoot, modelId) {
196
+ const descriptors = listConfigDescriptors(moduleRoot);
197
+ const descriptor = descriptors.find((entry) => entry.modelId === modelId);
198
+ if (!descriptor) {
199
+ const known = descriptors.map((entry) => entry.modelId).join(', ');
200
+ throw new Error(`unknown modelId ${modelId}; expected one of: ${known}`);
201
+ }
202
+ return descriptor;
203
+ }
204
+ function readManufacturerDefault(moduleRoot) {
205
+ const packagePath = join(moduleRoot, 'package.json');
206
+ if (!existsSync(packagePath)) {
207
+ return undefined;
208
+ }
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(readFileSync(packagePath, 'utf8'));
212
+ }
213
+ catch {
214
+ throw new Error('package.json is not valid JSON');
215
+ }
216
+ if (!parsed || typeof parsed !== 'object') {
217
+ return undefined;
218
+ }
219
+ const springfield = parsed.springfield;
220
+ if (!springfield || typeof springfield !== 'object') {
221
+ return undefined;
222
+ }
223
+ const manufacturer = springfield.manufacturer;
224
+ return typeof manufacturer === 'string' && manufacturer.length > 0 ? manufacturer : undefined;
225
+ }
226
+ function createZipFromStaging(stagingRoot, zipPath, relativePaths) {
227
+ if (existsSync(zipPath)) {
228
+ unlinkSync(zipPath);
229
+ }
230
+ const zipResult = spawnSync('zip', ['-q', zipPath, ...relativePaths, '-x', '*.DS_Store'], {
231
+ cwd: stagingRoot,
232
+ encoding: 'utf8',
233
+ });
234
+ if (zipResult.status !== 0) {
235
+ throw new Error(zipResult.stderr || zipResult.stdout || 'zip failed');
236
+ }
237
+ }
238
+ function integrityFromFile(filePath) {
239
+ const hash = createHash('sha256').update(readFileSync(filePath)).digest('hex');
240
+ return `sha256:${hash}`;
241
+ }
242
+ //# sourceMappingURL=pack-beta.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pack-beta.js","sourceRoot":"","sources":["../../src/pack/pack-beta.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Build one local beta driver zip (ADR 0010).\n *\n * Usage from a radio module repo: `yarn pack:beta <modelId> [n]`\n * n — positive beta counter (default 1, no leading zero).\n *\n * Writes `dist-release/radio-module-<modelId>-<version>-beta.<n>.zip` without\n * modifying the working tree, catalog entries, or other zips in `dist-release/`.\n * Does not call GitHub and does not skip a model that already has a release asset.\n */\nimport { createHash } from 'node:crypto';\nimport { spawnSync } from 'node:child_process';\nimport { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { dirname, join } from 'node:path';\nimport { collectRefClosure } from './ref-closure.js';\nimport { assertStrictSemver, BETA_COUNTER_PATTERN, packedBetaVersion, parseBetaCounter } from './driver-semver.js';\n\nexport {\n assertStrictSemver,\n BETA_COUNTER_PATTERN,\n BETA_VERSION_PATTERN,\n compareDriverVersions,\n compareToCatalogVersion,\n packedBetaVersion,\n parseBetaCounter,\n STRICT_SEMVER_PATTERN,\n} from './driver-semver.js';\n\nexport const MAX_BETA_ZIP_BYTES = 1024 * 1024;\n\nexport interface RadioModuleConfigDescriptor {\n fileName: string;\n relativeConfig: string;\n modelId: string;\n name: string;\n configVersion: string;\n minApiVersion: string;\n manufacturer: string;\n parsed: unknown;\n}\n\nexport interface PackBetaOptions {\n outputDirectory?: string;\n}\n\nexport interface PackBetaResult {\n zipPath: string;\n zipName: string;\n modelId: string;\n baseVersion: string;\n packedVersion: string;\n size: number;\n sha256: string;\n gitSha?: string;\n}\n\nexport function expectedBetaZipName(modelId: string, baseVersion: string, n: number): string {\n return `radio-module-${modelId}-${baseVersion}-beta.${n}.zip`;\n}\n\nexport function packBetaZip(moduleRoot: string, modelId: string, n = 1, options: PackBetaOptions = {}): PackBetaResult {\n if (!Number.isSafeInteger(n) || !BETA_COUNTER_PATTERN.test(String(n))) {\n throw new Error('beta counter n must be a positive integer with no leading zero');\n }\n\n const descriptor = loadDescriptor(moduleRoot, modelId);\n validateBetaDescriptor(descriptor);\n\n const pathsToZip = closurePathsForConfig(moduleRoot, descriptor);\n const version = packedBetaVersion(descriptor.configVersion, n);\n const zipName = expectedBetaZipName(descriptor.modelId, descriptor.configVersion, n);\n const outputDirectory = options.outputDirectory ?? join(moduleRoot, 'dist-release');\n const zipPath = join(outputDirectory, zipName);\n\n mkdirSync(outputDirectory, { recursive: true });\n\n const stagingRoot = mkdtempSync(join(tmpdir(), 'pack-beta-'));\n\n try {\n stageClosureWithBetaVersion(moduleRoot, stagingRoot, pathsToZip, descriptor.relativeConfig, version);\n createZipFromStaging(stagingRoot, zipPath, pathsToZip);\n } finally {\n rmSync(stagingRoot, { recursive: true, force: true });\n }\n\n const size = statSync(zipPath).size;\n\n if (size > MAX_BETA_ZIP_BYTES) {\n unlinkSync(zipPath);\n throw new Error(`beta zip exceeds ${MAX_BETA_ZIP_BYTES} bytes (${size} bytes)`);\n }\n\n const sha256 = integrityFromFile(zipPath);\n const gitSha = shortGitSha(moduleRoot);\n\n return {\n zipPath,\n zipName,\n modelId: descriptor.modelId,\n baseVersion: descriptor.configVersion,\n packedVersion: version,\n size,\n sha256,\n ...(gitSha ? { gitSha } : {}),\n };\n}\n\nexport function validateBetaDescriptor(descriptor: Pick<RadioModuleConfigDescriptor, 'relativeConfig' | 'configVersion' | 'minApiVersion'>): void {\n const { relativeConfig, configVersion, minApiVersion } = descriptor;\n\n if (typeof configVersion !== 'string' || configVersion.length === 0) {\n throw new Error(`${relativeConfig} is missing version`);\n }\n\n if (configVersion.includes('-') || configVersion.includes('+')) {\n throw new Error(`${relativeConfig} version must not include a prerelease or build metadata suffix`);\n }\n\n assertStrictSemver(configVersion, `${relativeConfig} version`);\n\n if (typeof minApiVersion !== 'string' || minApiVersion.length === 0) {\n throw new Error(`${relativeConfig} is missing minApiVersion`);\n }\n\n if (minApiVersion.includes('-') || minApiVersion.includes('+')) {\n throw new Error(`${relativeConfig} minApiVersion must not include a prerelease or build metadata suffix`);\n }\n\n assertStrictSemver(minApiVersion, `${relativeConfig} minApiVersion`);\n}\n\nexport function listConfigDescriptors(moduleRoot: string): RadioModuleConfigDescriptor[] {\n const configDirectory = join(moduleRoot, 'configs');\n\n if (!existsSync(configDirectory)) {\n throw new Error('configs directory is missing');\n }\n\n const manufacturerDefault = readManufacturerDefault(moduleRoot);\n\n return readdirSync(configDirectory)\n .filter((fileName) => fileName.endsWith('.json'))\n .sort()\n .map((fileName) => parseConfigDescriptor(moduleRoot, fileName, manufacturerDefault));\n}\n\nexport function parseConfigDescriptor(moduleRoot: string, fileName: string, manufacturerDefault?: string): RadioModuleConfigDescriptor {\n const relativeConfig = `configs/${fileName}`;\n const absoluteConfig = join(moduleRoot, relativeConfig);\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(readFileSync(absoluteConfig, 'utf8')) as unknown;\n } catch {\n throw new Error(`${relativeConfig} is not valid JSON`);\n }\n\n const record = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};\n const id = record.id && typeof record.id === 'object' ? (record.id as Record<string, unknown>) : {};\n const modelId = id.model;\n const name = id.name;\n const configVersion = record.version;\n const minApiVersion = record.minApiVersion;\n const manufacturer = typeof id.manufacturer === 'string' && id.manufacturer.length > 0 ? id.manufacturer : manufacturerDefault;\n\n if (typeof modelId !== 'string' || modelId.length === 0) {\n throw new Error(`${relativeConfig} is missing id.model`);\n }\n\n if (typeof name !== 'string' || name.length === 0) {\n throw new Error(`${relativeConfig} is missing id.name`);\n }\n\n if (typeof configVersion !== 'string' || configVersion.length === 0) {\n throw new Error(`${relativeConfig} is missing version`);\n }\n\n if (typeof minApiVersion !== 'string' || minApiVersion.length === 0) {\n throw new Error(`${relativeConfig} is missing minApiVersion`);\n }\n\n if (typeof manufacturer !== 'string' || manufacturer.length === 0) {\n throw new Error(`${relativeConfig} is missing id.manufacturer`);\n }\n\n const expectedFile = `configs/${modelId}.json`;\n\n if (relativeConfig !== expectedFile) {\n throw new Error(`${relativeConfig} must be named ${expectedFile} (id.model ${modelId})`);\n }\n\n return {\n fileName,\n relativeConfig,\n modelId,\n name,\n configVersion,\n minApiVersion,\n manufacturer,\n parsed,\n };\n}\n\nexport function closurePathsForConfig(moduleRoot: string, descriptor: Pick<RadioModuleConfigDescriptor, 'relativeConfig'>): string[] {\n const paths = collectRefClosure(moduleRoot, descriptor.relativeConfig);\n\n for (const relativePath of paths) {\n if (!existsSync(join(moduleRoot, relativePath))) {\n throw new Error(`${descriptor.relativeConfig} $ref target is missing: ${relativePath}`);\n }\n }\n\n return paths;\n}\n\nexport function stageClosureWithBetaVersion(moduleRoot: string, stagingRoot: string, relativePaths: string[], configRelative: string, version: string): void {\n for (const relativePath of relativePaths) {\n const sourcePath = join(moduleRoot, relativePath);\n const destPath = join(stagingRoot, relativePath);\n\n mkdirSync(dirname(destPath), { recursive: true });\n\n if (relativePath === configRelative) {\n const parsed = JSON.parse(readFileSync(sourcePath, 'utf8')) as Record<string, unknown>;\n parsed.version = version;\n writeFileSync(destPath, `${JSON.stringify(parsed, null, 2)}\\n`);\n } else {\n cpSync(sourcePath, destPath);\n }\n }\n}\n\nexport function shortGitSha(moduleRoot: string): string | undefined {\n const result = spawnSync('git', ['rev-parse', '--short', 'HEAD'], {\n cwd: moduleRoot,\n encoding: 'utf8',\n });\n\n if (result.status !== 0) {\n return undefined;\n }\n\n const sha = result.stdout.trim();\n return sha.length > 0 ? sha : undefined;\n}\n\nexport function formatBetaPackOutput(result: Pick<PackBetaResult, 'zipPath' | 'size' | 'sha256' | 'gitSha'>): string {\n const lines = [result.zipPath, String(result.size), result.sha256];\n\n if (result.gitSha) {\n lines.push(result.gitSha);\n }\n\n return `${lines.join('\\n')}\\n`;\n}\n\nexport interface PackBetaCliIo {\n write(chunk: string): void;\n}\n\nexport function runPackBetaCli(args: readonly string[], moduleRoot: string, stdout: PackBetaCliIo = process.stdout, stderr: PackBetaCliIo = process.stderr): number {\n const [modelId, nArg, ...rest] = args;\n\n if (!modelId || rest.length > 0) {\n stderr.write('usage: yarn pack:beta <modelId> [n]\\n');\n return 1;\n }\n\n try {\n const n = parseBetaCounter(nArg);\n const result = packBetaZip(moduleRoot, modelId, n);\n stdout.write(formatBetaPackOutput(result));\n return 0;\n } catch (error) {\n stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);\n return 1;\n }\n}\n\nfunction loadDescriptor(moduleRoot: string, modelId: string): RadioModuleConfigDescriptor {\n const descriptors = listConfigDescriptors(moduleRoot);\n const descriptor = descriptors.find((entry) => entry.modelId === modelId);\n\n if (!descriptor) {\n const known = descriptors.map((entry) => entry.modelId).join(', ');\n throw new Error(`unknown modelId ${modelId}; expected one of: ${known}`);\n }\n\n return descriptor;\n}\n\nfunction readManufacturerDefault(moduleRoot: string): string | undefined {\n const packagePath = join(moduleRoot, 'package.json');\n\n if (!existsSync(packagePath)) {\n return undefined;\n }\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as unknown;\n } catch {\n throw new Error('package.json is not valid JSON');\n }\n\n if (!parsed || typeof parsed !== 'object') {\n return undefined;\n }\n\n const springfield = (parsed as Record<string, unknown>).springfield;\n\n if (!springfield || typeof springfield !== 'object') {\n return undefined;\n }\n\n const manufacturer = (springfield as Record<string, unknown>).manufacturer;\n return typeof manufacturer === 'string' && manufacturer.length > 0 ? manufacturer : undefined;\n}\n\nfunction createZipFromStaging(stagingRoot: string, zipPath: string, relativePaths: string[]): void {\n if (existsSync(zipPath)) {\n unlinkSync(zipPath);\n }\n\n const zipResult = spawnSync('zip', ['-q', zipPath, ...relativePaths, '-x', '*.DS_Store'], {\n cwd: stagingRoot,\n encoding: 'utf8',\n });\n\n if (zipResult.status !== 0) {\n throw new Error(zipResult.stderr || zipResult.stdout || 'zip failed');\n }\n}\n\nfunction integrityFromFile(filePath: string): string {\n const hash = createHash('sha256').update(readFileSync(filePath)).digest('hex');\n return `sha256:${hash}`;\n}\n"]}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @returns Sorted repo-relative paths, including `startRelative`
3
+ */
4
+ export declare function collectRefClosure(moduleRoot: string, startRelative: string): string[];
5
+ export declare function readWorkingTreeText(moduleRoot: string, relativePath: string): string | undefined;
6
+ export declare function localRefTargets(value: unknown, into?: string[]): string[];
7
+ /**
8
+ * Path portion of a relative file `$ref`, without a JSON pointer.
9
+ * Fragment-only refs and absolute URIs are ignored.
10
+ */
11
+ export declare function filePathFromRef(ref: string): string | undefined;
12
+ //# sourceMappingURL=ref-closure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ref-closure.d.ts","sourceRoot":"","sources":["../../src/pack/ref-closure.ts"],"names":[],"mappings":"AAQA;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,EAAE,CA2CrF;AAED,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQhG;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CA4B7E;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAS/D"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Transitive relative `$ref` closure for one radio config.
3
+ * Matches the file set `pack:release` zips for that model: shared schemas and
4
+ * memory maps the config reaches, and not sibling radios.
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { dirname, join, relative, resolve, sep } from 'node:path';
8
+ /**
9
+ * @returns Sorted repo-relative paths, including `startRelative`
10
+ */
11
+ export function collectRefClosure(moduleRoot, startRelative) {
12
+ const included = new Set([startRelative]);
13
+ const pending = [startRelative];
14
+ while (pending.length > 0) {
15
+ const current = pending.pop();
16
+ if (!current) {
17
+ continue;
18
+ }
19
+ const text = readWorkingTreeText(moduleRoot, current);
20
+ if (text === undefined) {
21
+ continue;
22
+ }
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(text);
26
+ }
27
+ catch {
28
+ continue;
29
+ }
30
+ const fromDirectory = dirname(join(moduleRoot, current));
31
+ for (const refPath of localRefTargets(parsed)) {
32
+ const absolutePath = resolve(fromDirectory, refPath);
33
+ const relativePath = toRepoRelative(moduleRoot, absolutePath);
34
+ if (relativePath === undefined) {
35
+ throw new Error(`${current} $ref escapes the repository: ${refPath}`);
36
+ }
37
+ if (!included.has(relativePath)) {
38
+ included.add(relativePath);
39
+ pending.push(relativePath);
40
+ }
41
+ }
42
+ }
43
+ return [...included].sort();
44
+ }
45
+ export function readWorkingTreeText(moduleRoot, relativePath) {
46
+ const absolutePath = join(moduleRoot, relativePath);
47
+ if (!existsSync(absolutePath)) {
48
+ return undefined;
49
+ }
50
+ return readFileSync(absolutePath, 'utf8');
51
+ }
52
+ export function localRefTargets(value, into = []) {
53
+ if (Array.isArray(value)) {
54
+ for (const item of value) {
55
+ localRefTargets(item, into);
56
+ }
57
+ return into;
58
+ }
59
+ if (!value || typeof value !== 'object') {
60
+ return into;
61
+ }
62
+ const record = value;
63
+ if (typeof record.$ref === 'string') {
64
+ const refPath = filePathFromRef(record.$ref);
65
+ if (refPath) {
66
+ into.push(refPath);
67
+ }
68
+ }
69
+ for (const child of Object.values(record)) {
70
+ localRefTargets(child, into);
71
+ }
72
+ return into;
73
+ }
74
+ /**
75
+ * Path portion of a relative file `$ref`, without a JSON pointer.
76
+ * Fragment-only refs and absolute URIs are ignored.
77
+ */
78
+ export function filePathFromRef(ref) {
79
+ if (ref.length === 0 || ref.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(ref)) {
80
+ return undefined;
81
+ }
82
+ const hashIndex = ref.indexOf('#');
83
+ const refPath = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
84
+ return refPath.length > 0 ? refPath : undefined;
85
+ }
86
+ function toRepoRelative(rootDirectory, absolutePath) {
87
+ const relativePath = relative(rootDirectory, absolutePath);
88
+ if (relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`${sep}..`)) {
89
+ return undefined;
90
+ }
91
+ return relativePath.split(sep).join('/');
92
+ }
93
+ //# sourceMappingURL=ref-closure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ref-closure.js","sourceRoot":"","sources":["../../src/pack/ref-closure.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Transitive relative `$ref` closure for one radio config.\n * Matches the file set `pack:release` zips for that model: shared schemas and\n * memory maps the config reaches, and not sibling radios.\n */\nimport { existsSync, readFileSync } from 'node:fs';\nimport { dirname, join, relative, resolve, sep } from 'node:path';\n\n/**\n * @returns Sorted repo-relative paths, including `startRelative`\n */\nexport function collectRefClosure(moduleRoot: string, startRelative: string): string[] {\n const included = new Set<string>([startRelative]);\n const pending = [startRelative];\n\n while (pending.length > 0) {\n const current = pending.pop();\n\n if (!current) {\n continue;\n }\n\n const text = readWorkingTreeText(moduleRoot, current);\n\n if (text === undefined) {\n continue;\n }\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(text) as unknown;\n } catch {\n continue;\n }\n\n const fromDirectory = dirname(join(moduleRoot, current));\n\n for (const refPath of localRefTargets(parsed)) {\n const absolutePath = resolve(fromDirectory, refPath);\n const relativePath = toRepoRelative(moduleRoot, absolutePath);\n\n if (relativePath === undefined) {\n throw new Error(`${current} $ref escapes the repository: ${refPath}`);\n }\n\n if (!included.has(relativePath)) {\n included.add(relativePath);\n pending.push(relativePath);\n }\n }\n }\n\n return [...included].sort();\n}\n\nexport function readWorkingTreeText(moduleRoot: string, relativePath: string): string | undefined {\n const absolutePath = join(moduleRoot, relativePath);\n\n if (!existsSync(absolutePath)) {\n return undefined;\n }\n\n return readFileSync(absolutePath, 'utf8');\n}\n\nexport function localRefTargets(value: unknown, into: string[] = []): string[] {\n if (Array.isArray(value)) {\n for (const item of value) {\n localRefTargets(item, into);\n }\n\n return into;\n }\n\n if (!value || typeof value !== 'object') {\n return into;\n }\n\n const record = value as Record<string, unknown>;\n\n if (typeof record.$ref === 'string') {\n const refPath = filePathFromRef(record.$ref);\n\n if (refPath) {\n into.push(refPath);\n }\n }\n\n for (const child of Object.values(record)) {\n localRefTargets(child, into);\n }\n\n return into;\n}\n\n/**\n * Path portion of a relative file `$ref`, without a JSON pointer.\n * Fragment-only refs and absolute URIs are ignored.\n */\nexport function filePathFromRef(ref: string): string | undefined {\n if (ref.length === 0 || ref.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(ref)) {\n return undefined;\n }\n\n const hashIndex = ref.indexOf('#');\n const refPath = hashIndex === -1 ? ref : ref.slice(0, hashIndex);\n\n return refPath.length > 0 ? refPath : undefined;\n}\n\nfunction toRepoRelative(rootDirectory: string, absolutePath: string): string | undefined {\n const relativePath = relative(rootDirectory, absolutePath);\n\n if (relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`${sep}..`)) {\n return undefined;\n }\n\n return relativePath.split(sep).join('/');\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springfield/ham-radio-utils",
3
- "version": "4.16.0",
3
+ "version": "4.17.0",
4
4
  "author": "Bryan Hunt",
5
5
  "license": "MIT",
6
6
  "readme": "README.md",
@@ -11,6 +11,9 @@
11
11
  "type": "module",
12
12
  "main": "dist/index.js",
13
13
  "types": "dist/index.d.ts",
14
+ "bin": {
15
+ "ham-radio-pack-beta": "./dist/pack/pack-beta-cli.js"
16
+ },
14
17
  "repository": {
15
18
  "type": "git",
16
19
  "url": "git+https://github.com/springfield-ham-radio/ham-radio-utils.git"
@@ -25,6 +28,7 @@
25
28
  "lint": "oxlint",
26
29
  "lint:fix": "oxlint --fix --fix-suggestions",
27
30
  "release": "semantic-release",
31
+ "pack:beta": "ham-radio-pack-beta",
28
32
  "prepare": "husky && yarn build",
29
33
  "test": "yarn test:unit",
30
34
  "test:unit": "vitest run test/unit"
package/src/index.ts CHANGED
@@ -17,6 +17,29 @@ export * from './utils/to-hex-words.js';
17
17
  export * from './utils/ui-logger.js';
18
18
  export * from './utils/ui-logger-factory.js';
19
19
 
20
+ export {
21
+ BETA_COUNTER_PATTERN,
22
+ BETA_VERSION_PATTERN,
23
+ MAX_BETA_ZIP_BYTES,
24
+ STRICT_SEMVER_PATTERN,
25
+ assertStrictSemver,
26
+ closurePathsForConfig,
27
+ compareDriverVersions,
28
+ compareToCatalogVersion,
29
+ expectedBetaZipName,
30
+ formatBetaPackOutput,
31
+ listConfigDescriptors,
32
+ packBetaZip,
33
+ packedBetaVersion,
34
+ parseBetaCounter,
35
+ parseConfigDescriptor,
36
+ runPackBetaCli,
37
+ shortGitSha,
38
+ stageClosureWithBetaVersion,
39
+ validateBetaDescriptor,
40
+ } from './pack/pack-beta.js';
41
+ export type { PackBetaCliIo, PackBetaOptions, PackBetaResult, RadioModuleConfigDescriptor } from './pack/pack-beta.js';
42
+
20
43
  // Export the radio protocol schema
21
44
  export { default as radioProtocolSchema } from './schemas/radio-protocol-schema.json' with { type: 'json' };
22
45
  export { default as radioMemoryMapSchema } from './schemas/radio-memory-map-schema.json' with { type: 'json' };
@@ -124,6 +124,53 @@ export function structInstanceAddress(struct: RadioMemoryMapStruct, instanceInde
124
124
  return seek + groupIndex * (stride * groupSize + groupPad) + indexInGroup * stride;
125
125
  }
126
126
 
127
+ /**
128
+ * ASCII fields stop at NUL or 0xFF, then drop trailing spaces. The bytes after
129
+ * the visible text are not part of the setting value.
130
+ */
131
+ function decodeAscii(bytes: readonly number[]): string {
132
+ let text = '';
133
+
134
+ for (const byte of bytes) {
135
+ if (byte === 0xff || byte === 0x00) {
136
+ break;
137
+ }
138
+
139
+ text += String.fromCodePoint(byte);
140
+ }
141
+
142
+ return text.trimEnd();
143
+ }
144
+
145
+ /**
146
+ * Clone images mix space, NUL, and 0xFF padding in one ASCII field. Re-encoding
147
+ * an unchanged value with the single declared pad rewrites those bytes (a
148
+ * TH-D74 power-on message ending in spaces came back NUL-padded; a D-STAR memo
149
+ * ending in NUL came back space-padded). When the text still matches what
150
+ * decode would return, keep the stored bytes. Edited text uses `kind.pad`.
151
+ */
152
+ function encodeAscii(
153
+ kind: Extract<RadioMemoryMapValueKind, { kind: 'ascii' }>,
154
+ value: RadioSettingValue,
155
+ byteLength: number,
156
+ originalBytes: readonly number[] | undefined,
157
+ ): number[] {
158
+ const text = typeof value === 'string' ? value : '';
159
+
160
+ if (originalBytes && originalBytes.length === byteLength && decodeAscii(originalBytes) === text) {
161
+ return [...originalBytes];
162
+ }
163
+
164
+ const pad = kind.pad ?? 0xff;
165
+ const bytes: number[] = [];
166
+
167
+ for (let index = 0; index < byteLength; index += 1) {
168
+ bytes[index] = index < text.length ? (text.codePointAt(index) ?? pad) : pad;
169
+ }
170
+
171
+ return bytes;
172
+ }
173
+
127
174
  function decodeRawValue(kind: RadioMemoryMapValueKind | undefined, raw: number | number[]): RadioSettingValue {
128
175
  if (!kind) {
129
176
  return typeof raw === 'number' ? raw : raw;
@@ -140,17 +187,7 @@ function decodeRawValue(kind: RadioMemoryMapValueKind | undefined, raw: number |
140
187
  }
141
188
  case 'ascii': {
142
189
  const bytes = typeof raw === 'number' ? [raw] : raw;
143
- let text = '';
144
-
145
- for (const byte of bytes) {
146
- if (byte === 0xff || byte === 0x00) {
147
- break;
148
- }
149
-
150
- text += String.fromCodePoint(byte);
151
- }
152
-
153
- return text.trimEnd();
190
+ return decodeAscii(bytes);
154
191
  }
155
192
  case 'digits': {
156
193
  const bytes = typeof raw === 'number' ? [raw] : raw;
@@ -246,7 +283,12 @@ function decodeRawValue(kind: RadioMemoryMapValueKind | undefined, raw: number |
246
283
  }
247
284
  }
248
285
 
249
- function encodeRawValue(kind: RadioMemoryMapValueKind | undefined, value: RadioSettingValue, byteLength: number): number[] {
286
+ function encodeRawValue(
287
+ kind: RadioMemoryMapValueKind | undefined,
288
+ value: RadioSettingValue,
289
+ byteLength: number,
290
+ originalBytes?: readonly number[],
291
+ ): number[] {
250
292
  const bytes = Array.from({ length: byteLength }, () => 0xff);
251
293
 
252
294
  if (!kind) {
@@ -270,16 +312,8 @@ function encodeRawValue(kind: RadioMemoryMapValueKind | undefined, value: RadioS
270
312
  bytes[0] = index >= 0 ? index : 0;
271
313
  return bytes;
272
314
  }
273
- case 'ascii': {
274
- const text = typeof value === 'string' ? value : '';
275
- const pad = kind.pad ?? 0xff;
276
-
277
- for (let index = 0; index < byteLength; index += 1) {
278
- bytes[index] = index < text.length ? (text.codePointAt(index) ?? pad) : pad;
279
- }
280
-
281
- return bytes;
282
- }
315
+ case 'ascii':
316
+ return encodeAscii(kind, value, byteLength, originalBytes);
283
317
  case 'digits': {
284
318
  const scale = kind.scale ?? 1;
285
319
  let numeric = typeof value === 'number' ? Math.round(value / scale) : 0;
@@ -596,15 +630,27 @@ function encodeStruct(
596
630
  }
597
631
 
598
632
  const settingValue = values[field.id];
633
+ const originalBytes: number[] | undefined = field.value?.kind === 'ascii' ? [] : undefined;
634
+
635
+ if (originalBytes) {
636
+ for (let index = 0; index < length; index += 1) {
637
+ originalBytes.push(readByte(contents, bufferOffsetFor(radioAddress + index, memoryConfig, contents)));
638
+ }
639
+ }
599
640
 
600
641
  if (field.type === 'u16' || field.type === 'u32') {
601
- const encoded = encodeRawValue(field.value ?? { kind: 'integer' }, settingValue ?? 0, length);
642
+ const encoded = encodeRawValue(field.value ?? { kind: 'integer' }, settingValue ?? 0, length, originalBytes);
602
643
 
603
644
  for (let index = 0; index < length; index += 1) {
604
645
  writeByte(contents, bufferOffsetFor(radioAddress + index, memoryConfig, contents), encoded[index] ?? 0);
605
646
  }
606
647
  } else {
607
- const encoded = encodeRawValue(field.value, settingValue ?? (field.value?.kind === 'ascii' ? '' : 0), length);
648
+ const encoded = encodeRawValue(
649
+ field.value,
650
+ settingValue ?? (field.value?.kind === 'ascii' ? '' : 0),
651
+ length,
652
+ originalBytes,
653
+ );
608
654
 
609
655
  for (let index = 0; index < length; index += 1) {
610
656
  writeByte(contents, bufferOffsetFor(radioAddress + index, memoryConfig, contents), encoded[index]);