@springfield/ham-radio-utils 4.16.1 → 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.
- package/README.md +15 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pack/driver-semver.d.ts +34 -0
- package/dist/pack/driver-semver.d.ts.map +1 -0
- package/dist/pack/driver-semver.js +85 -0
- package/dist/pack/driver-semver.js.map +1 -0
- package/dist/pack/pack-beta-cli.d.ts +3 -0
- package/dist/pack/pack-beta-cli.d.ts.map +1 -0
- package/dist/pack/pack-beta-cli.js +5 -0
- package/dist/pack/pack-beta-cli.js.map +1 -0
- package/dist/pack/pack-beta.d.ts +39 -0
- package/dist/pack/pack-beta.d.ts.map +1 -0
- package/dist/pack/pack-beta.js +242 -0
- package/dist/pack/pack-beta.js.map +1 -0
- package/dist/pack/ref-closure.d.ts +12 -0
- package/dist/pack/ref-closure.d.ts.map +1 -0
- package/dist/pack/ref-closure.js +93 -0
- package/dist/pack/ref-closure.js.map +1 -0
- package/package.json +5 -1
- package/src/index.ts +23 -0
- package/src/pack/driver-semver.ts +112 -0
- package/src/pack/pack-beta-cli.ts +5 -0
- package/src/pack/pack-beta.ts +340 -0
- package/src/pack/ref-closure.ts +120 -0
|
@@ -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.
|
|
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' };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driver version precedence for local beta zips against a strict catalog version.
|
|
3
|
+
* ADR 0010: registry `compareSemver` stays numeric-only. This helper is the
|
|
4
|
+
* prerelease rule HamBench applies to install directories.
|
|
5
|
+
*
|
|
6
|
+
* - `1.11.1-beta.1` is older than catalog `1.11.1`
|
|
7
|
+
* - `1.11.2-beta.1` is newer than catalog `1.11.1`
|
|
8
|
+
* - among betas, `n` is numeric (`2` is less than `10`)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const STRICT_SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
12
|
+
export const BETA_COUNTER_PATTERN = /^[1-9]\d*$/;
|
|
13
|
+
export const BETA_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)-beta\.([1-9]\d*)$/;
|
|
14
|
+
|
|
15
|
+
export interface ParsedDriverVersion {
|
|
16
|
+
major: number;
|
|
17
|
+
minor: number;
|
|
18
|
+
patch: number;
|
|
19
|
+
beta?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function assertStrictSemver(value: string, fieldLabel: string): void {
|
|
23
|
+
if (!STRICT_SEMVER_PATTERN.test(value)) {
|
|
24
|
+
throw new Error(`${fieldLabel} must be strict major.minor.patch (no prerelease or build metadata)`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseBetaCounter(raw: string | undefined): number {
|
|
29
|
+
if (raw === undefined) {
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!BETA_COUNTER_PATTERN.test(raw)) {
|
|
34
|
+
throw new Error('beta counter n must be a positive integer with no leading zero');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Number(raw);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function packedBetaVersion(baseVersion: string, n: number): string {
|
|
41
|
+
return `${baseVersion}-beta.${n}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseDriverVersion(version: string): ParsedDriverVersion {
|
|
45
|
+
const trimmed = version.trim();
|
|
46
|
+
const betaMatch = BETA_VERSION_PATTERN.exec(trimmed);
|
|
47
|
+
|
|
48
|
+
if (betaMatch) {
|
|
49
|
+
return {
|
|
50
|
+
major: Number(betaMatch[1]),
|
|
51
|
+
minor: Number(betaMatch[2]),
|
|
52
|
+
patch: Number(betaMatch[3]),
|
|
53
|
+
beta: Number(betaMatch[4]),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!STRICT_SEMVER_PATTERN.test(trimmed)) {
|
|
58
|
+
throw new Error(`driver version must be strict major.minor.patch or major.minor.patch-beta.n: ${version}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [major, minor, patch] = trimmed.split('.').map((part) => Number(part));
|
|
62
|
+
|
|
63
|
+
return { major: major ?? 0, minor: minor ?? 0, patch: patch ?? 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Positive when `left` is newer than `right`.
|
|
68
|
+
* A release is newer than a beta of the same major.minor.patch.
|
|
69
|
+
*/
|
|
70
|
+
export function compareDriverVersions(left: string, right: string): number {
|
|
71
|
+
const a = parseDriverVersion(left);
|
|
72
|
+
const b = parseDriverVersion(right);
|
|
73
|
+
|
|
74
|
+
if (a.major !== b.major) {
|
|
75
|
+
return a.major - b.major;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (a.minor !== b.minor) {
|
|
79
|
+
return a.minor - b.minor;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (a.patch !== b.patch) {
|
|
83
|
+
return a.patch - b.patch;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (a.beta === undefined && b.beta === undefined) {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (a.beta === undefined) {
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (b.beta === undefined) {
|
|
95
|
+
return -1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return a.beta - b.beta;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Compare an installed driver version to a catalog `major.minor.patch`.
|
|
103
|
+
* Catalog strings are strict; prerelease catalog versions are rejected.
|
|
104
|
+
* Negative means the install is older than the catalog entry (HamBench offers Update).
|
|
105
|
+
*/
|
|
106
|
+
export function compareToCatalogVersion(installedVersion: string, catalogVersion: string): number {
|
|
107
|
+
if (!STRICT_SEMVER_PATTERN.test(catalogVersion)) {
|
|
108
|
+
throw new Error(`catalog version must be strict major.minor.patch: ${catalogVersion}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return compareDriverVersions(installedVersion, catalogVersion);
|
|
112
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
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
|
+
|
|
19
|
+
export {
|
|
20
|
+
assertStrictSemver,
|
|
21
|
+
BETA_COUNTER_PATTERN,
|
|
22
|
+
BETA_VERSION_PATTERN,
|
|
23
|
+
compareDriverVersions,
|
|
24
|
+
compareToCatalogVersion,
|
|
25
|
+
packedBetaVersion,
|
|
26
|
+
parseBetaCounter,
|
|
27
|
+
STRICT_SEMVER_PATTERN,
|
|
28
|
+
} from './driver-semver.js';
|
|
29
|
+
|
|
30
|
+
export const MAX_BETA_ZIP_BYTES = 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
export interface RadioModuleConfigDescriptor {
|
|
33
|
+
fileName: string;
|
|
34
|
+
relativeConfig: string;
|
|
35
|
+
modelId: string;
|
|
36
|
+
name: string;
|
|
37
|
+
configVersion: string;
|
|
38
|
+
minApiVersion: string;
|
|
39
|
+
manufacturer: string;
|
|
40
|
+
parsed: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PackBetaOptions {
|
|
44
|
+
outputDirectory?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PackBetaResult {
|
|
48
|
+
zipPath: string;
|
|
49
|
+
zipName: string;
|
|
50
|
+
modelId: string;
|
|
51
|
+
baseVersion: string;
|
|
52
|
+
packedVersion: string;
|
|
53
|
+
size: number;
|
|
54
|
+
sha256: string;
|
|
55
|
+
gitSha?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function expectedBetaZipName(modelId: string, baseVersion: string, n: number): string {
|
|
59
|
+
return `radio-module-${modelId}-${baseVersion}-beta.${n}.zip`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function packBetaZip(moduleRoot: string, modelId: string, n = 1, options: PackBetaOptions = {}): PackBetaResult {
|
|
63
|
+
if (!Number.isSafeInteger(n) || !BETA_COUNTER_PATTERN.test(String(n))) {
|
|
64
|
+
throw new Error('beta counter n must be a positive integer with no leading zero');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const descriptor = loadDescriptor(moduleRoot, modelId);
|
|
68
|
+
validateBetaDescriptor(descriptor);
|
|
69
|
+
|
|
70
|
+
const pathsToZip = closurePathsForConfig(moduleRoot, descriptor);
|
|
71
|
+
const version = packedBetaVersion(descriptor.configVersion, n);
|
|
72
|
+
const zipName = expectedBetaZipName(descriptor.modelId, descriptor.configVersion, n);
|
|
73
|
+
const outputDirectory = options.outputDirectory ?? join(moduleRoot, 'dist-release');
|
|
74
|
+
const zipPath = join(outputDirectory, zipName);
|
|
75
|
+
|
|
76
|
+
mkdirSync(outputDirectory, { recursive: true });
|
|
77
|
+
|
|
78
|
+
const stagingRoot = mkdtempSync(join(tmpdir(), 'pack-beta-'));
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
stageClosureWithBetaVersion(moduleRoot, stagingRoot, pathsToZip, descriptor.relativeConfig, version);
|
|
82
|
+
createZipFromStaging(stagingRoot, zipPath, pathsToZip);
|
|
83
|
+
} finally {
|
|
84
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const size = statSync(zipPath).size;
|
|
88
|
+
|
|
89
|
+
if (size > MAX_BETA_ZIP_BYTES) {
|
|
90
|
+
unlinkSync(zipPath);
|
|
91
|
+
throw new Error(`beta zip exceeds ${MAX_BETA_ZIP_BYTES} bytes (${size} bytes)`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sha256 = integrityFromFile(zipPath);
|
|
95
|
+
const gitSha = shortGitSha(moduleRoot);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
zipPath,
|
|
99
|
+
zipName,
|
|
100
|
+
modelId: descriptor.modelId,
|
|
101
|
+
baseVersion: descriptor.configVersion,
|
|
102
|
+
packedVersion: version,
|
|
103
|
+
size,
|
|
104
|
+
sha256,
|
|
105
|
+
...(gitSha ? { gitSha } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function validateBetaDescriptor(descriptor: Pick<RadioModuleConfigDescriptor, 'relativeConfig' | 'configVersion' | 'minApiVersion'>): void {
|
|
110
|
+
const { relativeConfig, configVersion, minApiVersion } = descriptor;
|
|
111
|
+
|
|
112
|
+
if (typeof configVersion !== 'string' || configVersion.length === 0) {
|
|
113
|
+
throw new Error(`${relativeConfig} is missing version`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (configVersion.includes('-') || configVersion.includes('+')) {
|
|
117
|
+
throw new Error(`${relativeConfig} version must not include a prerelease or build metadata suffix`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
assertStrictSemver(configVersion, `${relativeConfig} version`);
|
|
121
|
+
|
|
122
|
+
if (typeof minApiVersion !== 'string' || minApiVersion.length === 0) {
|
|
123
|
+
throw new Error(`${relativeConfig} is missing minApiVersion`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (minApiVersion.includes('-') || minApiVersion.includes('+')) {
|
|
127
|
+
throw new Error(`${relativeConfig} minApiVersion must not include a prerelease or build metadata suffix`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
assertStrictSemver(minApiVersion, `${relativeConfig} minApiVersion`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function listConfigDescriptors(moduleRoot: string): RadioModuleConfigDescriptor[] {
|
|
134
|
+
const configDirectory = join(moduleRoot, 'configs');
|
|
135
|
+
|
|
136
|
+
if (!existsSync(configDirectory)) {
|
|
137
|
+
throw new Error('configs directory is missing');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const manufacturerDefault = readManufacturerDefault(moduleRoot);
|
|
141
|
+
|
|
142
|
+
return readdirSync(configDirectory)
|
|
143
|
+
.filter((fileName) => fileName.endsWith('.json'))
|
|
144
|
+
.sort()
|
|
145
|
+
.map((fileName) => parseConfigDescriptor(moduleRoot, fileName, manufacturerDefault));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseConfigDescriptor(moduleRoot: string, fileName: string, manufacturerDefault?: string): RadioModuleConfigDescriptor {
|
|
149
|
+
const relativeConfig = `configs/${fileName}`;
|
|
150
|
+
const absoluteConfig = join(moduleRoot, relativeConfig);
|
|
151
|
+
let parsed: unknown;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(readFileSync(absoluteConfig, 'utf8')) as unknown;
|
|
155
|
+
} catch {
|
|
156
|
+
throw new Error(`${relativeConfig} is not valid JSON`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const record = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
|
160
|
+
const id = record.id && typeof record.id === 'object' ? (record.id as Record<string, unknown>) : {};
|
|
161
|
+
const modelId = id.model;
|
|
162
|
+
const name = id.name;
|
|
163
|
+
const configVersion = record.version;
|
|
164
|
+
const minApiVersion = record.minApiVersion;
|
|
165
|
+
const manufacturer = typeof id.manufacturer === 'string' && id.manufacturer.length > 0 ? id.manufacturer : manufacturerDefault;
|
|
166
|
+
|
|
167
|
+
if (typeof modelId !== 'string' || modelId.length === 0) {
|
|
168
|
+
throw new Error(`${relativeConfig} is missing id.model`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
172
|
+
throw new Error(`${relativeConfig} is missing id.name`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (typeof configVersion !== 'string' || configVersion.length === 0) {
|
|
176
|
+
throw new Error(`${relativeConfig} is missing version`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (typeof minApiVersion !== 'string' || minApiVersion.length === 0) {
|
|
180
|
+
throw new Error(`${relativeConfig} is missing minApiVersion`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (typeof manufacturer !== 'string' || manufacturer.length === 0) {
|
|
184
|
+
throw new Error(`${relativeConfig} is missing id.manufacturer`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const expectedFile = `configs/${modelId}.json`;
|
|
188
|
+
|
|
189
|
+
if (relativeConfig !== expectedFile) {
|
|
190
|
+
throw new Error(`${relativeConfig} must be named ${expectedFile} (id.model ${modelId})`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
fileName,
|
|
195
|
+
relativeConfig,
|
|
196
|
+
modelId,
|
|
197
|
+
name,
|
|
198
|
+
configVersion,
|
|
199
|
+
minApiVersion,
|
|
200
|
+
manufacturer,
|
|
201
|
+
parsed,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function closurePathsForConfig(moduleRoot: string, descriptor: Pick<RadioModuleConfigDescriptor, 'relativeConfig'>): string[] {
|
|
206
|
+
const paths = collectRefClosure(moduleRoot, descriptor.relativeConfig);
|
|
207
|
+
|
|
208
|
+
for (const relativePath of paths) {
|
|
209
|
+
if (!existsSync(join(moduleRoot, relativePath))) {
|
|
210
|
+
throw new Error(`${descriptor.relativeConfig} $ref target is missing: ${relativePath}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return paths;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function stageClosureWithBetaVersion(moduleRoot: string, stagingRoot: string, relativePaths: string[], configRelative: string, version: string): void {
|
|
218
|
+
for (const relativePath of relativePaths) {
|
|
219
|
+
const sourcePath = join(moduleRoot, relativePath);
|
|
220
|
+
const destPath = join(stagingRoot, relativePath);
|
|
221
|
+
|
|
222
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
223
|
+
|
|
224
|
+
if (relativePath === configRelative) {
|
|
225
|
+
const parsed = JSON.parse(readFileSync(sourcePath, 'utf8')) as Record<string, unknown>;
|
|
226
|
+
parsed.version = version;
|
|
227
|
+
writeFileSync(destPath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
228
|
+
} else {
|
|
229
|
+
cpSync(sourcePath, destPath);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function shortGitSha(moduleRoot: string): string | undefined {
|
|
235
|
+
const result = spawnSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
236
|
+
cwd: moduleRoot,
|
|
237
|
+
encoding: 'utf8',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (result.status !== 0) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const sha = result.stdout.trim();
|
|
245
|
+
return sha.length > 0 ? sha : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function formatBetaPackOutput(result: Pick<PackBetaResult, 'zipPath' | 'size' | 'sha256' | 'gitSha'>): string {
|
|
249
|
+
const lines = [result.zipPath, String(result.size), result.sha256];
|
|
250
|
+
|
|
251
|
+
if (result.gitSha) {
|
|
252
|
+
lines.push(result.gitSha);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return `${lines.join('\n')}\n`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface PackBetaCliIo {
|
|
259
|
+
write(chunk: string): void;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function runPackBetaCli(args: readonly string[], moduleRoot: string, stdout: PackBetaCliIo = process.stdout, stderr: PackBetaCliIo = process.stderr): number {
|
|
263
|
+
const [modelId, nArg, ...rest] = args;
|
|
264
|
+
|
|
265
|
+
if (!modelId || rest.length > 0) {
|
|
266
|
+
stderr.write('usage: yarn pack:beta <modelId> [n]\n');
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const n = parseBetaCounter(nArg);
|
|
272
|
+
const result = packBetaZip(moduleRoot, modelId, n);
|
|
273
|
+
stdout.write(formatBetaPackOutput(result));
|
|
274
|
+
return 0;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function loadDescriptor(moduleRoot: string, modelId: string): RadioModuleConfigDescriptor {
|
|
282
|
+
const descriptors = listConfigDescriptors(moduleRoot);
|
|
283
|
+
const descriptor = descriptors.find((entry) => entry.modelId === modelId);
|
|
284
|
+
|
|
285
|
+
if (!descriptor) {
|
|
286
|
+
const known = descriptors.map((entry) => entry.modelId).join(', ');
|
|
287
|
+
throw new Error(`unknown modelId ${modelId}; expected one of: ${known}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return descriptor;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readManufacturerDefault(moduleRoot: string): string | undefined {
|
|
294
|
+
const packagePath = join(moduleRoot, 'package.json');
|
|
295
|
+
|
|
296
|
+
if (!existsSync(packagePath)) {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let parsed: unknown;
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as unknown;
|
|
304
|
+
} catch {
|
|
305
|
+
throw new Error('package.json is not valid JSON');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const springfield = (parsed as Record<string, unknown>).springfield;
|
|
313
|
+
|
|
314
|
+
if (!springfield || typeof springfield !== 'object') {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const manufacturer = (springfield as Record<string, unknown>).manufacturer;
|
|
319
|
+
return typeof manufacturer === 'string' && manufacturer.length > 0 ? manufacturer : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function createZipFromStaging(stagingRoot: string, zipPath: string, relativePaths: string[]): void {
|
|
323
|
+
if (existsSync(zipPath)) {
|
|
324
|
+
unlinkSync(zipPath);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const zipResult = spawnSync('zip', ['-q', zipPath, ...relativePaths, '-x', '*.DS_Store'], {
|
|
328
|
+
cwd: stagingRoot,
|
|
329
|
+
encoding: 'utf8',
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (zipResult.status !== 0) {
|
|
333
|
+
throw new Error(zipResult.stderr || zipResult.stdout || 'zip failed');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function integrityFromFile(filePath: string): string {
|
|
338
|
+
const hash = createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
|
339
|
+
return `sha256:${hash}`;
|
|
340
|
+
}
|