@tutti-os/app-release-tools 0.0.78 → 0.0.79
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 +26 -11
- package/bin/build-tutti-app-catalog.mjs +224 -64
- package/bin/build-tutti-app-release.mjs +4 -0
- package/bin/build-tutti-app-versions.mjs +236 -0
- package/bin/merge-tutti-app-latest.mjs +70 -0
- package/bin/publish-tutti-app-catalog.mjs +199 -0
- package/bin/publish-tutti-app-metadata.mjs +365 -0
- package/bin/tutti-app-versioning.mjs +44 -0
- package/bin/verify-tutti-app-release-artifacts.mjs +96 -124
- package/package.json +12 -1
package/README.md
CHANGED
|
@@ -6,10 +6,11 @@ Command-line tools for publishing Tutti workspace apps into App Center release m
|
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
build-tutti-app-release --app-id vibe-design --package-dir dist/tutti-app/vibe-design --base-url https://cdn.example.test/tutti-app-releases
|
|
9
|
-
build-tutti-app-
|
|
10
|
-
build-tutti-app-catalog --
|
|
9
|
+
build-tutti-app-versions --release-file ./apps/vibe-design/0.2.0/release.json --min-tutti-version 0.12.0 --output ./apps/vibe-design/versions.json
|
|
10
|
+
build-tutti-app-catalog --versions-file ./apps/vibe-design/versions.json --output ./catalog.json
|
|
11
|
+
build-tutti-app-catalog --existing-catalog ./catalog.json --versions-file ./apps/vibe-design/versions.json --output ./catalog.json
|
|
11
12
|
verify-tutti-app-release-artifacts --release-file ./apps/vibe-design/latest.json
|
|
12
|
-
verify-tutti-app-release-artifacts --catalog-file ./catalog.json --
|
|
13
|
+
verify-tutti-app-release-artifacts --catalog-file ./catalog.json --versions-file ./apps/vibe-design/versions.json
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
The release command validates a complete Tutti app package, creates a zip,
|
|
@@ -18,13 +19,27 @@ manifest declares `localizationInfo`, the release metadata includes the
|
|
|
18
19
|
referenced manifest locale files so App Center can localize uninstalled remote
|
|
19
20
|
apps without downloading the package.
|
|
20
21
|
|
|
21
|
-
The
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
The versions command maintains one mutable `tutti.app.versions.v1` index per
|
|
23
|
+
app. Every catalog-eligible release has an explicit minimum Tutti version and
|
|
24
|
+
an `active` or `withdrawn` status. Reusing an immutable app version with changed
|
|
25
|
+
release or compatibility metadata is rejected.
|
|
26
|
+
|
|
27
|
+
The catalog command merges version indexes into `tutti.app.catalog.v1`.
|
|
28
|
+
`apps[]` contains the highest active app version whose minimum Tutti version is
|
|
29
|
+
`0.0.0`, so old Tutti clients remain safe. `compatibility.apps` contains only
|
|
30
|
+
the compact compatibility frontier needed by new Tutti clients, not every
|
|
31
|
+
historical release. Catalog output is rejected when it exceeds the legacy 1 MiB
|
|
32
|
+
reader limit. The older `--release-file` form remains available for legacy
|
|
33
|
+
tooling but does not create compatibility metadata.
|
|
26
34
|
|
|
27
35
|
The verify command checks release and catalog metadata against the referenced
|
|
28
|
-
artifact downloads. When
|
|
29
|
-
catalog entries for those apps must exactly match the
|
|
30
|
-
before artifact SHA-256 and size checks run.
|
|
36
|
+
artifact downloads. When `--catalog-file` is combined with `--release-file` or
|
|
37
|
+
`--versions-file`, catalog entries for those apps must exactly match the
|
|
38
|
+
corresponding release metadata before artifact SHA-256 and size checks run.
|
|
39
|
+
|
|
40
|
+
`publish-tutti-app-metadata` and `publish-tutti-app-catalog` update mutable S3
|
|
41
|
+
JSON objects with ETag conditional writes and retry on concurrent changes. They
|
|
42
|
+
bootstrap a missing versions index from the exact immutable release currently
|
|
43
|
+
present in the legacy catalog; they never infer legacy compatibility from a
|
|
44
|
+
newer `latest.json` object. Catalog publication validates all metadata and
|
|
45
|
+
downloads referenced artifacts before attempting the conditional S3 write.
|
|
@@ -7,85 +7,108 @@ import {
|
|
|
7
7
|
releaseToCatalogApp,
|
|
8
8
|
validateRelease
|
|
9
9
|
} from "./build-tutti-app-release.mjs";
|
|
10
|
+
import { validateVersionsDocument } from "./build-tutti-app-versions.mjs";
|
|
11
|
+
import {
|
|
12
|
+
compareReleaseVersions,
|
|
13
|
+
compareSemver
|
|
14
|
+
} from "./tutti-app-versioning.mjs";
|
|
10
15
|
|
|
11
|
-
const catalogSchemaVersion = "tutti.app.catalog.v1";
|
|
16
|
+
export const catalogSchemaVersion = "tutti.app.catalog.v1";
|
|
17
|
+
export const maxCatalogBytes = 1024 * 1024;
|
|
12
18
|
|
|
13
19
|
export async function buildTuttiAppCatalog(options) {
|
|
14
20
|
const existingCatalogPath = options.existingCatalogPath
|
|
15
21
|
? path.resolve(String(options.existingCatalogPath))
|
|
16
22
|
: null;
|
|
17
|
-
const releaseFiles =
|
|
23
|
+
const releaseFiles = normalizeFiles(options.releaseFiles);
|
|
24
|
+
const versionsFiles = normalizeFiles(options.versionsFiles);
|
|
18
25
|
const outputPath = path.resolve(
|
|
19
26
|
options.outputPath
|
|
20
27
|
? String(options.outputPath)
|
|
21
28
|
: "dist/tutti-app-catalog/catalog.json"
|
|
22
29
|
);
|
|
23
30
|
|
|
24
|
-
const seenAppIDs = new Set();
|
|
25
|
-
const seenReleaseAppIDs = new Set();
|
|
26
31
|
const appsByID = new Map();
|
|
27
|
-
|
|
32
|
+
const compatibilityByAppID = new Map();
|
|
28
33
|
if (existingCatalogPath) {
|
|
29
34
|
const existingCatalog = JSON.parse(
|
|
30
35
|
await readFile(existingCatalogPath, "utf8")
|
|
31
36
|
);
|
|
32
37
|
validateCatalog(existingCatalog);
|
|
33
38
|
for (const app of existingCatalog.apps) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
appsByID.set(app.manifest.appId, app);
|
|
40
|
+
}
|
|
41
|
+
for (const [appId, entries] of Object.entries(
|
|
42
|
+
existingCatalog.compatibility?.apps ?? {}
|
|
43
|
+
)) {
|
|
44
|
+
compatibilityByAppID.set(appId, entries);
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
const seenVersionsAppIDs = new Set();
|
|
49
|
+
for (const versionsFile of versionsFiles) {
|
|
50
|
+
const versions = JSON.parse(await readFile(versionsFile, "utf8"));
|
|
51
|
+
validateVersionsDocument(versions);
|
|
52
|
+
if (seenVersionsAppIDs.has(versions.appId)) {
|
|
53
|
+
throw new Error(`duplicate versions appId ${versions.appId}`);
|
|
54
|
+
}
|
|
55
|
+
seenVersionsAppIDs.add(versions.appId);
|
|
56
|
+
applyVersionsDocument(appsByID, compatibilityByAppID, versions);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const seenReleaseAppIDs = new Set();
|
|
43
60
|
for (const releaseFile of releaseFiles) {
|
|
44
61
|
const release = JSON.parse(await readFile(releaseFile, "utf8"));
|
|
45
62
|
validateRelease(release);
|
|
46
63
|
if (seenReleaseAppIDs.has(release.appId)) {
|
|
47
64
|
throw new Error(`duplicate release appId ${release.appId}`);
|
|
48
65
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
if (seenVersionsAppIDs.has(release.appId)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`appId ${release.appId} cannot be supplied as both release and versions metadata`
|
|
69
|
+
);
|
|
52
70
|
}
|
|
71
|
+
seenReleaseAppIDs.add(release.appId);
|
|
53
72
|
appsByID.set(release.appId, releaseToCatalogApp(release));
|
|
54
73
|
}
|
|
55
74
|
|
|
56
|
-
if (appsByID.size === 0) {
|
|
75
|
+
if (appsByID.size === 0 && compatibilityByAppID.size === 0) {
|
|
57
76
|
throw new Error(
|
|
58
|
-
"at least one release file or existing catalog app is required"
|
|
77
|
+
"at least one versions file, release file, or existing catalog app is required"
|
|
59
78
|
);
|
|
60
79
|
}
|
|
61
80
|
|
|
62
|
-
const apps = [...appsByID.values()]
|
|
63
|
-
apps.sort((left, right) =>
|
|
81
|
+
const apps = [...appsByID.values()].sort((left, right) =>
|
|
64
82
|
left.manifest.appId.localeCompare(right.manifest.appId)
|
|
65
83
|
);
|
|
66
|
-
|
|
84
|
+
const compatibilityApps = Object.fromEntries(
|
|
85
|
+
[...compatibilityByAppID.entries()]
|
|
86
|
+
.filter(([, entries]) => entries.length > 0)
|
|
87
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
88
|
+
);
|
|
67
89
|
const catalog = {
|
|
68
90
|
schemaVersion: catalogSchemaVersion,
|
|
69
|
-
apps
|
|
91
|
+
apps,
|
|
92
|
+
...(Object.keys(compatibilityApps).length > 0
|
|
93
|
+
? { compatibility: { apps: compatibilityApps } }
|
|
94
|
+
: {})
|
|
70
95
|
};
|
|
96
|
+
validateCatalog(catalog);
|
|
97
|
+
|
|
98
|
+
const encoded = `${JSON.stringify(catalog, null, 2)}\n`;
|
|
99
|
+
if (Buffer.byteLength(encoded) > maxCatalogBytes) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`catalog exceeds legacy ${maxCatalogBytes}-byte response limit`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
71
104
|
|
|
72
105
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
73
|
-
await writeFile(outputPath,
|
|
106
|
+
await writeFile(outputPath, encoded);
|
|
74
107
|
return { outputPath, catalog };
|
|
75
108
|
}
|
|
76
109
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
? value
|
|
80
|
-
: String(value ?? "")
|
|
81
|
-
.split(/[\n,]/)
|
|
82
|
-
.map((file) => file.trim())
|
|
83
|
-
.filter(Boolean);
|
|
84
|
-
return files.map((file) => path.resolve(file));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function validateCatalog(catalog) {
|
|
88
|
-
if (!catalog || typeof catalog !== "object") {
|
|
110
|
+
export function validateCatalog(catalog) {
|
|
111
|
+
if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) {
|
|
89
112
|
throw new Error("catalog must be an object");
|
|
90
113
|
}
|
|
91
114
|
if (catalog.schemaVersion !== catalogSchemaVersion) {
|
|
@@ -94,51 +117,188 @@ function validateCatalog(catalog) {
|
|
|
94
117
|
if (!Array.isArray(catalog.apps)) {
|
|
95
118
|
throw new Error("catalog apps must be an array");
|
|
96
119
|
}
|
|
120
|
+
|
|
121
|
+
const seenLegacyAppIDs = new Set();
|
|
97
122
|
for (const [index, app] of catalog.apps.entries()) {
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
const appId = validateCatalogApp(app, `catalog apps[${index}]`);
|
|
124
|
+
if (seenLegacyAppIDs.has(appId)) {
|
|
125
|
+
throw new Error(`duplicate catalog appId ${appId}`);
|
|
100
126
|
}
|
|
127
|
+
seenLegacyAppIDs.add(appId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const compatibility = catalog.compatibility;
|
|
131
|
+
if (compatibility === undefined) {
|
|
132
|
+
return catalog;
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
!compatibility ||
|
|
136
|
+
typeof compatibility !== "object" ||
|
|
137
|
+
Array.isArray(compatibility) ||
|
|
138
|
+
!compatibility.apps ||
|
|
139
|
+
typeof compatibility.apps !== "object" ||
|
|
140
|
+
Array.isArray(compatibility.apps)
|
|
141
|
+
) {
|
|
142
|
+
throw new Error("catalog compatibility.apps must be an object");
|
|
143
|
+
}
|
|
144
|
+
for (const [appId, entries] of Object.entries(compatibility.apps)) {
|
|
145
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
146
|
+
throw new Error(`catalog compatibility app ${appId} must be non-empty`);
|
|
147
|
+
}
|
|
148
|
+
const seenVersions = new Set();
|
|
149
|
+
const seenMinimums = new Set();
|
|
150
|
+
for (const [index, entry] of entries.entries()) {
|
|
151
|
+
const label = `catalog compatibility app ${appId}[${index}]`;
|
|
152
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
153
|
+
throw new Error(`${label} must be an object`);
|
|
154
|
+
}
|
|
155
|
+
const minTuttiVersion = String(entry.minTuttiVersion ?? "").trim();
|
|
156
|
+
compareSemver(minTuttiVersion, minTuttiVersion);
|
|
157
|
+
if (seenMinimums.has(minTuttiVersion)) {
|
|
158
|
+
throw new Error(`${label} has duplicate minTuttiVersion`);
|
|
159
|
+
}
|
|
160
|
+
seenMinimums.add(minTuttiVersion);
|
|
161
|
+
const entryAppId = validateCatalogApp(entry.app, `${label}.app`);
|
|
162
|
+
if (entryAppId !== appId) {
|
|
163
|
+
throw new Error(`${label}.app manifest.appId must match map key`);
|
|
164
|
+
}
|
|
165
|
+
const version = entry.app.manifest.version;
|
|
166
|
+
if (seenVersions.has(version)) {
|
|
167
|
+
throw new Error(`${label} has duplicate app version ${version}`);
|
|
168
|
+
}
|
|
169
|
+
seenVersions.add(version);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return catalog;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateCatalogApp(app, label) {
|
|
176
|
+
if (!app || typeof app !== "object" || Array.isArray(app)) {
|
|
177
|
+
throw new Error(`${label} must be an object`);
|
|
178
|
+
}
|
|
179
|
+
const appId = String(app.manifest?.appId ?? "").trim();
|
|
180
|
+
const version = String(app.manifest?.version ?? "").trim();
|
|
181
|
+
if (!appId) {
|
|
182
|
+
throw new Error(`${label}.manifest.appId is required`);
|
|
183
|
+
}
|
|
184
|
+
compareSemver(version, version);
|
|
185
|
+
const distribution = app.distribution;
|
|
186
|
+
if (!distribution || distribution.kind !== "remote") {
|
|
187
|
+
throw new Error(`${label}.distribution.kind must be remote`);
|
|
188
|
+
}
|
|
189
|
+
for (const key of ["artifactUrl", "artifactSha256", "iconUrl"]) {
|
|
101
190
|
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
typeof app.manifest.appId !== "string" ||
|
|
105
|
-
app.manifest.appId.trim() === ""
|
|
191
|
+
typeof distribution[key] !== "string" ||
|
|
192
|
+
distribution[key].trim() === ""
|
|
106
193
|
) {
|
|
107
|
-
throw new Error(
|
|
194
|
+
throw new Error(`${label}.distribution.${key} is required`);
|
|
108
195
|
}
|
|
109
196
|
}
|
|
197
|
+
if (!/^[a-f0-9]{64}$/iu.test(distribution.artifactSha256)) {
|
|
198
|
+
throw new Error(`${label}.distribution.artifactSha256 must be sha256`);
|
|
199
|
+
}
|
|
200
|
+
return appId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function applyVersionsDocument(appsByID, compatibilityByAppID, versions) {
|
|
204
|
+
const activeRecords = versions.versions.filter(
|
|
205
|
+
(record) => record.status === "active"
|
|
206
|
+
);
|
|
207
|
+
const legacyRecords = activeRecords.filter(
|
|
208
|
+
(record) => record.minTuttiVersion === "0.0.0"
|
|
209
|
+
);
|
|
210
|
+
const legacy = highestReleaseRecord(legacyRecords);
|
|
211
|
+
if (legacy) {
|
|
212
|
+
appsByID.set(versions.appId, releaseToCatalogApp(legacy.release));
|
|
213
|
+
} else {
|
|
214
|
+
appsByID.delete(versions.appId);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const entries = compatibilityFrontier(activeRecords).map((record) => ({
|
|
218
|
+
minTuttiVersion: record.minTuttiVersion,
|
|
219
|
+
app: releaseToCatalogApp(record.release)
|
|
220
|
+
}));
|
|
221
|
+
if (entries.length > 0) {
|
|
222
|
+
compatibilityByAppID.set(versions.appId, entries);
|
|
223
|
+
} else {
|
|
224
|
+
compatibilityByAppID.delete(versions.appId);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function compatibilityFrontier(records) {
|
|
229
|
+
const highestByMinimum = new Map();
|
|
230
|
+
for (const record of records) {
|
|
231
|
+
const existing = highestByMinimum.get(record.minTuttiVersion);
|
|
232
|
+
if (
|
|
233
|
+
!existing ||
|
|
234
|
+
compareReleaseVersions(record.release, existing.release) > 0
|
|
235
|
+
) {
|
|
236
|
+
highestByMinimum.set(record.minTuttiVersion, record);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const candidates = [...highestByMinimum.values()].sort((left, right) => {
|
|
241
|
+
const minimum = compareSemver(left.minTuttiVersion, right.minTuttiVersion);
|
|
242
|
+
if (minimum !== 0) {
|
|
243
|
+
return minimum;
|
|
244
|
+
}
|
|
245
|
+
return compareReleaseVersions(left.release, right.release);
|
|
246
|
+
});
|
|
247
|
+
const frontier = [];
|
|
248
|
+
let highest = null;
|
|
249
|
+
for (const candidate of candidates) {
|
|
250
|
+
if (
|
|
251
|
+
!highest ||
|
|
252
|
+
compareReleaseVersions(candidate.release, highest.release) > 0
|
|
253
|
+
) {
|
|
254
|
+
frontier.push(candidate);
|
|
255
|
+
highest = candidate;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return frontier;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function highestReleaseRecord(records) {
|
|
262
|
+
return [...records]
|
|
263
|
+
.sort((left, right) => compareReleaseVersions(left.release, right.release))
|
|
264
|
+
.at(-1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizeFiles(value) {
|
|
268
|
+
const files = Array.isArray(value)
|
|
269
|
+
? value
|
|
270
|
+
: String(value ?? "")
|
|
271
|
+
.split(/[\n,]/u)
|
|
272
|
+
.map((file) => file.trim())
|
|
273
|
+
.filter(Boolean);
|
|
274
|
+
return files.map((file) => path.resolve(file));
|
|
110
275
|
}
|
|
111
276
|
|
|
112
277
|
function parseArgs(argv) {
|
|
113
|
-
const result = {
|
|
114
|
-
releaseFiles: []
|
|
115
|
-
};
|
|
278
|
+
const result = { releaseFiles: [], versionsFiles: [] };
|
|
116
279
|
for (let index = 0; index < argv.length; index += 1) {
|
|
117
280
|
const arg = argv[index];
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (arg === "--existing-catalog") {
|
|
128
|
-
const value = argv[index + 1];
|
|
281
|
+
const value = argv[index + 1];
|
|
282
|
+
if (
|
|
283
|
+
[
|
|
284
|
+
"--release-file",
|
|
285
|
+
"--versions-file",
|
|
286
|
+
"--existing-catalog",
|
|
287
|
+
"--output"
|
|
288
|
+
].includes(arg)
|
|
289
|
+
) {
|
|
129
290
|
if (!value || value.startsWith("--")) {
|
|
130
|
-
throw new Error(
|
|
291
|
+
throw new Error(`missing value for ${arg}`);
|
|
131
292
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
293
|
+
if (arg === "--release-file") {
|
|
294
|
+
result.releaseFiles.push(value);
|
|
295
|
+
} else if (arg === "--versions-file") {
|
|
296
|
+
result.versionsFiles.push(value);
|
|
297
|
+
} else if (arg === "--existing-catalog") {
|
|
298
|
+
result.existingCatalogPath = value;
|
|
299
|
+
} else {
|
|
300
|
+
result.outputPath = value;
|
|
140
301
|
}
|
|
141
|
-
result.outputPath = value;
|
|
142
302
|
index += 1;
|
|
143
303
|
continue;
|
|
144
304
|
}
|
|
@@ -5,6 +5,8 @@ import { pathToFileURL } from "node:url";
|
|
|
5
5
|
import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
|
|
8
|
+
import { requireSemver } from "./tutti-app-versioning.mjs";
|
|
9
|
+
|
|
8
10
|
const manifestSchemaVersion = "tutti.app.manifest.v1";
|
|
9
11
|
const cliManifestSchemaVersion = "tutti.app.cli.v1";
|
|
10
12
|
const releaseSchemaVersion = "tutti.app.release.v1";
|
|
@@ -37,6 +39,7 @@ export async function buildTuttiAppRelease(options) {
|
|
|
37
39
|
"version"
|
|
38
40
|
);
|
|
39
41
|
requireSafePathSegment(version, "version");
|
|
42
|
+
requireSemver(version, "version");
|
|
40
43
|
manifest.version = version;
|
|
41
44
|
validateManifest(manifest, manifestPath);
|
|
42
45
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
@@ -496,6 +499,7 @@ export function validateRelease(release) {
|
|
|
496
499
|
]) {
|
|
497
500
|
requireManifestString(release, key, "release");
|
|
498
501
|
}
|
|
502
|
+
requireSemver(release.version, "release version");
|
|
499
503
|
validateManifest(release.manifest, "release.manifest");
|
|
500
504
|
if (release.manifest.appId !== release.appId) {
|
|
501
505
|
throw new Error("release manifest.appId must match release appId");
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { validateRelease } from "./build-tutti-app-release.mjs";
|
|
7
|
+
import {
|
|
8
|
+
compareReleaseVersions,
|
|
9
|
+
requireSemver
|
|
10
|
+
} from "./tutti-app-versioning.mjs";
|
|
11
|
+
|
|
12
|
+
export const versionsSchemaVersion = "tutti.app.versions.v1";
|
|
13
|
+
|
|
14
|
+
export async function buildTuttiAppVersions(options) {
|
|
15
|
+
const existingPath = options.existingVersionsPath
|
|
16
|
+
? path.resolve(String(options.existingVersionsPath))
|
|
17
|
+
: null;
|
|
18
|
+
const releaseFiles = normalizeFiles(options.releaseFiles);
|
|
19
|
+
const baselineReleaseFiles = normalizeFiles(options.baselineReleaseFiles);
|
|
20
|
+
const outputPath = path.resolve(
|
|
21
|
+
options.outputPath
|
|
22
|
+
? String(options.outputPath)
|
|
23
|
+
: "dist/tutti-app-release/versions.json"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
let document = null;
|
|
27
|
+
if (existingPath) {
|
|
28
|
+
document = JSON.parse(await readFile(existingPath, "utf8"));
|
|
29
|
+
validateVersionsDocument(document);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const releaseFile of baselineReleaseFiles) {
|
|
33
|
+
const release = JSON.parse(await readFile(releaseFile, "utf8"));
|
|
34
|
+
validateRelease(release);
|
|
35
|
+
document = addVersionRecord(document, {
|
|
36
|
+
minTuttiVersion: "0.0.0",
|
|
37
|
+
status: "active",
|
|
38
|
+
release
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (releaseFiles.length > 0) {
|
|
43
|
+
const minTuttiVersion = requireSemver(
|
|
44
|
+
options.minTuttiVersion,
|
|
45
|
+
"minTuttiVersion"
|
|
46
|
+
);
|
|
47
|
+
const status = normalizeStatus(options.status ?? "active");
|
|
48
|
+
for (const releaseFile of releaseFiles) {
|
|
49
|
+
const release = JSON.parse(await readFile(releaseFile, "utf8"));
|
|
50
|
+
validateRelease(release);
|
|
51
|
+
document = addVersionRecord(document, {
|
|
52
|
+
minTuttiVersion,
|
|
53
|
+
status,
|
|
54
|
+
release
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (options.setStatusVersion) {
|
|
60
|
+
if (!document) {
|
|
61
|
+
throw new Error("existing versions are required when setting status");
|
|
62
|
+
}
|
|
63
|
+
const version = requireSemver(options.setStatusVersion, "setStatusVersion");
|
|
64
|
+
const status = normalizeStatus(options.status);
|
|
65
|
+
const record = document.versions.find(
|
|
66
|
+
(candidate) => candidate.release.version === version
|
|
67
|
+
);
|
|
68
|
+
if (!record) {
|
|
69
|
+
throw new Error(`version ${version} is not present in versions index`);
|
|
70
|
+
}
|
|
71
|
+
record.status = status;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!document || document.versions.length === 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"at least one release, baseline release, or existing versions document is required"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
document.versions.sort((left, right) =>
|
|
81
|
+
compareReleaseVersions(left.release, right.release)
|
|
82
|
+
);
|
|
83
|
+
validateVersionsDocument(document);
|
|
84
|
+
|
|
85
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
86
|
+
await writeFile(outputPath, `${JSON.stringify(document, null, 2)}\n`);
|
|
87
|
+
return { outputPath, versions: document };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function validateVersionsDocument(document) {
|
|
91
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
92
|
+
throw new Error("versions document must be an object");
|
|
93
|
+
}
|
|
94
|
+
if (document.schemaVersion !== versionsSchemaVersion) {
|
|
95
|
+
throw new Error(`versions schemaVersion must be ${versionsSchemaVersion}`);
|
|
96
|
+
}
|
|
97
|
+
const appId = requireNonEmpty(document.appId, "versions appId");
|
|
98
|
+
if (!Array.isArray(document.versions)) {
|
|
99
|
+
throw new Error("versions must be an array");
|
|
100
|
+
}
|
|
101
|
+
const seenVersions = new Set();
|
|
102
|
+
for (const [index, record] of document.versions.entries()) {
|
|
103
|
+
const label = `versions[${index}]`;
|
|
104
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
105
|
+
throw new Error(`${label} must be an object`);
|
|
106
|
+
}
|
|
107
|
+
record.minTuttiVersion = requireSemver(
|
|
108
|
+
record.minTuttiVersion,
|
|
109
|
+
`${label}.minTuttiVersion`
|
|
110
|
+
);
|
|
111
|
+
record.status = normalizeStatus(record.status);
|
|
112
|
+
validateRelease(record.release);
|
|
113
|
+
if (record.release.appId !== appId) {
|
|
114
|
+
throw new Error(`${label}.release.appId must match versions appId`);
|
|
115
|
+
}
|
|
116
|
+
const version = requireSemver(
|
|
117
|
+
record.release.version,
|
|
118
|
+
`${label}.release.version`
|
|
119
|
+
);
|
|
120
|
+
if (seenVersions.has(version)) {
|
|
121
|
+
throw new Error(`duplicate versions release version ${version}`);
|
|
122
|
+
}
|
|
123
|
+
seenVersions.add(version);
|
|
124
|
+
}
|
|
125
|
+
return document;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function addVersionRecord(document, record) {
|
|
129
|
+
const appId = record.release.appId;
|
|
130
|
+
const result = document ?? {
|
|
131
|
+
schemaVersion: versionsSchemaVersion,
|
|
132
|
+
appId,
|
|
133
|
+
versions: []
|
|
134
|
+
};
|
|
135
|
+
validateVersionsDocument(result);
|
|
136
|
+
if (result.appId !== appId) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`release appId ${appId} must match versions appId ${result.appId}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const existing = result.versions.find(
|
|
143
|
+
(candidate) => candidate.release.version === record.release.version
|
|
144
|
+
);
|
|
145
|
+
if (!existing) {
|
|
146
|
+
result.versions.push(record);
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
if (JSON.stringify(existing) !== JSON.stringify(record)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`version ${record.release.version} already exists with different compatibility or release metadata`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeFiles(value) {
|
|
158
|
+
const files = Array.isArray(value)
|
|
159
|
+
? value
|
|
160
|
+
: String(value ?? "")
|
|
161
|
+
.split(/[\n,]/u)
|
|
162
|
+
.map((file) => file.trim())
|
|
163
|
+
.filter(Boolean);
|
|
164
|
+
return files.map((file) => path.resolve(file));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeStatus(value) {
|
|
168
|
+
const status = String(value ?? "").trim();
|
|
169
|
+
if (status !== "active" && status !== "withdrawn") {
|
|
170
|
+
throw new Error("status must be active or withdrawn");
|
|
171
|
+
}
|
|
172
|
+
return status;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requireNonEmpty(value, label) {
|
|
176
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
177
|
+
throw new Error(`${label} is required`);
|
|
178
|
+
}
|
|
179
|
+
return value.trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseArgs(argv) {
|
|
183
|
+
const result = { releaseFiles: [], baselineReleaseFiles: [] };
|
|
184
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
185
|
+
const arg = argv[index];
|
|
186
|
+
const value = argv[index + 1];
|
|
187
|
+
if (
|
|
188
|
+
[
|
|
189
|
+
"--existing-versions",
|
|
190
|
+
"--release-file",
|
|
191
|
+
"--baseline-release-file",
|
|
192
|
+
"--min-tutti-version",
|
|
193
|
+
"--set-status-version",
|
|
194
|
+
"--status",
|
|
195
|
+
"--output"
|
|
196
|
+
].includes(arg)
|
|
197
|
+
) {
|
|
198
|
+
if (!value || value.startsWith("--")) {
|
|
199
|
+
throw new Error(`missing value for ${arg}`);
|
|
200
|
+
}
|
|
201
|
+
const key = {
|
|
202
|
+
"--existing-versions": "existingVersionsPath",
|
|
203
|
+
"--min-tutti-version": "minTuttiVersion",
|
|
204
|
+
"--set-status-version": "setStatusVersion",
|
|
205
|
+
"--status": "status",
|
|
206
|
+
"--output": "outputPath"
|
|
207
|
+
}[arg];
|
|
208
|
+
if (arg === "--release-file") {
|
|
209
|
+
result.releaseFiles.push(value);
|
|
210
|
+
} else if (arg === "--baseline-release-file") {
|
|
211
|
+
result.baselineReleaseFiles.push(value);
|
|
212
|
+
} else {
|
|
213
|
+
result[key] = value;
|
|
214
|
+
}
|
|
215
|
+
index += 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`unexpected argument: ${arg}`);
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function main() {
|
|
224
|
+
const result = await buildTuttiAppVersions(parseArgs(process.argv.slice(2)));
|
|
225
|
+
process.stdout.write(`${JSON.stringify(result.versions, null, 2)}\n`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (
|
|
229
|
+
process.argv[1] &&
|
|
230
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
231
|
+
) {
|
|
232
|
+
main().catch((error) => {
|
|
233
|
+
console.error(error.message);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
});
|
|
236
|
+
}
|