@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.
@@ -5,100 +5,76 @@ import { readFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
 
8
+ import {
9
+ validateCatalog,
10
+ validateCatalogApp
11
+ } from "./build-tutti-app-catalog.mjs";
8
12
  import { validateRelease } from "./build-tutti-app-release.mjs";
9
-
10
- const catalogSchemaVersion = "tutti.app.catalog.v1";
13
+ import { validateVersionsDocument } from "./build-tutti-app-versions.mjs";
11
14
 
12
15
  export async function verifyTuttiAppReleaseArtifacts(options) {
13
16
  const releaseFiles = normalizeFiles(options.releaseFiles);
17
+ const versionsFiles = normalizeFiles(options.versionsFiles);
14
18
  const catalogFile = options.catalogFile
15
19
  ? path.resolve(String(options.catalogFile))
16
20
  : null;
17
21
  const verifyArtifacts = options.verifyArtifacts !== false;
18
22
 
19
- if (releaseFiles.length === 0 && !catalogFile) {
23
+ if (releaseFiles.length === 0 && versionsFiles.length === 0 && !catalogFile) {
20
24
  throw new Error(
21
- "at least one --release-file or --catalog-file is required"
25
+ "at least one --release-file, --versions-file, or --catalog-file is required"
22
26
  );
23
27
  }
24
28
 
25
- const releases = [];
26
- const releasesByAppID = new Map();
29
+ const releasesByKey = new Map();
30
+ for (const versionsFile of versionsFiles) {
31
+ const document = JSON.parse(await readFile(versionsFile, "utf8"));
32
+ validateVersionsDocument(document);
33
+ for (const record of document.versions) {
34
+ const key = releaseKey(record.release.appId, record.release.version);
35
+ if (releasesByKey.has(key)) {
36
+ throw new Error(`duplicate versions release ${key}`);
37
+ }
38
+ releasesByKey.set(key, record.release);
39
+ }
40
+ }
41
+
42
+ const checks = new Map();
27
43
  for (const releaseFile of releaseFiles) {
28
44
  const release = JSON.parse(await readFile(releaseFile, "utf8"));
29
45
  validateRelease(release);
30
- if (releasesByAppID.has(release.appId)) {
31
- throw new Error(`duplicate release appId ${release.appId}`);
46
+ const key = releaseKey(release.appId, release.version);
47
+ const existing = releasesByKey.get(key);
48
+ if (existing && JSON.stringify(existing) !== JSON.stringify(release)) {
49
+ throw new Error(`release ${key} does not match versions metadata`);
32
50
  }
33
- releases.push(release);
34
- releasesByAppID.set(release.appId, release);
35
- }
36
-
37
- const checks = [];
38
- for (const release of releases) {
39
- checks.push({
40
- appId: release.appId,
41
- artifactUrl: release.artifactUrl,
42
- artifactSha256: release.artifactSha256,
43
- artifactSizeBytes: release.artifactSizeBytes,
44
- source: `release ${release.appId}`
45
- });
51
+ releasesByKey.set(key, release);
52
+ addArtifactCheck(checks, release, `release ${key}`);
46
53
  }
47
54
 
48
55
  if (catalogFile) {
49
56
  const catalog = JSON.parse(await readFile(catalogFile, "utf8"));
50
57
  validateCatalog(catalog);
51
- const seenCatalogAppIDs = new Set();
52
58
  for (const app of catalog.apps) {
53
- const appId = app.manifest.appId;
54
- if (seenCatalogAppIDs.has(appId)) {
55
- throw new Error(`duplicate catalog appId ${appId}`);
56
- }
57
- seenCatalogAppIDs.add(appId);
58
- const distribution = app.distribution;
59
- if (!distribution || distribution.kind !== "remote") {
60
- throw new Error(
61
- `catalog app ${appId} distribution.kind must be remote`
59
+ addCatalogAppCheck(checks, releasesByKey, app, "catalog legacy app");
60
+ }
61
+ for (const [appId, entries] of Object.entries(
62
+ catalog.compatibility?.apps ?? {}
63
+ )) {
64
+ for (const entry of entries) {
65
+ addCatalogAppCheck(
66
+ checks,
67
+ releasesByKey,
68
+ entry.app,
69
+ `catalog compatibility app ${appId}`
62
70
  );
63
71
  }
64
- for (const key of ["artifactUrl", "artifactSha256", "iconUrl"]) {
65
- if (
66
- typeof distribution[key] !== "string" ||
67
- distribution[key].trim() === ""
68
- ) {
69
- throw new Error(
70
- `catalog app ${appId} distribution.${key} is required`
71
- );
72
- }
73
- }
74
- requireSHA256Hex(
75
- distribution.artifactSha256,
76
- `catalog app ${appId} distribution.artifactSha256`
77
- );
78
-
79
- const release = releasesByAppID.get(appId);
80
- if (release) {
81
- assertCatalogMatchesRelease(app, release);
82
- }
83
-
84
- checks.push({
85
- appId,
86
- artifactUrl: distribution.artifactUrl,
87
- artifactSha256: distribution.artifactSha256,
88
- artifactSizeBytes: release?.artifactSizeBytes,
89
- source: `catalog ${appId}`
90
- });
91
72
  }
92
73
  }
93
74
 
94
75
  if (verifyArtifacts) {
95
- const artifactDigests = new Map();
96
- for (const check of checks) {
97
- let digest = artifactDigests.get(check.artifactUrl);
98
- if (!digest) {
99
- digest = await digestArtifact(check.artifactUrl);
100
- artifactDigests.set(check.artifactUrl, digest);
101
- }
76
+ for (const check of checks.values()) {
77
+ const digest = await digestArtifact(check.artifactUrl);
102
78
  if (digest.sha256 !== check.artifactSha256.toLowerCase()) {
103
79
  throw new Error(
104
80
  `${check.source} artifact sha256 mismatch: want ${check.artifactSha256} got ${digest.sha256}`
@@ -118,12 +94,49 @@ export async function verifyTuttiAppReleaseArtifacts(options) {
118
94
  return {
119
95
  catalogFile,
120
96
  releaseFiles,
121
- checkedArtifactCount: verifyArtifacts
122
- ? new Set(checks.map((check) => check.artifactUrl)).size
123
- : 0
97
+ versionsFiles,
98
+ checkedArtifactCount: verifyArtifacts ? checks.size : 0
124
99
  };
125
100
  }
126
101
 
102
+ function addCatalogAppCheck(checks, releasesByKey, app, source) {
103
+ const appId = validateCatalogApp(app, source);
104
+ const version = app.manifest.version;
105
+ const release = releasesByKey.get(releaseKey(appId, version));
106
+ if (release) assertCatalogMatchesRelease(app, release);
107
+ addArtifactCheck(
108
+ checks,
109
+ {
110
+ appId,
111
+ version,
112
+ artifactUrl: app.distribution.artifactUrl,
113
+ artifactSha256: app.distribution.artifactSha256,
114
+ artifactSizeBytes: release?.artifactSizeBytes
115
+ },
116
+ `${source} ${appId}@${version}`
117
+ );
118
+ }
119
+
120
+ function addArtifactCheck(checks, release, source) {
121
+ const existing = checks.get(release.artifactUrl);
122
+ const check = {
123
+ artifactUrl: release.artifactUrl,
124
+ artifactSha256: release.artifactSha256,
125
+ artifactSizeBytes: release.artifactSizeBytes,
126
+ source
127
+ };
128
+ if (
129
+ existing &&
130
+ (existing.artifactSha256 !== check.artifactSha256 ||
131
+ (Number.isSafeInteger(existing.artifactSizeBytes) &&
132
+ Number.isSafeInteger(check.artifactSizeBytes) &&
133
+ existing.artifactSizeBytes !== check.artifactSizeBytes))
134
+ ) {
135
+ throw new Error(`artifact metadata conflicts for ${release.artifactUrl}`);
136
+ }
137
+ checks.set(release.artifactUrl, existing ?? check);
138
+ }
139
+
127
140
  function assertCatalogMatchesRelease(app, release) {
128
141
  const appId = release.appId;
129
142
  const distribution = app.distribution;
@@ -157,31 +170,6 @@ function assertCatalogMatchesRelease(app, release) {
157
170
  }
158
171
  }
159
172
 
160
- function validateCatalog(catalog) {
161
- if (!catalog || typeof catalog !== "object") {
162
- throw new Error("catalog must be an object");
163
- }
164
- if (catalog.schemaVersion !== catalogSchemaVersion) {
165
- throw new Error(`catalog schemaVersion must be ${catalogSchemaVersion}`);
166
- }
167
- if (!Array.isArray(catalog.apps)) {
168
- throw new Error("catalog apps must be an array");
169
- }
170
- for (const [index, app] of catalog.apps.entries()) {
171
- if (!app || typeof app !== "object") {
172
- throw new Error(`catalog apps[${index}] must be an object`);
173
- }
174
- if (
175
- !app.manifest ||
176
- typeof app.manifest !== "object" ||
177
- typeof app.manifest.appId !== "string" ||
178
- app.manifest.appId.trim() === ""
179
- ) {
180
- throw new Error(`catalog apps[${index}].manifest.appId is required`);
181
- }
182
- }
183
- }
184
-
185
173
  async function digestArtifact(artifactUrl) {
186
174
  const url = String(artifactUrl).trim();
187
175
  if (url.startsWith("http://") || url.startsWith("https://")) {
@@ -200,9 +188,7 @@ async function digestHTTPArtifact(url) {
200
188
  return await digestHTTPArtifactOnce(url);
201
189
  } catch (error) {
202
190
  lastError = error;
203
- if (attempt < 3) {
204
- await delay(1000 * attempt);
205
- }
191
+ if (attempt < 3) await delay(1000 * attempt);
206
192
  }
207
193
  }
208
194
  throw lastError;
@@ -222,10 +208,7 @@ async function digestHTTPArtifactOnce(url) {
222
208
  hash.update(buffer);
223
209
  size += buffer.length;
224
210
  }
225
- return {
226
- sha256: hash.digest("hex"),
227
- size
228
- };
211
+ return { sha256: hash.digest("hex"), size };
229
212
  }
230
213
 
231
214
  function delay(ms) {
@@ -244,57 +227,46 @@ async function digestFileArtifact(filePath) {
244
227
  stream.on("error", reject);
245
228
  stream.on("end", resolve);
246
229
  });
247
- return {
248
- sha256: hash.digest("hex"),
249
- size
250
- };
230
+ return { sha256: hash.digest("hex"), size };
251
231
  }
252
232
 
253
233
  function normalizeFiles(value) {
254
234
  const files = Array.isArray(value)
255
235
  ? value
256
236
  : String(value ?? "")
257
- .split(/[\n,]/)
237
+ .split(/[\n,]/u)
258
238
  .map((file) => file.trim())
259
239
  .filter(Boolean);
260
240
  return files.map((file) => path.resolve(file));
261
241
  }
262
242
 
263
- function requireSHA256Hex(value, label) {
264
- if (!/^[a-f0-9]{64}$/i.test(String(value ?? ""))) {
265
- throw new Error(`${label} must be a sha256 hex digest`);
266
- }
243
+ function releaseKey(appId, version) {
244
+ return `${appId}@${version}`;
267
245
  }
268
246
 
269
247
  function parseArgs(argv) {
270
248
  const result = {
271
249
  releaseFiles: [],
250
+ versionsFiles: [],
272
251
  verifyArtifacts: true
273
252
  };
274
253
  for (let index = 0; index < argv.length; index += 1) {
275
254
  const arg = argv[index];
276
- if (arg === "--release-file") {
277
- const value = argv[index + 1];
278
- if (!value || value.startsWith("--")) {
279
- throw new Error("missing value for --release-file");
280
- }
281
- result.releaseFiles.push(value);
282
- index += 1;
255
+ if (arg === "--skip-artifact-download") {
256
+ result.verifyArtifacts = false;
283
257
  continue;
284
258
  }
285
- if (arg === "--catalog-file") {
286
- const value = argv[index + 1];
259
+ const value = argv[index + 1];
260
+ if (["--release-file", "--versions-file", "--catalog-file"].includes(arg)) {
287
261
  if (!value || value.startsWith("--")) {
288
- throw new Error("missing value for --catalog-file");
262
+ throw new Error(`missing value for ${arg}`);
289
263
  }
290
- result.catalogFile = value;
264
+ if (arg === "--release-file") result.releaseFiles.push(value);
265
+ if (arg === "--versions-file") result.versionsFiles.push(value);
266
+ if (arg === "--catalog-file") result.catalogFile = value;
291
267
  index += 1;
292
268
  continue;
293
269
  }
294
- if (arg === "--skip-artifact-download") {
295
- result.verifyArtifacts = false;
296
- continue;
297
- }
298
270
  throw new Error(`unexpected argument: ${arg}`);
299
271
  }
300
272
  return result;
package/package.json CHANGED
@@ -1,16 +1,24 @@
1
1
  {
2
2
  "name": "@tutti-os/app-release-tools",
3
- "version": "0.0.78",
3
+ "version": "0.0.79",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "build-tutti-app-release": "./bin/build-tutti-app-release.mjs",
8
8
  "build-tutti-app-catalog": "./bin/build-tutti-app-catalog.mjs",
9
+ "build-tutti-app-versions": "./bin/build-tutti-app-versions.mjs",
10
+ "merge-tutti-app-latest": "./bin/merge-tutti-app-latest.mjs",
11
+ "publish-tutti-app-catalog": "./bin/publish-tutti-app-catalog.mjs",
12
+ "publish-tutti-app-metadata": "./bin/publish-tutti-app-metadata.mjs",
9
13
  "verify-tutti-app-release-artifacts": "./bin/verify-tutti-app-release-artifacts.mjs"
10
14
  },
11
15
  "exports": {
12
16
  "./build-tutti-app-release": "./bin/build-tutti-app-release.mjs",
13
17
  "./build-tutti-app-catalog": "./bin/build-tutti-app-catalog.mjs",
18
+ "./build-tutti-app-versions": "./bin/build-tutti-app-versions.mjs",
19
+ "./merge-tutti-app-latest": "./bin/merge-tutti-app-latest.mjs",
20
+ "./publish-tutti-app-catalog": "./bin/publish-tutti-app-catalog.mjs",
21
+ "./publish-tutti-app-metadata": "./bin/publish-tutti-app-metadata.mjs",
14
22
  "./verify-tutti-app-release-artifacts": "./bin/verify-tutti-app-release-artifacts.mjs"
15
23
  },
16
24
  "files": [
@@ -25,6 +33,9 @@
25
33
  "publishConfig": {
26
34
  "access": "public"
27
35
  },
36
+ "dependencies": {
37
+ "semver": "7.8.0"
38
+ },
28
39
  "scripts": {
29
40
  "build": "node -e \"\"",
30
41
  "test": "node --test ../../../tools/scripts/build-tutti-app-release.test.mjs"